diff options
Diffstat (limited to 'mobile')
1612 files changed, 308654 insertions, 0 deletions
diff --git a/mobile/.eslintrc.js b/mobile/.eslintrc.js new file mode 100644 index 0000000000..bde25b2023 --- /dev/null +++ b/mobile/.eslintrc.js @@ -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/. */ + +"use strict"; + +module.exports = { + rules: { + "prefer-const": "error", + }, +}; diff --git a/mobile/android/.eslintrc.js b/mobile/android/.eslintrc.js new file mode 100644 index 0000000000..05a1def154 --- /dev/null +++ b/mobile/android/.eslintrc.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + globals, +} = require("../../toolkit/components/extensions/parent/.eslintrc.js"); + +module.exports = { + overrides: [ + { + files: ["components/extensions/ext-*.js"], + excludedFiles: ["components/extensions/ext-c-*.js"], + globals: { + ...globals, + // These globals are defined in ext-android.js and can only be used in + // the extension files that run in the parent process. + EventDispatcher: true, + ExtensionError: true, + makeGlobalEvent: true, + TabContext: true, + tabTracker: true, + windowTracker: true, + }, + }, + { + files: [ + "chrome/geckoview/**", + "components/geckoview/**", + "modules/geckoview/**", + "actors/**", + ], + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + varsIgnorePattern: "(debug|warn)", + }, + ], + "no-restricted-syntax": [ + "error", + { + selector: `CallExpression > \ + Identifier.callee[name = /^debug$|^warn$/]`, + message: + "Use debug and warn with template literals, e.g. debug `foo`;", + }, + { + selector: `BinaryExpression[operator = '+'] > \ + TaggedTemplateExpression.left > \ + Identifier.tag[name = /^debug$|^warn$/]`, + message: + "Use only one template literal with debug/warn instead of concatenating multiple expressions,\n" + + " e.g. (debug `foo ${42} bar`) instead of (debug `foo` + 42 + `bar`)", + }, + { + selector: `TaggedTemplateExpression[tag.type = 'Identifier'][tag.name = /^debug$|^warn$/] > \ + TemplateLiteral.quasi CallExpression > \ + MemberExpression.callee[object.type = 'Identifier'][object.name = 'JSON'] > \ + Identifier.property[name = 'stringify']`, + message: + "Don't call JSON.stringify within debug/warn literals,\n" + + " e.g. (debug `foo=${foo}`) instead of (debug `foo=${JSON.stringify(foo)}`)", + }, + ], + }, + }, + ], +}; diff --git a/mobile/android/LICENSE b/mobile/android/LICENSE new file mode 100644 index 0000000000..14e2f777f6 --- /dev/null +++ b/mobile/android/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/mobile/android/actors/ContentDelegateChild.sys.mjs b/mobile/android/actors/ContentDelegateChild.sys.mjs new file mode 100644 index 0000000000..3fb9e4ff07 --- /dev/null +++ b/mobile/android/actors/ContentDelegateChild.sys.mjs @@ -0,0 +1,167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ManifestObtainer: "resource://gre/modules/ManifestObtainer.sys.mjs", +}); + +export class ContentDelegateChild extends GeckoViewActorChild { + notifyParentOfViewportFit() { + if (this.triggerViewportFitChange) { + this.contentWindow.cancelIdleCallback(this.triggerViewportFitChange); + } + this.triggerViewportFitChange = this.contentWindow.requestIdleCallback( + () => { + this.triggerViewportFitChange = null; + const viewportFit = this.contentWindow.windowUtils.getViewportFitInfo(); + if (this.lastViewportFit === viewportFit) { + return; + } + this.lastViewportFit = viewportFit; + this.eventDispatcher.sendRequest({ + type: "GeckoView:DOMMetaViewportFit", + viewportfit: viewportFit, + }); + } + ); + } + + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "contextmenu": { + if (aEvent.defaultPrevented) { + return; + } + + function nearestParentAttribute(aNode, aAttribute) { + while ( + aNode && + aNode.hasAttribute && + !aNode.hasAttribute(aAttribute) + ) { + aNode = aNode.parentNode; + } + return aNode && aNode.getAttribute && aNode.getAttribute(aAttribute); + } + + function createAbsoluteUri(aBaseUri, aUri) { + if (!aUri || !aBaseUri || !aBaseUri.displaySpec) { + return null; + } + return Services.io.newURI(aUri, null, aBaseUri).displaySpec; + } + + const node = aEvent.composedTarget; + const baseUri = node.ownerDocument.baseURIObject; + const uri = createAbsoluteUri( + baseUri, + nearestParentAttribute(node, "href") + ); + const title = nearestParentAttribute(node, "title"); + const alt = nearestParentAttribute(node, "alt"); + const elementType = ChromeUtils.getClassName(node); + const isImage = elementType === "HTMLImageElement"; + const isMedia = + elementType === "HTMLVideoElement" || + elementType === "HTMLAudioElement"; + let elementSrc = (isImage || isMedia) && (node.currentSrc || node.src); + if (elementSrc) { + const isBlob = elementSrc.startsWith("blob:"); + if (isBlob && !URL.isValidObjectURL(elementSrc)) { + elementSrc = null; + } + } + + if (uri || isImage || isMedia) { + const msg = { + type: "GeckoView:ContextMenu", + // We don't have full zoom on Android, so using CSS coordinates + // here is fine, since the CSS coordinate spaces match between the + // child and parent processes. + screenX: aEvent.screenX, + screenY: aEvent.screenY, + baseUri: (baseUri && baseUri.displaySpec) || null, + uri, + title, + alt, + elementType, + elementSrc: elementSrc || null, + textContent: node.textContent || null, + }; + + this.eventDispatcher.sendRequest(msg); + aEvent.preventDefault(); + } + break; + } + case "MozDOMFullscreen:Request": { + this.sendAsyncMessage("GeckoView:DOMFullscreenRequest", {}); + break; + } + case "MozDOMFullscreen:Entered": + case "MozDOMFullscreen:Exited": + // Content may change fullscreen state by itself, and we should ensure + // that the parent always exits fullscreen when content has left + // full screen mode. + if (this.contentWindow?.document.fullscreenElement) { + break; + } + // fall-through + case "MozDOMFullscreen:Exit": + this.sendAsyncMessage("GeckoView:DOMFullscreenExit", {}); + break; + case "DOMMetaViewportFitChanged": + if (aEvent.originalTarget.ownerGlobal == this.contentWindow) { + this.notifyParentOfViewportFit(); + } + break; + case "DOMContentLoaded": { + if (aEvent.originalTarget.ownerGlobal == this.contentWindow) { + // If loaded content doesn't have viewport-fit, parent still + // uses old value of previous content. + this.notifyParentOfViewportFit(); + } + if (this.contentWindow !== this.contentWindow?.top) { + // Only check WebApp manifest on the top level window. + return; + } + this.contentWindow.requestIdleCallback(async () => { + const manifest = await lazy.ManifestObtainer.contentObtainManifest( + this.contentWindow + ); + if (manifest) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:WebAppManifest", + manifest, + }); + } + }); + break; + } + case "MozFirstContentfulPaint": { + this.eventDispatcher.sendRequest({ + type: "GeckoView:FirstContentfulPaint", + }); + break; + } + case "MozPaintStatusReset": { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PaintStatusReset", + }); + break; + } + } + } +} + +const { debug, warn } = ContentDelegateChild.initLogging( + "ContentDelegateChild" +); diff --git a/mobile/android/actors/ContentDelegateParent.sys.mjs b/mobile/android/actors/ContentDelegateParent.sys.mjs new file mode 100644 index 0000000000..d621c80104 --- /dev/null +++ b/mobile/android/actors/ContentDelegateParent.sys.mjs @@ -0,0 +1,75 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("ContentDelegateParent"); + +export class ContentDelegateParent extends GeckoViewActorParent { + didDestroy() { + this._didDestroy = true; + } + + async receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "GeckoView:DOMFullscreenExit": { + if (!this.#hasBeenDestroyed() && !this.#requestOrigin) { + this.#requestOrigin = this; + } + this.window.windowUtils.remoteFrameFullscreenReverted(); + return null; + } + + case "GeckoView:DOMFullscreenRequest": { + this.#requestOrigin = this; + this.window.windowUtils.remoteFrameFullscreenChanged(this.browser); + return null; + } + } + + return super.receiveMessage(aMsg); + } + + // This is a copy of browser/actors/DOMFullscreenParent.sys.mjs + get #requestOrigin() { + const chromeBC = this.browsingContext.topChromeWindow?.browsingContext; + const requestOrigin = chromeBC?.fullscreenRequestOrigin; + return requestOrigin && requestOrigin.get(); + } + + // This is a copy of browser/actors/DOMFullscreenParent.sys.mjs + set #requestOrigin(aActor) { + const chromeBC = this.browsingContext.topChromeWindow?.browsingContext; + if (!chromeBC) { + debug`not able to get browsingContext for chrome window.`; + return; + } + + if (aActor) { + chromeBC.fullscreenRequestOrigin = Cu.getWeakReference(aActor); + return; + } + delete chromeBC.fullscreenRequestOrigin; + } + + // This is a copy of browser/actors/DOMFullscreenParent.sys.mjs + #hasBeenDestroyed() { + if (this._didDestroy) { + return true; + } + + // The 'didDestroy' callback is not always getting called. + // So we can't rely on it here. Instead, we will try to access + // the browsing context to judge wether the actor has + // been destroyed or not. + try { + return !this.browsingContext; + } catch { + return true; + } + } +} diff --git a/mobile/android/actors/GeckoViewAutoFillChild.sys.mjs b/mobile/android/actors/GeckoViewAutoFillChild.sys.mjs new file mode 100644 index 0000000000..bd49b7aaf3 --- /dev/null +++ b/mobile/android/actors/GeckoViewAutoFillChild.sys.mjs @@ -0,0 +1,395 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", + LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", +}); + +export class GeckoViewAutoFillChild extends GeckoViewActorChild { + constructor() { + super(); + + this._autofillElements = undefined; + this._autofillInfos = undefined; + } + + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "DOMFormHasPassword": { + this.addElement( + lazy.FormLikeFactory.createFromForm(aEvent.composedTarget) + ); + break; + } + case "DOMInputPasswordAdded": { + const input = aEvent.composedTarget; + if (!input.form) { + this.addElement(lazy.FormLikeFactory.createFromField(input)); + } + break; + } + case "focusin": { + const element = aEvent.composedTarget; + if (!this.contentWindow.HTMLInputElement.isInstance(element)) { + break; + } + GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => { + if (Cu.isDeadWrapper(element)) { + // Focus element is removed or document is navigated to new page. + return; + } + const focusedElement = + Services.focus.focusedElement || + element.ownerDocument?.activeElement; + if (element == focusedElement) { + this.onFocus(focusedElement); + } + }); + break; + } + case "focusout": { + if ( + this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget) + ) { + this.onFocus(null); + } + break; + } + case "pagehide": { + if (aEvent.target === this.document) { + this.clearElements(this.browsingContext); + } + break; + } + case "pageshow": { + if (aEvent.target === this.document) { + this.scanDocument(this.document); + } + break; + } + case "PasswordManager:ShowDoorhanger": { + const { form: formLike } = aEvent.detail; + this.commitAutofill(formLike); + break; + } + } + } + + /** + * Process an auto-fillable form and send the relevant details of the form + * to Java. Multiple calls within a short time period for the same form are + * coalesced, so that, e.g., if multiple inputs are added to a form in + * succession, we will only perform one processing pass. Note that for inputs + * without forms, FormLikeFactory treats the document as the "form", but + * there is no difference in how we process them. + * + * @param aFormLike A FormLike object produced by FormLikeFactory. + */ + async addElement(aFormLike) { + debug`Adding auto-fill ${aFormLike.rootElement.tagName}`; + + const window = aFormLike.rootElement.ownerGlobal; + // Get password field to get better form data via LoginManagerChild. + let passwordField; + for (const field of aFormLike.elements) { + if ( + ChromeUtils.getClassName(field) === "HTMLInputElement" && + field.type == "password" + ) { + passwordField = field; + break; + } + } + + const loginManagerChild = lazy.LoginManagerChild.forWindow(window); + const docState = loginManagerChild.stateForDocument( + passwordField.ownerDocument + ); + const [usernameField] = docState.getUserNameAndPasswordFields( + passwordField || aFormLike.elements[0] + ); + + const focusedElement = aFormLike.rootElement.ownerDocument.activeElement; + let sendFocusEvent = aFormLike.rootElement === focusedElement; + + const rootInfo = this._getInfo( + aFormLike.rootElement, + null, + undefined, + null + ); + + rootInfo.rootUuid = rootInfo.uuid; + rootInfo.children = aFormLike.elements + .filter( + element => + element.type != "hidden" && + (!usernameField || + element.type != "text" || + element == usernameField || + (element.getAutocompleteInfo() && + element.getAutocompleteInfo().fieldName == "email")) + ) + .map(element => { + sendFocusEvent |= element === focusedElement; + return this._getInfo( + element, + rootInfo.uuid, + rootInfo.uuid, + usernameField + ); + }); + + try { + // We don't await here so that we can send a focus event immediately + // after this as the app might not know which element is focused. + const responsePromise = this.sendQuery("Add", { + node: rootInfo, + }); + + if (sendFocusEvent) { + // We might have missed sending a focus event for the active element. + this.onFocus(aFormLike.ownerDocument.activeElement); + } + + const responses = await responsePromise; + // `responses` is an object with global IDs as keys. + debug`Performing auto-fill ${Object.keys(responses)}`; + + const AUTOFILL_STATE = "autofill"; + const winUtils = window.windowUtils; + + for (const uuid in responses) { + const entry = + this._autofillElements && this._autofillElements.get(uuid); + const element = entry && entry.get(); + const value = responses[uuid] || ""; + + if ( + window.HTMLInputElement.isInstance(element) && + !element.disabled && + element.parentElement + ) { + element.setUserInput(value); + if (winUtils && element.value === value) { + // Add highlighting for autofilled fields. + winUtils.addManuallyManagedState(element, AUTOFILL_STATE); + + // Remove highlighting when the field is changed. + element.addEventListener( + "input", + _ => winUtils.removeManuallyManagedState(element, AUTOFILL_STATE), + { mozSystemGroup: true, once: true } + ); + } + } else if (element) { + warn`Don't know how to auto-fill ${element.tagName}`; + } + } + } catch (error) { + warn`Cannot perform autofill ${error}`; + } + } + + _getInfo(aElement, aParent, aRoot, aUsernameField) { + if (!this._autofillInfos) { + this._autofillInfos = new WeakMap(); + this._autofillElements = new Map(); + } + + let info = this._autofillInfos.get(aElement); + if (info) { + return info; + } + + const window = aElement.ownerGlobal; + const bounds = aElement.getBoundingClientRect(); + const isInputElement = window.HTMLInputElement.isInstance(aElement); + + info = { + isInputElement, + uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces + parentUuid: aParent, + rootUuid: aRoot, + tag: aElement.tagName, + type: isInputElement ? aElement.type : null, + value: isInputElement ? aElement.value : null, + editable: + isInputElement && + [ + "color", + "date", + "datetime-local", + "email", + "month", + "number", + "password", + "range", + "search", + "tel", + "text", + "time", + "url", + "week", + ].includes(aElement.type), + disabled: isInputElement ? aElement.disabled : null, + attributes: Object.assign( + {}, + ...Array.from(aElement.attributes) + .filter(attr => attr.localName !== "value") + .map(attr => ({ [attr.localName]: attr.value })) + ), + origin: aElement.ownerDocument.location.origin, + autofillhint: "", + bounds: { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + }, + }; + + if (aElement === aUsernameField) { + info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME + } else if (isInputElement) { + // Using autocomplete attribute if it is email. + const autocompleteInfo = aElement.getAutocompleteInfo(); + if (autocompleteInfo) { + const autocompleteAttr = autocompleteInfo.fieldName; + if (autocompleteAttr == "email") { + info.type = "email"; + } + } + } + + this._autofillInfos.set(aElement, info); + this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement)); + return info; + } + + _updateInfoValues(aElements) { + if (!this._autofillInfos) { + return []; + } + + const updated = []; + for (const element of aElements) { + const info = this._autofillInfos.get(element); + + if (!info?.isInputElement || info.value === element.value) { + continue; + } + debug`Updating value ${info.value} to ${element.value}`; + + info.value = element.value; + this._autofillInfos.set(element, info); + updated.push(info); + } + return updated; + } + + /** + * Called when an auto-fillable field is focused or blurred. + * + * @param aTarget Focused element, or null if an element has lost focus. + */ + onFocus(aTarget) { + debug`Auto-fill focus on ${aTarget && aTarget.tagName}`; + + const info = aTarget && this._autofillInfos?.get(aTarget); + if (info) { + const bounds = aTarget.getBoundingClientRect(); + const screenRect = lazy.LayoutUtils.rectToScreenRect( + aTarget.ownerGlobal, + bounds + ); + info.screenRect = { + left: screenRect.left, + top: screenRect.top, + right: screenRect.right, + bottom: screenRect.bottom, + }; + } + + if (!aTarget || info) { + this.sendAsyncMessage("Focus", { + node: info, + }); + } + } + + commitAutofill(aFormLike) { + if (!aFormLike) { + throw new Error("null-form on autofill commit"); + } + + debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`; + + const updatedNodeInfos = this._updateInfoValues([ + aFormLike.rootElement, + ...aFormLike.elements, + ]); + + for (const updatedInfo of updatedNodeInfos) { + debug`Updating node ${updatedInfo}`; + this.sendAsyncMessage("Update", { + node: updatedInfo, + }); + } + + const info = this._getInfo(aFormLike.rootElement); + if (info) { + debug`Committing node ${info}`; + this.sendAsyncMessage("Commit", { + node: info, + }); + } + } + + /** + * Clear all tracked auto-fill forms and notify Java. + */ + clearElements(browsingContext) { + this._autofillInfos = undefined; + this._autofillElements = undefined; + + if (browsingContext === browsingContext.top) { + this.sendAsyncMessage("Clear"); + } + } + + /** + * Scan for auto-fillable forms and add them if necessary. Called when a page + * is navigated to through history, in which case we don't get our typical + * "input added" notifications. + * + * @param aDoc Document to scan. + */ + scanDocument(aDoc) { + // Add forms first; only check forms with password inputs. + const inputs = aDoc.querySelectorAll("input[type=password]"); + let inputAdded = false; + for (let i = 0; i < inputs.length; i++) { + if (inputs[i].form) { + // Let addElement coalesce multiple calls for the same form. + this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form)); + } else if (!inputAdded) { + // Treat inputs without forms as one unit, and process them only once. + inputAdded = true; + this.addElement(lazy.FormLikeFactory.createFromField(inputs[i])); + } + } + } +} + +const { debug, warn } = GeckoViewAutoFillChild.initLogging("GeckoViewAutoFill"); diff --git a/mobile/android/actors/GeckoViewAutoFillParent.sys.mjs b/mobile/android/actors/GeckoViewAutoFillParent.sys.mjs new file mode 100644 index 0000000000..d7248d61fe --- /dev/null +++ b/mobile/android/actors/GeckoViewAutoFillParent.sys.mjs @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + gAutofillManager: "resource://gre/modules/GeckoViewAutofill.sys.mjs", +}); + +export class GeckoViewAutoFillParent extends GeckoViewActorParent { + constructor() { + super(); + this.sessionId = Services.uuid.generateUUID().toString().slice(1, -1); // discard the surrounding curly braces + } + + get rootActor() { + return this.browsingContext.top.currentWindowGlobal.getActor( + "GeckoViewAutoFill" + ); + } + + get autofill() { + return lazy.gAutofillManager.get(this.sessionId); + } + + add(node) { + // We will start a new session if the current one does not exist. + const autofill = lazy.gAutofillManager.ensure( + this.sessionId, + this.eventDispatcher + ); + return autofill?.add(node); + } + + focus(node) { + this.autofill?.focus(node); + } + + commit(node) { + this.autofill?.commit(node); + } + + update(node) { + this.autofill?.update(node); + } + + clear() { + lazy.gAutofillManager.delete(this.sessionId); + } + + async receiveMessage(aMessage) { + const { name } = aMessage; + debug`receiveMessage ${name}`; + + // We need to re-route all messages through the root actor to ensure that we + // have a consistent sessionId for the entire browsingContext tree. + switch (name) { + case "Add": { + return this.rootActor.add(aMessage.data.node); + } + case "Focus": { + this.rootActor.focus(aMessage.data.node); + break; + } + case "Update": { + this.rootActor.update(aMessage.data.node); + break; + } + case "Commit": { + this.rootActor.commit(aMessage.data.node); + break; + } + case "Clear": { + if (this.browsingContext === this.browsingContext.top) { + this.clear(); + } + break; + } + } + + return null; + } +} + +const { debug, warn } = + GeckoViewAutoFillParent.initLogging("GeckoViewAutoFill"); diff --git a/mobile/android/actors/GeckoViewContentChild.sys.mjs b/mobile/android/actors/GeckoViewContentChild.sys.mjs new file mode 100644 index 0000000000..97691c97fd --- /dev/null +++ b/mobile/android/actors/GeckoViewContentChild.sys.mjs @@ -0,0 +1,335 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +// This needs to match ScreenLength.java +const SCREEN_LENGTH_TYPE_PIXEL = 0; +const SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_WIDTH = 1; +const SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_HEIGHT = 2; +const SCREEN_LENGTH_DOCUMENT_WIDTH = 3; +const SCREEN_LENGTH_DOCUMENT_HEIGHT = 4; + +// This need to match PanZoomController.java +const SCROLL_BEHAVIOR_SMOOTH = 0; +const SCROLL_BEHAVIOR_AUTO = 1; + +const SCREEN_ORIENTATION_PORTRAIT = 0; +const SCREEN_ORIENTATION_LANDSCAPE = 1; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivacyFilter: "resource://gre/modules/sessionstore/PrivacyFilter.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs", +}); + +export class GeckoViewContentChild extends GeckoViewActorChild { + constructor() { + super(); + this.lastOrientation = SCREEN_ORIENTATION_PORTRAIT; + } + + actorCreated() { + super.actorCreated(); + + this.pageShow = new Promise(resolve => { + this.receivedPageShow = resolve; + }); + } + + toPixels(aLength, aType) { + const { contentWindow } = this; + if (aType === SCREEN_LENGTH_TYPE_PIXEL) { + return aLength; + } else if (aType === SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_WIDTH) { + return aLength * contentWindow.visualViewport.width; + } else if (aType === SCREEN_LENGTH_TYPE_VISUAL_VIEWPORT_HEIGHT) { + return aLength * contentWindow.visualViewport.height; + } else if (aType === SCREEN_LENGTH_DOCUMENT_WIDTH) { + return aLength * contentWindow.document.body.scrollWidth; + } else if (aType === SCREEN_LENGTH_DOCUMENT_HEIGHT) { + return aLength * contentWindow.document.body.scrollHeight; + } + + return aLength; + } + + toScrollBehavior(aBehavior) { + const { contentWindow } = this; + if (!contentWindow) { + return 0; + } + const { windowUtils } = contentWindow; + if (aBehavior === SCROLL_BEHAVIOR_SMOOTH) { + return windowUtils.SCROLL_MODE_SMOOTH; + } else if (aBehavior === SCROLL_BEHAVIOR_AUTO) { + return windowUtils.SCROLL_MODE_INSTANT; + } + return windowUtils.SCROLL_MODE_SMOOTH; + } + + collectSessionState() { + const { docShell, contentWindow } = this; + const history = lazy.SessionHistory.collect(docShell); + let formdata = SessionStoreUtils.collectFormData(contentWindow); + let scrolldata = SessionStoreUtils.collectScrollPosition(contentWindow); + + // Save the current document resolution. + let zoom = 1; + const domWindowUtils = contentWindow.windowUtils; + zoom = domWindowUtils.getResolution(); + scrolldata = scrolldata || {}; + scrolldata.zoom = {}; + scrolldata.zoom.resolution = zoom; + + // Save some data that'll help in adjusting the zoom level + // when restoring in a different screen orientation. + const displaySize = {}; + const width = {}, + height = {}; + domWindowUtils.getDocumentViewerSize(width, height); + + displaySize.width = width.value; + displaySize.height = height.value; + + scrolldata.zoom.displaySize = displaySize; + + formdata = lazy.PrivacyFilter.filterFormData(formdata || {}); + + return { history, formdata, scrolldata }; + } + + orientation() { + const currentOrientationType = this.contentWindow?.screen.orientation.type; + if (!currentOrientationType) { + // Unfortunately, we don't know current screen orientation. + // Return portrait as default. + return SCREEN_ORIENTATION_PORTRAIT; + } + if (currentOrientationType.startsWith("landscape")) { + return SCREEN_ORIENTATION_LANDSCAPE; + } + return SCREEN_ORIENTATION_PORTRAIT; + } + + receiveMessage(message) { + const { name } = message; + debug`receiveMessage: ${name}`; + + switch (name) { + case "GeckoView:DOMFullscreenEntered": + this.lastOrientation = this.orientation(); + if ( + !this.contentWindow?.windowUtils.handleFullscreenRequests() && + !this.contentWindow?.document.fullscreenElement + ) { + // If we don't actually have any pending fullscreen request + // to handle, neither we have been in fullscreen, tell the + // parent to just exit. + const actor = + this.contentWindow?.windowGlobalChild?.getActor("ContentDelegate"); + actor?.sendAsyncMessage("GeckoView:DOMFullscreenExit", {}); + } + break; + case "GeckoView:DOMFullscreenExited": + // During fullscreen, window size is changed. So don't restore viewport size. + const restoreViewSize = this.orientation() == this.lastOrientation; + this.contentWindow?.windowUtils.exitFullscreen(!restoreViewSize); + break; + case "GeckoView:ZoomToInput": { + const { contentWindow } = this; + const dwu = contentWindow.windowUtils; + + const zoomToFocusedInput = function () { + if (!dwu.flushApzRepaints()) { + dwu.zoomToFocusedInput(); + return; + } + Services.obs.addObserver(function apzFlushDone() { + Services.obs.removeObserver(apzFlushDone, "apz-repaints-flushed"); + dwu.zoomToFocusedInput(); + }, "apz-repaints-flushed"); + }; + + const { force } = message.data; + + let gotResize = false; + const onResize = function () { + gotResize = true; + if (dwu.isMozAfterPaintPending) { + contentWindow.windowRoot.addEventListener( + "MozAfterPaint", + () => zoomToFocusedInput(), + { capture: true, once: true } + ); + } else { + zoomToFocusedInput(); + } + }; + + contentWindow.addEventListener("resize", onResize, { capture: true }); + + // When the keyboard is displayed, we can get one resize event, + // multiple resize events, or none at all. Try to handle all these + // cases by allowing resizing within a set interval, and still zoom to + // input if there is no resize event at the end of the interval. + contentWindow.setTimeout(() => { + contentWindow.removeEventListener("resize", onResize, { + capture: true, + }); + if (!gotResize && force) { + onResize(); + } + }, 500); + break; + } + case "RestoreSessionState": { + this.restoreSessionState(message); + break; + } + case "RestoreHistoryAndNavigate": { + const { history, switchId } = message.data; + if (history) { + lazy.SessionHistory.restore(this.docShell, history); + const historyIndex = history.requestedIndex - 1; + const webNavigation = this.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + + if (!switchId) { + // TODO: Bug 1648158 This won't work for Fission or HistoryInParent. + webNavigation.sessionHistory.legacySHistory.reloadCurrentEntry(); + } else { + webNavigation.resumeRedirectedLoad(switchId, historyIndex); + } + } + break; + } + case "GeckoView:UpdateInitData": { + // Provide a hook for native code to detect a transfer. + Services.obs.notifyObservers( + this.docShell, + "geckoview-content-global-transferred" + ); + break; + } + case "GeckoView:ScrollBy": { + const x = {}; + const y = {}; + const { contentWindow } = this; + const { widthValue, widthType, heightValue, heightType, behavior } = + message.data; + contentWindow.windowUtils.getVisualViewportOffset(x, y); + contentWindow.windowUtils.scrollToVisual( + x.value + this.toPixels(widthValue, widthType), + y.value + this.toPixels(heightValue, heightType), + contentWindow.windowUtils.UPDATE_TYPE_MAIN_THREAD, + this.toScrollBehavior(behavior) + ); + break; + } + case "GeckoView:ScrollTo": { + const { contentWindow } = this; + const { widthValue, widthType, heightValue, heightType, behavior } = + message.data; + contentWindow.windowUtils.scrollToVisual( + this.toPixels(widthValue, widthType), + this.toPixels(heightValue, heightType), + contentWindow.windowUtils.UPDATE_TYPE_MAIN_THREAD, + this.toScrollBehavior(behavior) + ); + break; + } + case "CollectSessionState": { + return this.collectSessionState(); + } + case "ContainsFormData": { + return this.containsFormData(); + } + } + + return null; + } + + async containsFormData() { + const { contentWindow } = this; + let formdata = SessionStoreUtils.collectFormData(contentWindow); + formdata = lazy.PrivacyFilter.filterFormData(formdata || {}); + if (formdata) { + return true; + } + return false; + } + + async restoreSessionState(message) { + // Make sure we showed something before restoring scrolling and form data + await this.pageShow; + + const { contentWindow } = this; + const { formdata, scrolldata } = message.data; + + if (formdata) { + lazy.Utils.restoreFrameTreeData( + contentWindow, + formdata, + (frame, data) => { + // restore() will return false, and thus abort restoration for the + // current |frame| and its descendants, if |data.url| is given but + // doesn't match the loaded document's URL. + return SessionStoreUtils.restoreFormData(frame.document, data); + } + ); + } + + if (scrolldata) { + lazy.Utils.restoreFrameTreeData( + contentWindow, + scrolldata, + (frame, data) => { + if (data.scroll) { + SessionStoreUtils.restoreScrollPosition(frame, data); + } + } + ); + } + + if (scrolldata && scrolldata.zoom && scrolldata.zoom.displaySize) { + const utils = contentWindow.windowUtils; + // Restore zoom level. + utils.setRestoreResolution( + scrolldata.zoom.resolution, + scrolldata.zoom.displaySize.width, + scrolldata.zoom.displaySize.height + ); + } + } + + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "pageshow": { + this.receivedPageShow(); + break; + } + + case "mozcaretstatechanged": + if ( + aEvent.reason === "presscaret" || + aEvent.reason === "releasecaret" + ) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PinOnScreen", + pinned: aEvent.reason === "presscaret", + }); + } + break; + } + } +} + +const { debug, warn } = GeckoViewContentChild.initLogging("GeckoViewContent"); diff --git a/mobile/android/actors/GeckoViewContentParent.sys.mjs b/mobile/android/actors/GeckoViewContentParent.sys.mjs new file mode 100644 index 0000000000..354489054a --- /dev/null +++ b/mobile/android/actors/GeckoViewContentParent.sys.mjs @@ -0,0 +1,84 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewContentParent"); + +export class GeckoViewContentParent extends GeckoViewActorParent { + async collectState() { + return this.sendQuery("CollectSessionState"); + } + + async containsFormData() { + return this.sendQuery("ContainsFormData"); + } + + restoreState({ history, switchId, formdata, scrolldata }) { + if (Services.appinfo.sessionHistoryInParent) { + const { browsingContext } = this.browser; + lazy.SessionHistory.restoreFromParent( + browsingContext.sessionHistory, + history + ); + + // TODO Bug 1648158 this should include scroll, form history, etc + return SessionStoreUtils.initializeRestore( + browsingContext, + SessionStoreUtils.constructSessionStoreRestoreData() + ); + } + + // Restoring is made of two parts. First we need to restore the history + // of the tab and navigating to the current page, after the page + // navigates to the current page we need to restore the state of the + // page (scroll position, form data, etc). + // + // We can't do everything in one step inside the child actor because + // the actor is recreated when navigating, so we need to keep the state + // on the parent side until we navigate. + this.sendAsyncMessage("RestoreHistoryAndNavigate", { + history, + switchId, + }); + + if (!formdata && !scrolldata) { + return null; + } + + const progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + + const { browser } = this; + const progressListener = { + QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener"]), + + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + if (!aWebProgress.isTopLevel) { + return; + } + // The actor might get recreated between navigations so we need to + // query it again for the up-to-date instance. + browser.browsingContext.currentWindowGlobal + .getActor("GeckoViewContent") + .sendAsyncMessage("RestoreSessionState", { formdata, scrolldata }); + progressFilter.removeProgressListener(this); + browser.removeProgressListener(progressFilter); + }, + }; + + const flags = Ci.nsIWebProgress.NOTIFY_LOCATION; + progressFilter.addProgressListener(progressListener, flags); + browser.addProgressListener(progressFilter, flags); + return null; + } +} diff --git a/mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs b/mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs new file mode 100644 index 0000000000..5e83ef9063 --- /dev/null +++ b/mobile/android/actors/GeckoViewExperimentDelegateParent.sys.mjs @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +export class GeckoViewExperimentDelegateParent extends GeckoViewActorParent { + constructor() { + super(); + } + + /** + * Gets experiment information on a given feature. + * + * @param feature the experiment item to retrieve information on + * @returns a promise of success with a JSON message or failure + */ + async getExperimentFeature(feature) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:GetExperimentFeature", + feature, + }); + } + + /** + * Records an exposure event, that the experiment area was encountered, on a given feature. + * + * @param feature the experiment item to record an exposure event of + * @returns a promise of success or failure + */ + async recordExposure(feature) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:RecordExposure", + feature, + }); + } + + /** + * Records an exposure event on a specific experiment feature and element. + * + * Note: Use recordExposure, if the slug is not known. + * + * @param feature the experiment item to record an exposure event of + * @param slug a specific experiment element + * @returns a promise of success or failure + */ + async recordExperimentExposure(feature, slug) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:RecordExperimentExposure", + feature, + slug, + }); + } + + /** + * For recording malformed configuration. + * + * @param feature the experiment item to record an exposure event of + * @param part malformed information to send + * @returns a promise of success or failure + */ + async recordExperimentMalformedConfig(feature, part) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:RecordMalformedConfig", + feature, + part, + }); + } +} diff --git a/mobile/android/actors/GeckoViewFormValidationChild.sys.mjs b/mobile/android/actors/GeckoViewFormValidationChild.sys.mjs new file mode 100644 index 0000000000..869271cfe7 --- /dev/null +++ b/mobile/android/actors/GeckoViewFormValidationChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export class GeckoViewFormValidationChild extends GeckoViewActorChild { + handleEvent(aEvent) { + switch (aEvent.type) { + case "MozInvalidForm": { + // Handle invalid form submission. If we don't hook up to this, + // invalid forms are allowed to be submitted! + aEvent.preventDefault(); + // We should show the validation message, bug 1510450. + break; + } + } + } +} diff --git a/mobile/android/actors/GeckoViewPermissionChild.sys.mjs b/mobile/android/actors/GeckoViewPermissionChild.sys.mjs new file mode 100644 index 0000000000..bbe9457cc5 --- /dev/null +++ b/mobile/android/actors/GeckoViewPermissionChild.sys.mjs @@ -0,0 +1,188 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", +}); + +const PERM_ACCESS_FINE_LOCATION = "android.permission.ACCESS_FINE_LOCATION"; + +const MAPPED_TO_EXTENSION_PERMISSIONS = [ + "persistent-storage", + // TODO(Bug 1336194): support geolocation manifest permission + // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1336194#c17)l +]; + +export class GeckoViewPermissionChild extends GeckoViewActorChild { + getMediaPermission(aPermission) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:MediaPermission", + ...aPermission, + }); + } + + addCameraPermission() { + return this.sendQuery("AddCameraPermission"); + } + + getAppPermissions(aPermissions) { + return this.sendQuery("GetAppPermissions", aPermissions); + } + + mediaRecordingStatusChanged(aDevices) { + return this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaRecordingStatusChanged", + devices: aDevices, + }); + } + + // Some WebAPI permissions can be requested and granted to extensions through the + // the extension manifest.json, which the user have been already prompted for + // (e.g. at install time for the one listed in the manifest.json permissions property, + // or at runtime through the optional_permissions property and the permissions.request + // WebExtensions API method). + // + // WebAPI permission that are expected to be mapped to extensions permissions are listed + // in the MAPPED_TO_EXTENSION_PERMISSIONS array. + // + // @param {nsIContentPermissionType} perm + // The WebAPI permission being requested + // @param {nsIContentPermissionRequest} aRequest + // The nsIContentPermissionRequest as received by the promptPermission method. + // + // @returns {null | { allow: boolean, permission: Object } + // Returns null if the request was not handled and should continue with the + // regular permission prompting flow, otherwise it returns an object to + // allow or disallow the permission request right away. + checkIfGrantedByExtensionPermissions(perm, aRequest) { + if (!aRequest.principal.addonPolicy) { + // Not an extension, continue with the regular permission prompting flow. + return null; + } + + // Return earlier and continue with the regular permission prompting flow if the + // the permission is no one that can be requested from the extension manifest file. + if (!MAPPED_TO_EXTENSION_PERMISSIONS.includes(perm.type)) { + return null; + } + + // Disallow if the extension is not active anymore. + if (!aRequest.principal.addonPolicy.active) { + return { allow: false }; + } + + // Check if the permission have been already granted to the extension, if it is allow it right away. + const isGranted = + Services.perms.testPermissionFromPrincipal( + aRequest.principal, + perm.type + ) === Services.perms.ALLOW_ACTION; + if (isGranted) { + return { + allow: true, + permission: { [perm.type]: Services.perms.ALLOW_ACTION }, + }; + } + + // continue with the regular permission prompting flow otherwise. + return null; + } + + async promptPermission(aRequest) { + // Only allow exactly one permission request here. + const types = aRequest.types.QueryInterface(Ci.nsIArray); + if (types.length !== 1) { + return { allow: false }; + } + + const perm = types.queryElementAt(0, Ci.nsIContentPermissionType); + + // Check if the request principal is an extension principal and if the permission requested + // should be already granted based on the extension permissions (or disallowed right away + // because the extension is not enabled anymore. + const extensionResult = this.checkIfGrantedByExtensionPermissions( + perm, + aRequest + ); + if (extensionResult) { + return extensionResult; + } + + if ( + perm.type === "desktop-notification" && + !aRequest.hasValidTransientUserGestureActivation && + Services.prefs.getBoolPref( + "dom.webnotifications.requireuserinteraction", + true + ) + ) { + // We need user interaction and don't have it. + return { allow: false }; + } + + const principal = + perm.type === "storage-access" + ? aRequest.principal + : aRequest.topLevelPrincipal; + + let allowOrDeny; + try { + allowOrDeny = await this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:ContentPermission", + uri: principal.URI.displaySpec, + thirdPartyOrigin: aRequest.principal.origin, + principal: lazy.E10SUtils.serializePrincipal(principal), + perm: perm.type, + value: perm.capability, + contextId: principal.originAttributes.geckoViewSessionContextId ?? null, + privateMode: principal.privateBrowsingId != 0, + }); + + if (allowOrDeny === Services.perms.ALLOW_ACTION) { + // Ask for app permission after asking for content permission. + if (perm.type === "geolocation") { + const granted = await this.getAppPermissions([ + PERM_ACCESS_FINE_LOCATION, + ]); + allowOrDeny = granted + ? Services.perms.ALLOW_ACTION + : Services.perms.DENY_ACTION; + } + } + } catch (error) { + console.error("Permission error:", error); + allowOrDeny = Services.perms.DENY_ACTION; + } + + // Manually release the target request here to facilitate garbage collection. + aRequest = undefined; + + const allow = allowOrDeny === Services.perms.ALLOW_ACTION; + + // The storage access code adds itself to the perm manager; no need for us to do it. + if (perm.type === "storage-access") { + if (allow) { + return { allow, permission: { "storage-access": "allow" } }; + } + return { allow }; + } + + Services.perms.addFromPrincipal( + principal, + perm.type, + allowOrDeny, + Services.perms.EXPIRE_NEVER + ); + + return { allow }; + } +} + +const { debug, warn } = GeckoViewPermissionChild.initLogging( + "GeckoViewPermissionChild" +); diff --git a/mobile/android/actors/GeckoViewPermissionParent.sys.mjs b/mobile/android/actors/GeckoViewPermissionParent.sys.mjs new file mode 100644 index 0000000000..c4a5cf56cf --- /dev/null +++ b/mobile/android/actors/GeckoViewPermissionParent.sys.mjs @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +export class GeckoViewPermissionParent extends GeckoViewActorParent { + _appPermissions = {}; + + async getAppPermissions(aPermissions) { + const perms = aPermissions.filter(perm => !this._appPermissions[perm]); + if (!perms.length) { + return Promise.resolve(/* granted */ true); + } + + const granted = await this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:AndroidPermission", + perms, + }); + + if (granted) { + for (const perm of perms) { + this._appPermissions[perm] = true; + } + } + + return granted; + } + + addCameraPermission() { + const principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + this.browsingContext.top.currentWindowGlobal.documentPrincipal.origin + ); + + // Although the lifetime is "session" it will be removed upon + // use so it's more of a one-shot. + Services.perms.addFromPrincipal( + principal, + "MediaManagerVideo", + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_SESSION + ); + + return null; + } + + receiveMessage(aMessage) { + debug`receiveMessage ${aMessage.name}`; + + switch (aMessage.name) { + case "GetAppPermissions": { + return this.getAppPermissions(aMessage.data); + } + case "AddCameraPermission": { + return this.addCameraPermission(); + } + } + + return super.receiveMessage(aMessage); + } +} + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPermissionParent"); diff --git a/mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs b/mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs new file mode 100644 index 0000000000..2c9d271bbd --- /dev/null +++ b/mobile/android/actors/GeckoViewPermissionProcessChild.sys.mjs @@ -0,0 +1,191 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "MediaManagerService", + "@mozilla.org/mediaManagerService;1", + "nsIMediaManagerService" +); + +const STATUS_RECORDING = "recording"; +const STATUS_INACTIVE = "inactive"; +const TYPE_CAMERA = "camera"; +const TYPE_MICROPHONE = "microphone"; + +export class GeckoViewPermissionProcessChild extends JSProcessActorChild { + getActor(window) { + return window.windowGlobalChild.getActor("GeckoViewPermission"); + } + + /* ---------- nsIObserver ---------- */ + async observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "getUserMedia:ask-device-permission": { + await this.sendQuery("AskDevicePermission", aData); + Services.obs.notifyObservers( + aSubject, + "getUserMedia:got-device-permission" + ); + break; + } + case "getUserMedia:request": { + const { callID } = aSubject; + const allowedDevices = await this.handleMediaRequest(aSubject); + Services.obs.notifyObservers( + allowedDevices, + allowedDevices + ? "getUserMedia:response:allow" + : "getUserMedia:response:deny", + callID + ); + break; + } + case "PeerConnection:request": { + Services.obs.notifyObservers( + null, + "PeerConnection:response:allow", + aSubject.callID + ); + break; + } + case "recording-device-events": { + this.handleRecordingDeviceEvents(aSubject); + break; + } + } + } + + handleRecordingDeviceEvents(aRequest) { + aRequest.QueryInterface(Ci.nsIPropertyBag2); + const contentWindow = aRequest.getProperty("window"); + const devices = []; + + const getStatusString = function (activityStatus) { + switch (activityStatus) { + case lazy.MediaManagerService.STATE_CAPTURE_ENABLED: + case lazy.MediaManagerService.STATE_CAPTURE_DISABLED: + return STATUS_RECORDING; + case lazy.MediaManagerService.STATE_NOCAPTURE: + return STATUS_INACTIVE; + default: + throw new Error("Unexpected activityStatus value"); + } + }; + + const hasCamera = {}; + const hasMicrophone = {}; + const screen = {}; + const window = {}; + const browser = {}; + const mediaDevices = {}; + lazy.MediaManagerService.mediaCaptureWindowState( + contentWindow, + hasCamera, + hasMicrophone, + screen, + window, + browser, + mediaDevices + ); + var cameraStatus = getStatusString(hasCamera.value); + var microphoneStatus = getStatusString(hasMicrophone.value); + if (hasCamera.value != lazy.MediaManagerService.STATE_NOCAPTURE) { + devices.push({ + type: TYPE_CAMERA, + status: cameraStatus, + }); + } + if (hasMicrophone.value != lazy.MediaManagerService.STATE_NOCAPTURE) { + devices.push({ + type: TYPE_MICROPHONE, + status: microphoneStatus, + }); + } + this.getActor(contentWindow).mediaRecordingStatusChanged(devices); + } + + async handleMediaRequest(aRequest) { + const constraints = aRequest.getConstraints(); + const { devices, windowID } = aRequest; + const window = Services.wm.getOuterWindowWithId(windowID); + if (window.closed) { + return null; + } + + // Release the request first + aRequest = undefined; + + const sources = devices.map(device => { + device = device.QueryInterface(Ci.nsIMediaDevice); + return { + type: device.type, + id: device.rawId, + rawId: device.rawId, + name: device.rawName, // unfiltered device name to show to the user + mediaSource: device.mediaSource, + }; + }); + + if ( + constraints.video && + !sources.some(source => source.type === "videoinput") + ) { + console.error("Media device error: no video source"); + return null; + } else if ( + constraints.audio && + !sources.some(source => source.type === "audioinput") + ) { + console.error("Media device error: no audio source"); + return null; + } + + const response = await this.getActor(window).getMediaPermission({ + uri: window.document.documentURI, + video: constraints.video + ? sources.filter(source => source.type === "videoinput") + : null, + audio: constraints.audio + ? sources.filter(source => source.type === "audioinput") + : null, + }); + + if (!response) { + // Rejected. + return null; + } + + const allowedDevices = Cc["@mozilla.org/array;1"].createInstance( + Ci.nsIMutableArray + ); + if (constraints.video) { + const video = devices.find(device => response.video === device.rawId); + if (!video) { + console.error("Media device error: invalid video id"); + return null; + } + await this.getActor(window).addCameraPermission(); + allowedDevices.appendElement(video); + } + if (constraints.audio) { + const audio = devices.find(device => response.audio === device.rawId); + if (!audio) { + console.error("Media device error: invalid audio id"); + return null; + } + allowedDevices.appendElement(audio); + } + return allowedDevices; + } +} + +const { debug, warn } = GeckoViewUtils.initLogging( + "GeckoViewPermissionProcessChild" +); diff --git a/mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs b/mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs new file mode 100644 index 0000000000..47b98602a5 --- /dev/null +++ b/mobile/android/actors/GeckoViewPermissionProcessParent.sys.mjs @@ -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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +// See: http://developer.android.com/reference/android/Manifest.permission.html +const PERM_CAMERA = "android.permission.CAMERA"; +const PERM_RECORD_AUDIO = "android.permission.RECORD_AUDIO"; + +export class GeckoViewPermissionProcessParent extends JSProcessActorParent { + async askDevicePermission(aType) { + const perms = []; + if (aType === "video" || aType === "all") { + perms.push(PERM_CAMERA); + } + if (aType === "audio" || aType === "all") { + perms.push(PERM_RECORD_AUDIO); + } + + try { + // This looks sketchy but it's fine: Android needs the audio/video + // permission to enumerate devices, which Gecko wants to do even before + // we expose the list to web pages. + // So really it doesn't matter what the request source is, because we + // will, separately, issue a windowId-specific request to let the webpage + // actually have access to the list of devices. So even if the source of + // *this* request is incorrect, no actual harm will be done, as the user + // will always receive the request with the right origin after this. + const window = Services.wm.getMostRecentWindow("navigator:geckoview"); + const windowActor = window.browsingContext.currentWindowGlobal.getActor( + "GeckoViewPermission" + ); + await windowActor.getAppPermissions(perms); + } catch (error) { + // We can't really do anything here so we pretend we got the permission. + warn`Error getting app permission: ${error}`; + } + } + + receiveMessage(aMessage) { + debug`receiveMessage ${aMessage.name}`; + + switch (aMessage.name) { + case "AskDevicePermission": { + return this.askDevicePermission(aMessage.data); + } + } + + return super.receiveMessage(aMessage); + } +} + +const { debug, warn } = GeckoViewUtils.initLogging( + "GeckoViewPermissionProcess" +); diff --git a/mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs b/mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs new file mode 100644 index 0000000000..4004ffa779 --- /dev/null +++ b/mobile/android/actors/GeckoViewPrintDelegateChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export class GeckoViewPrintDelegateChild extends GeckoViewActorChild {} diff --git a/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs b/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs new file mode 100644 index 0000000000..db2edf652b --- /dev/null +++ b/mobile/android/actors/GeckoViewPrintDelegateParent.sys.mjs @@ -0,0 +1,35 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +export class GeckoViewPrintDelegateParent extends GeckoViewActorParent { + constructor() { + super(); + this._browserStaticClone = null; + } + + set browserStaticClone(staticClone) { + this._browserStaticClone = staticClone; + } + + get browserStaticClone() { + return this._browserStaticClone; + } + + clearStaticClone() { + // Removes static browser element from DOM that was made for window.print + this.browserStaticClone?.remove(); + this.browserStaticClone = null; + } + + printRequest() { + if (this.browserStaticClone != null) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:DotPrintRequest", + canonicalBrowsingContextId: this.browserStaticClone.browsingContext.id, + }); + } + } +} diff --git a/mobile/android/actors/GeckoViewPromptChild.sys.mjs b/mobile/android/actors/GeckoViewPromptChild.sys.mjs new file mode 100644 index 0000000000..7e5574a7c0 --- /dev/null +++ b/mobile/android/actors/GeckoViewPromptChild.sys.mjs @@ -0,0 +1,24 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export class GeckoViewPromptChild extends GeckoViewActorChild { + handleEvent(event) { + const { type } = event; + debug`handleEvent: ${type}`; + + switch (type) { + case "MozOpenDateTimePicker": + case "mozshowdropdown": + case "mozshowdropdown-sourcetouch": + case "click": + case "contextmenu": + case "DOMPopupBlocked": + Services.prompt.wrappedJSObject.handleEvent(event); + } + } +} + +const { debug, warn } = GeckoViewPromptChild.initLogging("GeckoViewPrompt"); diff --git a/mobile/android/actors/GeckoViewPrompterChild.sys.mjs b/mobile/android/actors/GeckoViewPrompterChild.sys.mjs new file mode 100644 index 0000000000..bb8c4fbcff --- /dev/null +++ b/mobile/android/actors/GeckoViewPrompterChild.sys.mjs @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export class GeckoViewPrompterChild extends GeckoViewActorChild { + constructor() { + super(); + this._prompts = new Map(); + } + + dismissPrompt(prompt) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:Prompt:Dismiss", + id: prompt.id, + }); + this.unregisterPrompt(prompt); + } + + updatePrompt(message) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:Prompt:Update", + prompt: message, + }); + } + + unregisterPrompt(prompt) { + this._prompts.delete(prompt.id); + this.sendAsyncMessage("UnregisterPrompt", { + id: prompt.id, + }); + } + + prompt(prompt, message) { + this._prompts.set(prompt.id, prompt); + this.sendAsyncMessage("RegisterPrompt", { + id: prompt.id, + promptType: prompt.getPromptType(), + }); + // We intentionally do not await here as we want to fire NotifyPromptShow + // immediately rather than waiting until the user accepts/dismisses the + // prompt. + const result = this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:Prompt", + prompt: message, + }); + this.sendAsyncMessage("NotifyPromptShow", { + id: prompt.id, + }); + return result; + } + + /** + * Handles the message coming from GeckoViewPrompterParent. + * + * @param {string} message.name The subject of the message. + * @param {object} message.data The data of the message. + */ + async receiveMessage({ name, data }) { + const prompt = this._prompts.get(data.id); + if (!prompt) { + // Unknown prompt, probably for a different child actor. + return; + } + switch (name) { + case "GetPromptText": { + // eslint-disable-next-line consistent-return + return prompt.getPromptText(); + } + case "GetInputText": { + // eslint-disable-next-line consistent-return + return prompt.getInputText(); + } + case "SetInputText": { + prompt.setInputText(data.text); + break; + } + case "AcceptPrompt": { + prompt.accept(); + break; + } + case "DismissPrompt": { + prompt.dismiss(); + break; + } + default: { + break; + } + } + } +} + +const { debug, warn } = GeckoViewPrompterChild.initLogging("Prompter"); diff --git a/mobile/android/actors/GeckoViewPrompterParent.sys.mjs b/mobile/android/actors/GeckoViewPrompterParent.sys.mjs new file mode 100644 index 0000000000..53eda226c8 --- /dev/null +++ b/mobile/android/actors/GeckoViewPrompterParent.sys.mjs @@ -0,0 +1,167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +const DIALOGS = [ + "alert", + "alertCheck", + "confirm", + "confirmCheck", + "prompt", + "promptCheck", +]; + +export class GeckoViewPrompterParent extends GeckoViewActorParent { + constructor() { + super(); + this._prompts = new Map(); + } + + get rootActor() { + return this.browsingContext.top.currentWindowGlobal.getActor( + "GeckoViewPrompter" + ); + } + + registerPrompt(promptId, promptType, actor) { + return this._prompts.set( + promptId, + new RemotePrompt(promptId, promptType, actor) + ); + } + + unregisterPrompt(promptId) { + this._prompts.delete(promptId); + } + + notifyPromptShow(promptId) { + // ToDo: Bug 1761480 - GeckoView can send additional prompts to Marionette + if (this._prompts.get(promptId).isDialog) { + Services.obs.notifyObservers({ id: promptId }, "geckoview-prompt-show"); + } + } + + getPrompts() { + const self = this; + const prompts = []; + // Marionette expects this event to be fired from the parent + const createDialogClosedEvent = detail => + new CustomEvent("DOMModalDialogClosed", { + cancelable: true, + bubbles: true, + detail, + }); + + for (const [, prompt] of this._prompts) { + // Adding only WebDriver compliant dialogs to the window + if (prompt.isDialog) { + prompts.push({ + args: { + modalType: "GeckoViewPrompter", + promptType: prompt.type, + isDialog: prompt.isDialog, + }, + setInputText(text) { + prompt.inputText = text; + prompt.setInputText(text); + }, + async getPromptText() { + return prompt.getPromptText(); + }, + async getInputText() { + return prompt.getInputText(); + }, + acceptPrompt() { + prompt.acceptPrompt(); + self.window.dispatchEvent( + createDialogClosedEvent({ + areLeaving: true, + value: prompt.inputText, + }) + ); + }, + dismissPrompt() { + prompt.dismissPrompt(); + self.window.dispatchEvent( + createDialogClosedEvent({ areLeaving: false }) + ); + }, + }); + } + } + return prompts; + } + + /** + * Handles the message coming from GeckoViewPrompterChild. + * + * @param {string} message.name The subject of the message. + * @param {object} message.data The data of the message. + */ + // eslint-disable-next-line consistent-return + async receiveMessage({ name, data }) { + switch (name) { + case "RegisterPrompt": { + this.rootActor.registerPrompt(data.id, data.promptType, this); + break; + } + case "UnregisterPrompt": { + this.rootActor.unregisterPrompt(data.id); + break; + } + case "NotifyPromptShow": { + this.rootActor.notifyPromptShow(data.id); + break; + } + default: { + return super.receiveMessage({ name, data }); + } + } + } +} + +class RemotePrompt { + constructor(id, type, actor) { + this.id = id; + this.type = type; + this.actor = actor; + } + + // Checks if the prompt conforms to a WebDriver simple dialog. + get isDialog() { + return DIALOGS.includes(this.type); + } + + getPromptText() { + return this.actor.sendQuery("GetPromptText", { + id: this.id, + }); + } + + getInputText() { + return this.actor.sendQuery("GetInputText", { + id: this.id, + }); + } + + setInputText(inputText) { + this.actor.sendAsyncMessage("SetInputText", { + id: this.id, + text: inputText, + }); + } + + acceptPrompt() { + this.actor.sendAsyncMessage("AcceptPrompt", { + id: this.id, + }); + } + + dismissPrompt() { + this.actor.sendAsyncMessage("DismissPrompt", { + id: this.id, + }); + } +} diff --git a/mobile/android/actors/GeckoViewSettingsChild.sys.mjs b/mobile/android/actors/GeckoViewSettingsChild.sys.mjs new file mode 100644 index 0000000000..d5e6c01e27 --- /dev/null +++ b/mobile/android/actors/GeckoViewSettingsChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +// Handles GeckoView content settings +export class GeckoViewSettingsChild extends GeckoViewActorChild { + receiveMessage(message) { + const { name } = message; + debug`receiveMessage: ${name}`; + + switch (name) { + case "SettingsUpdate": { + const settings = message.data; + + if (settings.isPopup) { + // Allow web extensions to close their own action popups (bz1612363) + this.contentWindow.windowUtils.allowScriptsToClose(); + } + } + } + } +} + +const { debug, warn } = GeckoViewSettingsChild.initLogging("GeckoViewSettings"); diff --git a/mobile/android/actors/LoadURIDelegateChild.sys.mjs b/mobile/android/actors/LoadURIDelegateChild.sys.mjs new file mode 100644 index 0000000000..bb54209f0c --- /dev/null +++ b/mobile/android/actors/LoadURIDelegateChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.sys.mjs", +}); + +// Implements nsILoadURIDelegate. +export class LoadURIDelegateChild extends GeckoViewActorChild { + // nsILoadURIDelegate. + handleLoadError(aUri, aError, aErrorModule) { + debug`handleLoadError: uri=${aUri && aUri.spec} + displaySpec=${aUri && aUri.displaySpec} + error=${aError}`; + if (aUri && lazy.LoadURIDelegate.isSafeBrowsingError(aError)) { + const message = { + type: "GeckoView:ContentBlocked", + uri: aUri.spec, + error: aError, + }; + + this.eventDispatcher.sendRequest(message); + } + + return lazy.LoadURIDelegate.handleLoadError( + this.contentWindow, + this.eventDispatcher, + aUri, + aError, + aErrorModule + ); + } +} + +LoadURIDelegateChild.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsILoadURIDelegate", +]); + +const { debug, warn } = LoadURIDelegateChild.initLogging("LoadURIDelegate"); diff --git a/mobile/android/actors/LoadURIDelegateParent.sys.mjs b/mobile/android/actors/LoadURIDelegateParent.sys.mjs new file mode 100644 index 0000000000..0446d7d88a --- /dev/null +++ b/mobile/android/actors/LoadURIDelegateParent.sys.mjs @@ -0,0 +1,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/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +// For this.eventDispatcher in the child +export class LoadURIDelegateParent extends GeckoViewActorParent {} diff --git a/mobile/android/actors/MediaControlDelegateChild.sys.mjs b/mobile/android/actors/MediaControlDelegateChild.sys.mjs new file mode 100644 index 0000000000..1db32b33f0 --- /dev/null +++ b/mobile/android/actors/MediaControlDelegateChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MediaUtils: "resource://gre/modules/MediaUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +export class MediaControlDelegateChild extends GeckoViewActorChild { + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "MozDOMFullscreen:Entered": + case "MozDOMFullscreen:Exited": + this.handleFullscreenChanged(true); + break; + } + } + + async handleFullscreenChanged(retry) { + debug`handleFullscreenChanged`; + + const element = this.document.fullscreenElement; + const mediaElement = lazy.MediaUtils.findMediaElement(element); + + if (element && !mediaElement) { + // Non-media element fullscreen. + debug`No fullscreen media element found.`; + } + + const activated = await this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:MediaSession:Fullscreen", + metadata: lazy.MediaUtils.getMetadata(mediaElement) ?? {}, + enabled: !!element, + }); + if (activated) { + return; + } + if (retry && element) { + // When media session is going to active, we have a race condition of + // full screen event because media session will be activated by full + // screen event. + // So we retry to call media session delegate for this situation. + lazy.setTimeout(() => { + this.handleFullscreenChanged(false); + }, 100); + } + } +} + +const { debug } = MediaControlDelegateChild.initLogging( + "MediaControlDelegateChild" +); diff --git a/mobile/android/actors/MediaControlDelegateParent.sys.mjs b/mobile/android/actors/MediaControlDelegateParent.sys.mjs new file mode 100644 index 0000000000..f0c3f47984 --- /dev/null +++ b/mobile/android/actors/MediaControlDelegateParent.sys.mjs @@ -0,0 +1,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/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +// For this.eventDispatcher in the child +export class MediaControlDelegateParent extends GeckoViewActorParent {} diff --git a/mobile/android/actors/ProgressDelegateChild.sys.mjs b/mobile/android/actors/ProgressDelegateChild.sys.mjs new file mode 100644 index 0000000000..4992ee5916 --- /dev/null +++ b/mobile/android/actors/ProgressDelegateChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export class ProgressDelegateChild extends GeckoViewActorChild { + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "DOMContentLoaded": // fall-through + case "MozAfterPaint": // fall-through + case "pageshow": { + // Forward to main process + const target = aEvent.originalTarget; + const uri = target?.location.href; + this.sendAsyncMessage(aEvent.type, { + uri, + }); + } + } + } +} + +const { debug, warn } = ProgressDelegateChild.initLogging("ProgressDelegate"); diff --git a/mobile/android/actors/ProgressDelegateParent.sys.mjs b/mobile/android/actors/ProgressDelegateParent.sys.mjs new file mode 100644 index 0000000000..bb3f1ec416 --- /dev/null +++ b/mobile/android/actors/ProgressDelegateParent.sys.mjs @@ -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/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +export class ProgressDelegateParent extends GeckoViewActorParent {} diff --git a/mobile/android/actors/ScrollDelegateChild.sys.mjs b/mobile/android/actors/ScrollDelegateChild.sys.mjs new file mode 100644 index 0000000000..5d71482f23 --- /dev/null +++ b/mobile/android/actors/ScrollDelegateChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export class ScrollDelegateChild extends GeckoViewActorChild { + // eslint-disable-next-line complexity + handleEvent(aEvent) { + if (aEvent.originalTarget.ownerGlobal != this.contentWindow) { + return; + } + + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "mozvisualscroll": + const x = {}; + const y = {}; + this.contentWindow.windowUtils.getVisualViewportOffset(x, y); + this.eventDispatcher.sendRequest({ + type: "GeckoView:ScrollChanged", + scrollX: x.value, + scrollY: y.value, + }); + break; + } + } +} + +const { debug, warn } = ScrollDelegateChild.initLogging("ScrollDelegate"); diff --git a/mobile/android/actors/ScrollDelegateParent.sys.mjs b/mobile/android/actors/ScrollDelegateParent.sys.mjs new file mode 100644 index 0000000000..39921d4411 --- /dev/null +++ b/mobile/android/actors/ScrollDelegateParent.sys.mjs @@ -0,0 +1,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/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +// For this.eventDispatcher in the child +export class ScrollDelegateParent extends GeckoViewActorParent {} diff --git a/mobile/android/actors/SelectionActionDelegateChild.sys.mjs b/mobile/android/actors/SelectionActionDelegateChild.sys.mjs new file mode 100644 index 0000000000..9f05906e09 --- /dev/null +++ b/mobile/android/actors/SelectionActionDelegateChild.sys.mjs @@ -0,0 +1,442 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs", +}); + +const MAGNIFIER_PREF = "layout.accessiblecaret.magnifier.enabled"; +const ACCESSIBLECARET_HEIGHT_PREF = "layout.accessiblecaret.height"; +const PREFS = [MAGNIFIER_PREF, ACCESSIBLECARET_HEIGHT_PREF]; + +// Dispatches GeckoView:ShowSelectionAction and GeckoView:HideSelectionAction to +// the GeckoSession on accessible caret changes. +export class SelectionActionDelegateChild extends GeckoViewActorChild { + constructor(aModuleName, aMessageManager) { + super(aModuleName, aMessageManager); + + this._actionCallback = () => {}; + this._isActive = false; + this._previousMessage = ""; + + // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's + // directly, so we create a new function here instead to act as our + // nsIObserver, which forwards the notification to the observe method. + this._observerFunction = (subject, topic, data) => { + this.observe(subject, topic, data); + }; + for (const pref of PREFS) { + Services.prefs.addObserver(pref, this._observerFunction); + } + + this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF); + this._accessiblecaretHeight = parseFloat( + Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0") + ); + } + + didDestroy() { + for (const pref of PREFS) { + Services.prefs.removeObserver(pref, this._observerFunction); + } + } + + _actions = [ + { + id: "org.mozilla.geckoview.HIDE", + predicate: _ => true, + perform: _ => this.handleEvent({ type: "pagehide" }), + }, + { + id: "org.mozilla.geckoview.CUT", + predicate: e => + !e.collapsed && e.selectionEditable && !this._isPasswordField(e), + perform: _ => this.docShell.doCommand("cmd_cut"), + }, + { + id: "org.mozilla.geckoview.COPY", + predicate: e => !e.collapsed && !this._isPasswordField(e), + perform: _ => this.docShell.doCommand("cmd_copy"), + }, + { + id: "org.mozilla.geckoview.PASTE", + predicate: e => + (this._isContentHtmlEditable(e) && + Services.clipboard.hasDataMatchingFlavors( + /* The following image types are considered by editor */ + ["image/gif", "image/jpeg", "image/png"], + Ci.nsIClipboard.kGlobalClipboard + )) || + (e.selectionEditable && + Services.clipboard.hasDataMatchingFlavors( + ["text/plain"], + Ci.nsIClipboard.kGlobalClipboard + )), + perform: _ => this._performPaste(), + }, + { + id: "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT", + predicate: e => + this._isContentHtmlEditable(e) && + Services.clipboard.hasDataMatchingFlavors( + ["text/html"], + Ci.nsIClipboard.kGlobalClipboard + ), + perform: _ => this._performPasteAsPlainText(), + }, + { + id: "org.mozilla.geckoview.DELETE", + predicate: e => !e.collapsed && e.selectionEditable, + perform: _ => this.docShell.doCommand("cmd_delete"), + }, + { + id: "org.mozilla.geckoview.COLLAPSE_TO_START", + predicate: e => !e.collapsed && e.selectionEditable, + perform: e => this.docShell.doCommand("cmd_moveLeft"), + }, + { + id: "org.mozilla.geckoview.COLLAPSE_TO_END", + predicate: e => !e.collapsed && e.selectionEditable, + perform: e => this.docShell.doCommand("cmd_moveRight"), + }, + { + id: "org.mozilla.geckoview.UNSELECT", + predicate: e => !e.collapsed && !e.selectionEditable, + perform: e => this.docShell.doCommand("cmd_selectNone"), + }, + { + id: "org.mozilla.geckoview.SELECT_ALL", + predicate: e => { + if (e.reason === "longpressonemptycontent") { + return false; + } + // When on design mode, focusedElement will be null. + const element = + Services.focus.focusedElement || e.target?.activeElement; + if (e.selectionEditable && e.target && element) { + let value = ""; + if (element.value) { + value = element.value; + } else if ( + element.isContentEditable || + e.target.designMode === "on" + ) { + value = element.innerText; + } + // Do not show SELECT_ALL if the editable is empty + // or all the editable text is already selected. + return value !== "" && value !== e.selectedTextContent; + } + return true; + }, + perform: e => this.docShell.doCommand("cmd_selectAll"), + }, + ]; + + receiveMessage({ name, data }) { + debug`receiveMessage ${name}`; + + switch (name) { + case "ExecuteSelectionAction": { + this._actionCallback(data); + } + } + } + + _performPaste() { + this.handleEvent({ type: "pagehide" }); + this.docShell.doCommand("cmd_paste"); + } + + _performPasteAsPlainText() { + this.handleEvent({ type: "pagehide" }); + this.docShell.doCommand("cmd_pasteNoFormatting"); + } + + _isPasswordField(aEvent) { + if (!aEvent.selectionEditable) { + return false; + } + + const win = aEvent.target.defaultView; + const focus = aEvent.target.activeElement; + return ( + win && + win.HTMLInputElement && + win.HTMLInputElement.isInstance(focus) && + !focus.mozIsTextField(/* excludePassword */ true) + ); + } + + _isContentHtmlEditable(aEvent) { + if (!aEvent.selectionEditable) { + return false; + } + + if (aEvent.target.designMode == "on") { + return true; + } + + // focused element isn't <input> nor <textarea> + const win = aEvent.target.defaultView; + const focus = Services.focus.focusedElement; + return ( + win && + win.HTMLInputElement && + win.HTMLTextAreaElement && + !win.HTMLInputElement.isInstance(focus) && + !win.HTMLTextAreaElement.isInstance(focus) + ); + } + + _getDefaultMagnifierPoint(aEvent) { + const rect = lazy.LayoutUtils.rectToScreenRect(aEvent.target.ownerGlobal, { + left: aEvent.clientX, + top: aEvent.clientY - this._accessiblecaretHeight, + width: 0, + height: 0, + }); + return { x: rect.left, y: rect.top }; + } + + _getBetterMagnifierPoint(aEvent) { + const win = aEvent.target.defaultView; + if (!win) { + return this._getDefaultMagnifierPoint(aEvent); + } + + const focus = aEvent.target.activeElement; + if ( + win.HTMLInputElement?.isInstance(focus) && + focus.mozIsTextField(false) + ) { + // <input> element. Use vertical center position of input element. + const bounds = focus.getBoundingClientRect(); + const rect = lazy.LayoutUtils.rectToScreenRect( + aEvent.target.ownerGlobal, + { + left: aEvent.clientX, + top: bounds.top, + width: 0, + height: bounds.height, + } + ); + return { x: rect.left, y: rect.top + rect.height / 2 }; + } + + if (win.HTMLTextAreaElement?.isInstance(focus)) { + // TODO: + // <textarea> element. How to get better selection bounds? + return this._getDefaultMagnifierPoint(aEvent); + } + + const selection = win.getSelection(); + if (selection.rangeCount != 1) { + // When selecting text using accessible caret, selection count will be 1. + // This situation means that current selection isn't into text. + return this._getDefaultMagnifierPoint(aEvent); + } + + // We are looking for better selection bounds, then use it. + const bounds = (() => { + const range = selection.getRangeAt(0); + let distance = Number.MAX_SAFE_INTEGER; + let y = aEvent.clientY; + const rectList = range.getClientRects(); + for (const rect of rectList) { + const newDistance = Math.abs(aEvent.clientY - rect.bottom); + if (distance > newDistance) { + y = rect.top + rect.height / 2; + distance = newDistance; + } + } + return { left: aEvent.clientX, top: y, width: 0, height: 0 }; + })(); + + const rect = lazy.LayoutUtils.rectToScreenRect( + aEvent.target.ownerGlobal, + bounds + ); + return { x: rect.left, y: rect.top }; + } + + _handleMagnifier(aEvent) { + if (["presscaret", "dragcaret"].includes(aEvent.reason)) { + debug`_handleMagnifier: ${aEvent.reason}`; + const screenPoint = this._getBetterMagnifierPoint(aEvent); + this.eventDispatcher.sendRequest({ + type: "GeckoView:ShowMagnifier", + screenPoint, + }); + } else if (aEvent.reason == "releasecaret") { + debug`_handleMagnifier: ${aEvent.reason}`; + this.eventDispatcher.sendRequest({ + type: "GeckoView:HideMagnifier", + }); + } + } + + /** + * Receive and act on AccessibleCarets caret state-change + * (mozcaretstatechanged and pagehide) events. + */ + handleEvent(aEvent) { + if (aEvent.type === "pagehide" || aEvent.type === "deactivate") { + // Hide any selection actions on page hide or deactivate. + aEvent = { + reason: "visibilitychange", + caretVisibile: false, + selectionVisible: false, + collapsed: true, + selectionEditable: false, + }; + } + + let reason = aEvent.reason; + + if (this._isActive && !aEvent.caretVisible) { + // For mozcaretstatechanged, "visibilitychange" means the caret is hidden. + reason = "visibilitychange"; + } else if (!aEvent.collapsed && !aEvent.selectionVisible) { + reason = "invisibleselection"; + } else if ( + !this._isActive && + aEvent.selectionEditable && + aEvent.collapsed && + reason !== "longpressonemptycontent" && + reason !== "taponcaret" && + !Services.prefs.getBoolPref( + "geckoview.selection_action.show_on_focus", + false + ) + ) { + // Don't show selection actions when merely focusing on an editor or + // repositioning the cursor. Wait until long press or the caret is tapped + // in order to match Android behavior. + reason = "visibilitychange"; + } + + debug`handleEvent: ${reason}`; + + if (this._magnifierEnabled) { + this._handleMagnifier(aEvent); + } + + if ( + [ + "longpressonemptycontent", + "releasecaret", + "taponcaret", + "updateposition", + ].includes(reason) + ) { + const actions = this._actions.filter(action => + action.predicate.call(this, aEvent) + ); + + const screenRect = (() => { + const boundingRect = aEvent.boundingClientRect; + if (!boundingRect) { + return null; + } + const rect = lazy.LayoutUtils.rectToScreenRect( + aEvent.target.ownerGlobal, + boundingRect + ); + return { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom + this._accessiblecaretHeight, + }; + })(); + + const password = this._isPasswordField(aEvent); + + const msg = { + collapsed: aEvent.collapsed, + editable: aEvent.selectionEditable, + password, + selection: password ? "" : aEvent.selectedTextContent, + screenRect, + actions: actions.map(action => action.id), + }; + + if (this._isActive && JSON.stringify(msg) === this._previousMessage) { + // Don't call again if we're already active and things haven't changed. + return; + } + + this._isActive = true; + this._previousMessage = JSON.stringify(msg); + + // We can't just listen to the response of the message because we accept + // multiple callbacks. + this._actionCallback = data => { + const action = actions.find(action => action.id === data.id); + if (action) { + debug`Performing ${data.id}`; + action.perform.call(this, aEvent); + } else { + warn`Invalid action ${data.id}`; + } + }; + this.sendAsyncMessage("ShowSelectionAction", msg); + } else if ( + [ + "invisibleselection", + "presscaret", + "scroll", + "visibilitychange", + ].includes(reason) + ) { + if (!this._isActive) { + return; + } + this._isActive = false; + + // Mark previous actions as stale. Don't do this for "invisibleselection" + // or "scroll" because previous actions should still be valid even after + // these events occur. + if (reason !== "invisibleselection" && reason !== "scroll") { + this._seqNo++; + } + + this.sendAsyncMessage("HideSelectionAction", { reason }); + } else if (reason == "dragcaret") { + // nothing for selection action + } else { + warn`Unknown reason: ${reason}`; + } + } + + observe(aSubject, aTopic, aData) { + if (aTopic != "nsPref:changed") { + return; + } + + switch (aData) { + case ACCESSIBLECARET_HEIGHT_PREF: + this._accessiblecaretHeight = parseFloat( + Services.prefs.getCharPref(ACCESSIBLECARET_HEIGHT_PREF, "0") + ); + break; + case MAGNIFIER_PREF: + this._magnifierEnabled = Services.prefs.getBoolPref(MAGNIFIER_PREF); + break; + } + // Reset magnifier + this.eventDispatcher.sendRequest({ + type: "GeckoView:HideMagnifier", + }); + } +} + +const { debug, warn } = SelectionActionDelegateChild.initLogging( + "SelectionActionDelegate" +); diff --git a/mobile/android/actors/SelectionActionDelegateParent.sys.mjs b/mobile/android/actors/SelectionActionDelegateParent.sys.mjs new file mode 100644 index 0000000000..3ef5830ce4 --- /dev/null +++ b/mobile/android/actors/SelectionActionDelegateParent.sys.mjs @@ -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/. */ + +import { GeckoViewActorParent } from "resource://gre/modules/GeckoViewActorParent.sys.mjs"; + +export class SelectionActionDelegateParent extends GeckoViewActorParent { + respondTo = null; + actionId = null; + + get rootActor() { + return this.browsingContext.top.currentWindowGlobal.getActor( + "SelectionActionDelegate" + ); + } + + receiveMessage(aMessage) { + const { data, name } = aMessage; + switch (name) { + case "ShowSelectionAction": { + this.rootActor.showSelectionAction(this, data); + break; + } + + case "HideSelectionAction": { + this.rootActor.hideSelectionAction(this, data.reason); + break; + } + + default: { + super.receiveMessage(aMessage); + } + } + } + + hideSelectionAction(aRespondTo, reason) { + // Mark previous actions as stale. Don't do this for "invisibleselection" + // or "scroll" because previous actions should still be valid even after + // these events occur. + if (reason !== "invisibleselection" && reason !== "scroll") { + this.actionId = null; + } + + this.eventDispatcher?.sendRequest({ + type: "GeckoView:HideSelectionAction", + reason, + }); + } + + showSelectionAction(aRespondTo, aData) { + this.actionId = Services.uuid.generateUUID().toString(); + this.respondTo = aRespondTo; + + this.eventDispatcher?.sendRequest({ + type: "GeckoView:ShowSelectionAction", + actionId: this.actionId, + ...aData, + }); + } + + executeSelectionAction(aData) { + if (this.actionId === null || aData.actionId != this.actionId) { + warn`Stale response ${aData.id} ${aData.actionId}`; + return; + } + this.respondTo.sendAsyncMessage("ExecuteSelectionAction", aData); + } +} + +const { debug, warn } = SelectionActionDelegateParent.initLogging( + "SelectionActionDelegate" +); diff --git a/mobile/android/actors/metrics.yaml b/mobile/android/actors/metrics.yaml new file mode 100644 index 0000000000..636292100c --- /dev/null +++ b/mobile/android/actors/metrics.yaml @@ -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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'GeckoView :: General' diff --git a/mobile/android/actors/moz.build b/mobile/android/actors/moz.build new file mode 100644 index 0000000000..739405e56c --- /dev/null +++ b/mobile/android/actors/moz.build @@ -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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +FINAL_TARGET_FILES.actors += [ + "ContentDelegateChild.sys.mjs", + "ContentDelegateParent.sys.mjs", + "GeckoViewAutoFillChild.sys.mjs", + "GeckoViewAutoFillParent.sys.mjs", + "GeckoViewContentChild.sys.mjs", + "GeckoViewContentParent.sys.mjs", + "GeckoViewExperimentDelegateParent.sys.mjs", + "GeckoViewFormValidationChild.sys.mjs", + "GeckoViewPermissionChild.sys.mjs", + "GeckoViewPermissionParent.sys.mjs", + "GeckoViewPermissionProcessChild.sys.mjs", + "GeckoViewPermissionProcessParent.sys.mjs", + "GeckoViewPrintDelegateChild.sys.mjs", + "GeckoViewPrintDelegateParent.sys.mjs", + "GeckoViewPromptChild.sys.mjs", + "GeckoViewPrompterChild.sys.mjs", + "GeckoViewPrompterParent.sys.mjs", + "GeckoViewSettingsChild.sys.mjs", + "LoadURIDelegateChild.sys.mjs", + "LoadURIDelegateParent.sys.mjs", + "MediaControlDelegateChild.sys.mjs", + "MediaControlDelegateParent.sys.mjs", + "ProgressDelegateChild.sys.mjs", + "ProgressDelegateParent.sys.mjs", + "ScrollDelegateChild.sys.mjs", + "ScrollDelegateParent.sys.mjs", + "SelectionActionDelegateChild.sys.mjs", + "SelectionActionDelegateParent.sys.mjs", +] + +MOCHITEST_MANIFESTS += ["tests/mochitests/mochitest.toml"] diff --git a/mobile/android/actors/tests/mochitests/head.js b/mobile/android/actors/tests/mochitests/head.js new file mode 100644 index 0000000000..66735cddb6 --- /dev/null +++ b/mobile/android/actors/tests/mochitests/head.js @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; diff --git a/mobile/android/actors/tests/mochitests/mochitest.toml b/mobile/android/actors/tests/mochitests/mochitest.toml new file mode 100644 index 0000000000..10fb8342b4 --- /dev/null +++ b/mobile/android/actors/tests/mochitests/mochitest.toml @@ -0,0 +1,5 @@ +[DEFAULT] +support-files = ["head.js"] +run-if = ["os == 'android'"] + +["test_geckoview_experiment_delegate.html"] diff --git a/mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html b/mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html new file mode 100644 index 0000000000..c6425e2983 --- /dev/null +++ b/mobile/android/actors/tests/mochitests/test_geckoview_experiment_delegate.html @@ -0,0 +1,108 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=1845824 +--> +<head> + <meta charset="utf-8"> + <title>Test Experiment Delegate</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="head.js" type="application/javascript"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script class="testbody" type="text/javascript"> + + // Note: TestRunnerActivity provides a pseudo Experiment Delegate for this test. + async function requestExperiment(message) { + const chromeScript = SpecialPowers.loadChromeScript(_ => { + /* eslint-env mozilla/chrome-script */ + addMessageListener("experiment", (msg) => { + var result; + const navigator = Services.wm.getMostRecentWindow("navigator:geckoview"); + const experimentActor = navigator.window.moduleManager.getActor("GeckoViewExperimentDelegate"); + switch (msg.endpoint) { + case 'getExperimentFeature': + result = experimentActor.getExperimentFeature(msg.feature); + break; + case 'recordExposure': + result = experimentActor.recordExposure(msg.feature); + break + case 'recordExperimentExposure': + result = experimentActor.recordExperimentExposure(msg.feature, msg.slug); + break; + case 'recordExperimentMalformedConfig': + result = experimentActor.recordExperimentMalformedConfig(msg.feature, msg.part); + break; + default: + result = null; + break; + } + return result; + }); + + }); + + const result = await chromeScript.sendQuery("experiment", message); + chromeScript.destroy(); + return result; + } + + add_task(async function test_getExperimentFeature() { + const success = await requestExperiment({endpoint: "getExperimentFeature", feature: "test"}); + is(success["item-one"], true, "Retrieved TestRunnerActivity experiment feature 'test' for 'item-one'."); + is(success["item-two"], 5, "Retrieved TestRunnerActivity experiment feature 'test' for 'item-two'."); + var didErrorOccur = false; + try { + await requestExperiment({endpoint: "getExperimentFeature", feature: "no-feature"}); + } catch (error) { + is(error, "An error occurred while retrieving feature data.", "Correctly failed when the feature did not exist."); + didErrorOccur = true; + } + is(didErrorOccur, true, "Error was caught when no feature existed."); + }); + + add_task(async function test_recordExposure() { + const success = await requestExperiment({endpoint: "recordExposure", feature: "test"}); + is(success, true, "Recorded exposure for the feature."); + var didErrorOccur = false; + try { + await requestExperiment({endpoint: "recordExposure", feature: "no-feature"}); + } catch (error) { + is(error, "An error occurred while recording feature.", "Correctly failed when the feature did not exist."); + didErrorOccur = true; + } + is(didErrorOccur, true, "Error was caught when no feature existed."); + }); + + + add_task(async function test_recordExperimentExposure() { + const success = await requestExperiment({endpoint: "recordExperimentExposure", feature: "test", slug: "test"}); + is(success, true, "Recorded experiment exposure for the feature."); + var didErrorOccur = false; + try { + await requestExperiment({endpoint: "recordExperimentExposure", feature: "no-feature", slug: "no-slug"}); + } catch (error) { + is(error, "An error occurred while recording experiment feature.", "Correctly failed when the feature did not exist."); + didErrorOccur = true; + } + is(didErrorOccur, true, "Error was caught when no feature existed."); + }); + + + add_task(async function test_recordExperimentMalformedConfig() { + const success = await requestExperiment({endpoint: "recordExperimentMalformedConfig", feature: "test", part: "test"}); + is(success, true, "Recorded exposure for the feature."); + var didErrorOccur = false; + try { + await requestExperiment({endpoint: "recordExperimentMalformedConfig", feature: "no-feature", part:"no-part"}); + } catch (error) { + is(error, "An error occurred while recording malformed feature config.", "Correctly failed when the feature did not exist."); + didErrorOccur = true; + } + is(didErrorOccur, true, "Error was caught when no feature existed."); + }); + +</script> +</body> +</html> diff --git a/mobile/android/annotations/build.gradle b/mobile/android/annotations/build.gradle new file mode 100644 index 0000000000..147531b251 --- /dev/null +++ b/mobile/android/annotations/build.gradle @@ -0,0 +1,14 @@ +buildDir "${topobjdir}/gradle/build/mobile/android/annotations" + +apply plugin: 'java' + +// lint should be X+23.Y.Z of gradle_plugin version, according to: +// http://googlesamples.github.io/android-custom-lint-rules/api-guide.html#example:samplelintcheckgithubproject/lintversion? + +dependencies { + implementation 'com.android.tools.lint:lint:30.4.2' + implementation 'com.android.tools.lint:lint-checks:30.4.2' +} + +sourceCompatibility = JavaVersion.VERSION_17 +targetCompatibility = JavaVersion.VERSION_17 diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/AnnotationInfo.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/AnnotationInfo.java new file mode 100644 index 0000000000..0404a467f5 --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/AnnotationInfo.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.annotationProcessors; + +/** Object holding annotation data. Used by GeneratableElementIterator. */ +public class AnnotationInfo { + public enum ExceptionMode { + ABORT, + NSRESULT, + IGNORE; + + String nativeValue() { + return "mozilla::jni::ExceptionMode::" + name(); + } + } + + public enum CallingThread { + GECKO, + UI, + ANY; + + String nativeValue() { + return "mozilla::jni::CallingThread::" + name(); + } + } + + public enum DispatchTarget { + GECKO, + GECKO_PRIORITY, + PROXY, + CURRENT; + + String nativeValue() { + return "mozilla::jni::DispatchTarget::" + name(); + } + } + + public final String wrapperName; + public final ExceptionMode exceptionMode; + public final CallingThread callingThread; + public final DispatchTarget dispatchTarget; + public final boolean noLiteral; + + public AnnotationInfo( + String wrapperName, + ExceptionMode exceptionMode, + CallingThread callingThread, + DispatchTarget dispatchTarget, + boolean noLiteral) { + + this.wrapperName = wrapperName; + this.exceptionMode = exceptionMode; + this.callingThread = callingThread; + this.dispatchTarget = dispatchTarget; + this.noLiteral = noLiteral; + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/AnnotationProcessor.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/AnnotationProcessor.java new file mode 100644 index 0000000000..8db77eed0b --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/AnnotationProcessor.java @@ -0,0 +1,231 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.Arrays; +import java.util.Iterator; +import org.mozilla.gecko.annotationProcessors.classloader.AnnotatableEntity; +import org.mozilla.gecko.annotationProcessors.classloader.ClassWithOptions; +import org.mozilla.gecko.annotationProcessors.classloader.IterableJarLoadingURLClassLoader; +import org.mozilla.gecko.annotationProcessors.utils.GeneratableElementIterator; + +public class AnnotationProcessor { + private static final String NATIVES_NAME = "Natives"; + private static final String WRAPPERS_NAME = "Wrappers"; + private static final String EXPORT_PREFIX = "mozilla/java/"; + + public static final String GENERATED_COMMENT = + "// GENERATED CODE\n" + + "// Generated by the Java program at /build/annotationProcessors at compile time\n" + + "// from annotations on Java methods. To update, change the annotations on the\n" + + "// corresponding Java methods and rerun the build. Manually updating this file\n" + + "// will cause your build to fail.\n" + + "\n"; + + public static void main(String[] args) { + // We expect a list of jars on the commandline. If missing, whinge about it. + if (args.length < 2) { + System.err.println("Usage: java AnnotationProcessor outprefix jarfiles ..."); + System.exit(1); + } + + final String OUTPUT_PREFIX = args[0]; + final String QUALIFIER = OUTPUT_PREFIX + "JNI"; + + (new File(QUALIFIER)).mkdir(); + + System.out.println("Processing annotations..."); + + // We want to produce the same output as last time as often as possible. Ordering of + // generated statements, therefore, needs to be consistent. + final String[] jars = Arrays.copyOfRange(args, 1, args.length); + Arrays.sort(jars); + + // Start the clock! + long s = System.currentTimeMillis(); + + int ret = 0; + + // Get an iterator over the classes in the jar files given... + Iterator<ClassWithOptions> jarClassIterator = + IterableJarLoadingURLClassLoader.getIteratorOverJars(jars); + + while (jarClassIterator.hasNext()) { + final ClassWithOptions annotatedClass = jarClassIterator.next(); + if (!annotatedClass.hasGenerated()) { + continue; + } + + final String sourceFileName = + QUALIFIER + annotatedClass.generatedName + WRAPPERS_NAME + ".cpp"; + final String headerFileName = + QUALIFIER + File.separator + annotatedClass.generatedName + WRAPPERS_NAME + ".h"; + final String headerExportedFileName = + EXPORT_PREFIX + annotatedClass.generatedName + WRAPPERS_NAME + ".h"; + final String nativesFileName = + QUALIFIER + File.separator + annotatedClass.generatedName + NATIVES_NAME + ".h"; + final String nativesExportedFileName = + EXPORT_PREFIX + annotatedClass.generatedName + NATIVES_NAME + ".h"; + + final StringBuilder headerFile = new StringBuilder(GENERATED_COMMENT); + final StringBuilder implementationFile = new StringBuilder(GENERATED_COMMENT); + final StringBuilder nativesFile = new StringBuilder(GENERATED_COMMENT); + + headerFile.append( + "#ifndef " + + getHeaderGuardName(headerExportedFileName) + + "\n" + + "#define " + + getHeaderGuardName(headerExportedFileName) + + "\n" + + "\n" + + "#ifndef MOZ_PREPROCESSOR\n" + + "#include \"mozilla/jni/Refs.h\"\n" + + "#endif\n" + + "\n" + + "namespace mozilla {\n" + + "namespace java {\n" + + "\n"); + + implementationFile.append( + "#ifndef MOZ_PREPROCESSOR\n" + + "#include \"" + + headerExportedFileName + + "\"\n" + + "#include \"mozilla/jni/Accessors.h\"\n" + + "#endif\n" + + "\n" + + "namespace mozilla {\n" + + "namespace java {\n" + + "\n"); + + nativesFile.append( + "#ifndef " + + getHeaderGuardName(nativesExportedFileName) + + "\n" + + "#define " + + getHeaderGuardName(nativesExportedFileName) + + "\n" + + "\n" + + "#ifndef MOZ_PREPROCESSOR\n" + + "#include \"" + + headerExportedFileName + + "\"\n" + + "#include \"mozilla/jni/Natives.h\"\n" + + "#endif\n" + + "\n" + + "namespace mozilla {\n" + + "namespace java {\n" + + "\n"); + + generateClass(annotatedClass, headerFile, implementationFile, nativesFile); + + implementationFile.append("} /* java */\n" + "} /* mozilla */\n"); + + headerFile.append( + "} /* java */\n" + + "} /* mozilla */\n\n" + + "#endif // " + + getHeaderGuardName(headerExportedFileName) + + "\n"); + + nativesFile.append( + "} /* java */\n" + + "} /* mozilla */\n\n" + + "#endif // " + + getHeaderGuardName(nativesExportedFileName) + + "\n"); + + ret |= writeOutputFile(sourceFileName, implementationFile); + ret |= writeOutputFile(headerFileName, headerFile); + ret |= writeOutputFile(nativesFileName, nativesFile); + } + long e = System.currentTimeMillis(); + System.out.println("Annotation processing complete in " + (e - s) + "ms"); + + System.exit(ret); + } + + private static void generateClass( + final ClassWithOptions annotatedClass, + final StringBuilder headerFile, + final StringBuilder implementationFile, + final StringBuilder nativesFile) { + // Get an iterator over the appropriately generated methods of this class + final GeneratableElementIterator methodIterator = + new GeneratableElementIterator(annotatedClass); + final ClassWithOptions[] innerClasses = methodIterator.getInnerClasses(); + + final CodeGenerator generatorInstance = new CodeGenerator(annotatedClass); + generatorInstance.generateClasses(innerClasses); + + // Iterate all annotated members in this class.. + while (methodIterator.hasNext()) { + AnnotatableEntity aElementTuple = methodIterator.next(); + switch (aElementTuple.mEntityType) { + case METHOD: + generatorInstance.generateMethod(aElementTuple); + break; + case NATIVE: + generatorInstance.generateNative(aElementTuple); + break; + case FIELD: + generatorInstance.generateField(aElementTuple); + break; + case CONSTRUCTOR: + generatorInstance.generateConstructor(aElementTuple); + break; + } + } + + headerFile.append(generatorInstance.getHeaderFileContents()); + implementationFile.append(generatorInstance.getWrapperFileContents()); + nativesFile.append(generatorInstance.getNativesFileContents()); + + for (ClassWithOptions innerClass : innerClasses) { + generateClass(innerClass, headerFile, implementationFile, nativesFile); + } + } + + private static String getHeaderGuardName(final String name) { + return name.replaceAll("\\W", "_"); + } + + private static int writeOutputFile(final String name, final StringBuilder content) { + final byte[] contentBytes = content.toString().getBytes(StandardCharsets.UTF_8); + + try { + final byte[] existingBytes = Files.readAllBytes(new File(name).toPath()); + if (Arrays.equals(contentBytes, existingBytes)) { + return 0; + } + } catch (FileNotFoundException e) { + // Pass. + } catch (NoSuchFileException e) { + // Pass. + } catch (IOException e) { + System.err.println("Unable to read " + name + ". Perhaps a permissions issue?"); + e.printStackTrace(System.err); + return 1; + } + + try (FileOutputStream outStream = new FileOutputStream(name)) { + outStream.write(contentBytes); + } catch (IOException e) { + System.err.println("Unable to write " + name + ". Perhaps a permissions issue?"); + e.printStackTrace(System.err); + return 1; + } + + return 0; + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/CodeGenerator.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/CodeGenerator.java new file mode 100644 index 0000000000..a51f8346cb --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/CodeGenerator.java @@ -0,0 +1,839 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Locale; +import org.mozilla.gecko.annotationProcessors.classloader.AnnotatableEntity; +import org.mozilla.gecko.annotationProcessors.classloader.ClassWithOptions; +import org.mozilla.gecko.annotationProcessors.utils.Utils; + +public class CodeGenerator { + private static final Class<?>[] EMPTY_CLASS_ARRAY = new Class<?>[0]; + + // Buffers holding the strings to ultimately be written to the output files. + private final StringBuilder cpp = new StringBuilder(); + private final StringBuilder header = new StringBuilder(); + private final StringBuilder natives = new StringBuilder(); + private final StringBuilder nativesInits = new StringBuilder(); + + private final Class<?> cls; + private final String clsName; + private final ClassWithOptions options; + private AnnotationInfo.CallingThread callingThread = null; + private int numNativesInits; + + private final HashSet<String> takenMethodNames = new HashSet<String>(); + + public CodeGenerator(ClassWithOptions annotatedClass) { + this.cls = annotatedClass.wrappedClass; + this.clsName = annotatedClass.generatedName; + this.options = annotatedClass; + + final String unqualifiedName = Utils.getUnqualifiedName(clsName); + header.append( + Utils.getIfdefHeader(annotatedClass.ifdef) + + "class " + + clsName + + " : public mozilla::jni::ObjectBase<" + + unqualifiedName + + ">\n" + + "{\n" + + "public:\n" + + " static constexpr char name[] =\n" + + " \"" + + cls.getName().replace('.', '/') + + "\";\n" + + "\n" + + " explicit " + + unqualifiedName + + "(const Context& ctx) : ObjectBase<" + + unqualifiedName + + ">(ctx) {}\n" + + "\n"); + + cpp.append( + Utils.getIfdefHeader(annotatedClass.ifdef) + + "constexpr char " + + clsName + + "::name[];\n" + + "\n"); + + natives.append( + Utils.getIfdefHeader(annotatedClass.ifdef) + + "template<class Impl>\n" + + "class " + + clsName + + "::Natives : " + + "public mozilla::jni::NativeImpl<" + + unqualifiedName + + ", Impl>\n" + + "{\n" + + "public:\n"); + } + + private String getTraitsName(String uniqueName, boolean includeScope) { + return (includeScope ? clsName + "::" : "") + uniqueName + "_t"; + } + + /** + * Return the C++ type name for this class or any class within the chain of declaring classes, if + * the target class matches the given type. + * + * <p>Return null if the given type does not match any class searched. + */ + private String getMatchingClassType(final Class<?> type) { + Class<?> cls = this.cls; + String clsName = this.clsName; + + while (cls != null) { + if (type.equals(cls)) { + return clsName; + } + cls = cls.getDeclaringClass(); + clsName = clsName.substring(0, Math.max(0, clsName.lastIndexOf("::"))); + } + return null; + } + + private String getNativeParameterType(Class<?> type, AnnotationInfo info) { + final String clsName = getMatchingClassType(type); + if (clsName != null) { + return Utils.getUnqualifiedName(clsName) + "::Param"; + } + return Utils.getNativeParameterType(type, info); + } + + private String getNativeReturnType(Class<?> type, AnnotationInfo info) { + final String clsName = getMatchingClassType(type); + if (clsName != null) { + return Utils.getUnqualifiedName(clsName) + "::LocalRef"; + } + return Utils.getNativeReturnType(type, info); + } + + private void generateMember( + AnnotationInfo info, Member member, String uniqueName, Class<?> type, Class<?>[] argTypes) { + // Sanity check. + if (info.noLiteral + && !(member instanceof Field && Utils.isStatic(member) && Utils.isFinal(member))) { + throw new IllegalStateException(clsName + "::" + uniqueName + " is not a static final field"); + } + + final StringBuilder args = new StringBuilder(); + for (Class<?> argType : argTypes) { + args.append("\n " + getNativeParameterType(argType, info) + ","); + } + if (args.length() > 0) { + args.setLength(args.length() - 1); + } + + header.append( + " struct " + + getTraitsName(uniqueName, /* includeScope */ false) + + " {\n" + + " typedef " + + Utils.getUnqualifiedName(clsName) + + " Owner;\n" + + " typedef " + + getNativeReturnType(type, info) + + " ReturnType;\n" + + " typedef " + + getNativeParameterType(type, info) + + " SetterType;\n" + + " typedef mozilla::jni::Args<" + + args + + "> Args;\n" + + " static constexpr char name[] = \"" + + Utils.getMemberName(member) + + "\";\n" + + " static constexpr char signature[] =\n" + + " \"" + + Utils.getSignature(member) + + "\";\n" + + " static const bool isStatic = " + + Utils.isStatic(member) + + ";\n" + + " static const mozilla::jni::ExceptionMode exceptionMode =\n" + + " " + + info.exceptionMode.nativeValue() + + ";\n" + + " static const mozilla::jni::CallingThread callingThread =\n" + + " " + + info.callingThread.nativeValue() + + ";\n" + + " static const mozilla::jni::DispatchTarget dispatchTarget =\n" + + " " + + info.dispatchTarget.nativeValue() + + ";\n" + + " };\n" + + "\n"); + + cpp.append( + "constexpr char " + + getTraitsName(uniqueName, /* includeScope */ true) + + "::name[];\n" + + "constexpr char " + + getTraitsName(uniqueName, /* includeScope */ true) + + "::signature[];\n" + + "\n"); + + if (this.callingThread == null) { + this.callingThread = info.callingThread; + } else if (this.callingThread != info.callingThread) { + // We have a mix of calling threads, so specify "any" for the whole class. + this.callingThread = AnnotationInfo.CallingThread.ANY; + } + } + + private String getUniqueMethodName(String basename) { + String newName = basename; + int index = 1; + + while (takenMethodNames.contains(newName)) { + newName = basename + (++index); + } + + takenMethodNames.add(newName); + return newName; + } + + /** + * Generate a method prototype that includes return and argument types, without specifiers + * (static, const, etc.). + */ + private String generatePrototype( + String name, + Class<?>[] argTypes, + Class<?> returnType, + AnnotationInfo info, + boolean includeScope, + boolean includeArgName, + boolean isConst) { + + final StringBuilder proto = new StringBuilder(); + int argIndex = 0; + + proto.append("auto "); + + if (includeScope) { + proto.append(clsName).append("::"); + } + + proto.append(name).append('('); + + for (Class<?> argType : argTypes) { + proto.append(getNativeParameterType(argType, info)); + if (includeArgName) { + proto.append(" a").append(argIndex++); + } + proto.append(", "); + } + + if (info.exceptionMode == AnnotationInfo.ExceptionMode.NSRESULT + && !returnType.equals(void.class)) { + proto.append(getNativeReturnType(returnType, info)).append('*'); + if (includeArgName) { + proto.append(" a").append(argIndex++); + } + proto.append(", "); + } + + if (proto.substring(proto.length() - 2).equals(", ")) { + proto.setLength(proto.length() - 2); + } + + proto.append(')'); + + if (isConst) { + proto.append(" const"); + } + + if (info.exceptionMode == AnnotationInfo.ExceptionMode.NSRESULT) { + proto.append(" -> nsresult"); + } else { + proto.append(" -> ").append(getNativeReturnType(returnType, info)); + } + return proto.toString(); + } + + /** + * Generate a method declaration that includes the prototype with specifiers, but without the + * method body. + */ + private String generateDeclaration( + String name, + Class<?>[] argTypes, + Class<?> returnType, + AnnotationInfo info, + boolean isStatic) { + + return (isStatic ? "static " : "") + + generatePrototype( + name, + argTypes, + returnType, + info, + /* includeScope */ false, /* includeArgName */ + false, + /* isConst */ !isStatic) + + ';'; + } + + /** + * Generate a method definition that includes the prototype with specifiers, and with the method + * body. + */ + private String generateDefinition( + String accessorName, + String name, + Class<?>[] argTypes, + Class<?> returnType, + AnnotationInfo info, + boolean isStatic) { + + final StringBuilder def = + new StringBuilder( + generatePrototype( + name, + argTypes, + returnType, + info, + /* includeScope */ true, /* includeArgName */ + true, + /* isConst */ !isStatic)); + def.append("\n{\n"); + + // Generate code to handle the return value, if needed. + // We initialize rv to NS_OK instead of NS_ERROR_* because loading NS_OK (0) uses + // fewer instructions. We are guaranteed to set rv to the correct value later. + + if (info.exceptionMode == AnnotationInfo.ExceptionMode.NSRESULT + && returnType.equals(void.class)) { + def.append(" nsresult rv = NS_OK;\n" + " "); + + } else if (info.exceptionMode == AnnotationInfo.ExceptionMode.NSRESULT) { + // Non-void return type + final String resultArg = "a" + argTypes.length; + def.append( + " MOZ_ASSERT(" + + resultArg + + ");\n" + + " nsresult rv = NS_OK;\n" + + " *" + + resultArg + + " = "); + + } else { + def.append(" return "); + } + + // Generate a call, e.g., Method<Traits>::Call(a0, a1, a2); + + def.append(accessorName) + .append("(") + .append(Utils.getUnqualifiedName(clsName) + (isStatic ? "::Context()" : "::mCtx")); + + if (info.exceptionMode == AnnotationInfo.ExceptionMode.NSRESULT) { + def.append(", &rv"); + } else { + def.append(", nullptr"); + } + + // Generate the call argument list. + for (int argIndex = 0; argIndex < argTypes.length; argIndex++) { + def.append(", a").append(argIndex); + } + + def.append(");\n"); + + if (info.exceptionMode == AnnotationInfo.ExceptionMode.NSRESULT) { + def.append(" return rv;\n"); + } + + return def.append("}").toString(); + } + + private static void appendParameterList( + final StringBuilder builder, final Class<?> genScope, final Class<?> paramTypes[]) { + builder.append("("); + + final int maxParamIndex = paramTypes.length - 1; + + for (int i = 0; i < paramTypes.length; ++i) { + builder.append(Utils.getSimplifiedJavaClassName(genScope, paramTypes[i])); + if (i < maxParamIndex) { + builder.append(", "); + } + } + + builder.append(")"); + } + + /** + * This method generates a comment for C++ headers containing a simplified form of a method's Java + * signature. This is entirely for informational purposes to assist developers with disambiguating + * arguments to the native wrappers. + */ + private static String generateJavaStyleMethodSignatureHint( + final Method method, final boolean isStatic) { + final StringBuilder builder = new StringBuilder(" // "); + + if (isStatic) { + builder.append("static "); + } + + final Class<?> declaringClass = method.getDeclaringClass(); + + builder + .append(Utils.getSimplifiedJavaClassName(declaringClass, method.getReturnType())) + .append(" ") + .append(method.getName()); + + appendParameterList(builder, declaringClass, method.getParameterTypes()); + + builder.append("\n"); + return builder.toString(); + } + + /** + * This method generates a comment for C++ headers containing a simplified form of a + * constructors's Java signature. This is entirely for informational purposes to assist developers + * with disambiguating arguments to the native wrappers. + */ + private static String generateJavaStyleConstructorSignatureHint( + final Constructor<?> constructor) { + final StringBuilder builder = new StringBuilder(" // "); + + final Class<?> declaringClass = constructor.getDeclaringClass(); + + builder.append(declaringClass.getSimpleName()); + + appendParameterList(builder, declaringClass, constructor.getParameterTypes()); + + builder.append("\n"); + return builder.toString(); + } + + /** + * Append the appropriate generated code to the buffers for the method provided. + * + * @param annotatedMethod The Java method, plus annotation data. + */ + public void generateMethod(AnnotatableEntity annotatedMethod) { + // Unpack the tuple and extract some useful fields from the Method.. + final Method method = annotatedMethod.getMethod(); + final AnnotationInfo info = annotatedMethod.mAnnotationInfo; + final String uniqueName = getUniqueMethodName(info.wrapperName); + final Class<?>[] argTypes = method.getParameterTypes(); + final Class<?> returnType = method.getReturnType(); + + if (method.isSynthetic()) { + return; + } + + // Sanity check + if (info.dispatchTarget != AnnotationInfo.DispatchTarget.CURRENT) { + throw new IllegalStateException( + "Invalid dispatch target \"" + + info.dispatchTarget.name().toLowerCase(Locale.ROOT) + + "\" for non-native method " + + clsName + + "::" + + uniqueName); + } + + generateMember(info, method, uniqueName, returnType, argTypes); + + final boolean isStatic = Utils.isStatic(method); + + header.append(generateJavaStyleMethodSignatureHint(method, isStatic)); + + header.append( + " " + + generateDeclaration(info.wrapperName, argTypes, returnType, info, isStatic) + + "\n" + + "\n"); + + cpp.append( + generateDefinition( + "mozilla::jni::Method<" + + getTraitsName(uniqueName, /* includeScope */ false) + + ">::Call", + info.wrapperName, + argTypes, + returnType, + info, + isStatic) + + "\n" + + "\n"); + } + + /** + * Append the appropriate generated code to the buffers for the native method provided. + * + * @param annotatedMethod The Java native method, plus annotation data. + */ + public void generateNative(AnnotatableEntity annotatedMethod) { + // Unpack the tuple and extract some useful fields from the Method.. + final Method method = annotatedMethod.getMethod(); + final AnnotationInfo info = annotatedMethod.mAnnotationInfo; + final String uniqueName = getUniqueMethodName(info.wrapperName); + final Class<?>[] argTypes = method.getParameterTypes(); + final Class<?> returnType = method.getReturnType(); + + // Sanity check + if (info.exceptionMode != AnnotationInfo.ExceptionMode.ABORT + && info.exceptionMode != AnnotationInfo.ExceptionMode.IGNORE) { + throw new IllegalStateException( + "Invalid exception mode \"" + + info.exceptionMode.name().toLowerCase(Locale.ROOT) + + "\" for native method " + + clsName + + "::" + + uniqueName); + } + if (info.dispatchTarget != AnnotationInfo.DispatchTarget.CURRENT && returnType != void.class) { + throw new IllegalStateException( + "Must return void when not dispatching to current thread for native method " + + clsName + + "::" + + uniqueName); + } + + generateNativeSignatureHint(info, method, uniqueName, returnType, argTypes); + generateMember(info, method, uniqueName, returnType, argTypes); + + final String traits = getTraitsName(uniqueName, /* includeScope */ true); + + if (nativesInits.length() > 0) { + nativesInits.append(','); + } + + nativesInits.append( + "\n" + + "\n" + + " mozilla::jni::MakeNativeMethod<" + + traits + + ">(\n" + + " mozilla::jni::NativeStub<" + + traits + + ", Impl>\n" + + " ::template Wrap<&Impl::" + + info.wrapperName + + ">)"); + numNativesInits++; + } + + private void generateNativeSignatureHint( + AnnotationInfo info, + Member member, + String uniqueName, + Class<?> returnType, + Class<?>[] argTypes) { + final StringBuilder hint = + new StringBuilder(" // Suggested header signature for native method:\n // "); + + if (Utils.isStatic(member)) { + hint.append("static "); + } + + hint.append(Utils.getNativeReturnTypeHint(returnType, info)) + .append(" ") + .append(uniqueName) + .append("("); + + final int maxParamIndex = argTypes.length - 1; + + for (int i = 0; i < argTypes.length; ++i) { + hint.append(Utils.getNativeParameterTypeHint(argTypes[i], info)); + if (i < maxParamIndex) { + hint.append(", "); + } + } + + hint.append(");\n"); + + header.append(hint.toString()); + } + + private String getLiteral(Object val, AnnotationInfo info) { + final Class<?> type = val.getClass(); + + if (type.equals(char.class) || type.equals(Character.class)) { + final char c = (char) val; + if (c >= 0x20 && c < 0x7F) { + return "'" + c + '\''; + } + return "u'\\u" + Integer.toHexString(0x10000 | (int) c).substring(1) + '\''; + + } else if (type.equals(CharSequence.class) || type.equals(String.class)) { + final CharSequence str = (CharSequence) val; + final StringBuilder out = new StringBuilder("u\""); + for (int i = 0; i < str.length(); i++) { + final char c = str.charAt(i); + if (c >= 0x20 && c < 0x7F) { + out.append(c); + } else { + out.append("\\u").append(Integer.toHexString(0x10000 | (int) c).substring(1)); + } + } + return out.append('"').toString(); + } + + return String.valueOf(val); + } + + public void generateField(AnnotatableEntity annotatedField) { + final Field field = annotatedField.getField(); + final AnnotationInfo info = annotatedField.mAnnotationInfo; + final String uniqueName = info.wrapperName; + final Class<?> type = field.getType(); + + // Handle various cases where we don't care about the field. + if (field.isSynthetic() + || field.getName().equals("$VALUES") + || field.getName().equals("CREATOR")) { + return; + } + + // Sanity check + if (info.dispatchTarget != AnnotationInfo.DispatchTarget.CURRENT) { + throw new IllegalStateException( + "Invalid dispatch target \"" + + info.dispatchTarget.name().toLowerCase(Locale.ROOT) + + "\" for field " + + clsName + + "::" + + uniqueName); + } + + final boolean isStatic = Utils.isStatic(field); + final boolean isFinal = Utils.isFinal(field); + + if (!info.noLiteral + && isStatic + && isFinal + && (type.isPrimitive() || type.equals(String.class))) { + Object val = null; + try { + field.setAccessible(true); + val = field.get(null); + } catch (final IllegalAccessException e) { + } + + if (val != null && type.isPrimitive()) { + // For static final primitive fields, we can use a "static const" declaration. + header.append( + " static const " + + Utils.getNativeReturnType(type, info) + + ' ' + + info.wrapperName + + " = " + + getLiteral(val, info) + + ";\n" + + "\n"); + return; + + } else if (val != null && type.equals(String.class)) { + final String nativeType = "char16_t"; + + header.append(" static const " + nativeType + ' ' + info.wrapperName + "[];\n" + "\n"); + + cpp.append( + "const " + + nativeType + + ' ' + + clsName + + "::" + + info.wrapperName + + "[] = " + + getLiteral(val, info) + + ";\n" + + "\n"); + return; + } + + // Fall back to using accessors if we encounter an exception. + } + + generateMember(info, field, uniqueName, type, EMPTY_CLASS_ARRAY); + + final Class<?>[] getterArgs = EMPTY_CLASS_ARRAY; + + header.append( + " " + + generateDeclaration(info.wrapperName, getterArgs, type, info, isStatic) + + "\n" + + "\n"); + + cpp.append( + generateDefinition( + "mozilla::jni::Field<" + + getTraitsName(uniqueName, /* includeScope */ false) + + ">::Get", + info.wrapperName, + getterArgs, + type, + info, + isStatic) + + "\n" + + "\n"); + + if (isFinal) { + return; + } + + final Class<?>[] setterArgs = new Class<?>[] {type}; + + header.append( + " " + + generateDeclaration(info.wrapperName, setterArgs, void.class, info, isStatic) + + "\n" + + "\n"); + + cpp.append( + generateDefinition( + "mozilla::jni::Field<" + + getTraitsName(uniqueName, /* includeScope */ false) + + ">::Set", + info.wrapperName, + setterArgs, + void.class, + info, + isStatic) + + "\n" + + "\n"); + } + + public void generateConstructor(AnnotatableEntity annotatedConstructor) { + // Unpack the tuple and extract some useful fields from the Method.. + final Constructor<?> method = annotatedConstructor.getConstructor(); + final AnnotationInfo info = annotatedConstructor.mAnnotationInfo; + final String wrapperName = info.wrapperName.equals("<init>") ? "New" : info.wrapperName; + final String uniqueName = getUniqueMethodName(wrapperName); + final Class<?>[] argTypes = method.getParameterTypes(); + final Class<?> returnType = cls; + + if (method.isSynthetic()) { + return; + } + + // Sanity check + if (info.dispatchTarget != AnnotationInfo.DispatchTarget.CURRENT) { + throw new IllegalStateException( + "Invalid dispatch target \"" + + info.dispatchTarget.name().toLowerCase(Locale.ROOT) + + "\" for constructor " + + clsName + + "::" + + uniqueName); + } + + generateMember(info, method, uniqueName, returnType, argTypes); + + header.append(generateJavaStyleConstructorSignatureHint(method)); + + header.append( + " " + + generateDeclaration(wrapperName, argTypes, returnType, info, /* isStatic */ true) + + "\n" + + "\n"); + + cpp.append( + generateDefinition( + "mozilla::jni::Constructor<" + + getTraitsName(uniqueName, /* includeScope */ false) + + ">::Call", + wrapperName, + argTypes, + returnType, + info, /* isStatic */ + true) + + "\n" + + "\n"); + } + + public void generateClasses(final ClassWithOptions[] classes) { + if (classes.length == 0) { + return; + } + + for (final ClassWithOptions cls : classes) { + // Extract "Inner" from "Outer::Inner". + header.append(" class " + Utils.getUnqualifiedName(cls.generatedName) + ";\n"); + } + header.append('\n'); + } + + /** + * Get the finalised bytes to go into the generated wrappers file. + * + * @return The bytes to be written to the wrappers file. + */ + public String getWrapperFileContents() { + cpp.append(Utils.getIfdefFooter(options.ifdef)); + return cpp.toString(); + } + + private boolean haveNatives() { + return nativesInits.length() > 0 || Utils.isJNIObject(cls); + } + + /** + * Get the finalised bytes to go into the generated header file. + * + * @return The bytes to be written to the header file. + */ + public String getHeaderFileContents() { + if (this.callingThread == null) { + this.callingThread = AnnotationInfo.CallingThread.ANY; + } + + header.append( + " static const mozilla::jni::CallingThread callingThread =\n" + + " " + + this.callingThread.nativeValue() + + ";\n" + + "\n"); + + if (haveNatives()) { + header.append(" template<class Impl> class Natives;\n"); + } + header.append("};\n" + "\n" + Utils.getIfdefFooter(options.ifdef)); + return header.toString(); + } + + /** + * Get the finalised bytes to go into the generated natives header file. + * + * @return The bytes to be written to the header file. + */ + public String getNativesFileContents() { + if (!haveNatives()) { + return ""; + } + natives.append( + " static const JNINativeMethod methods[" + + numNativesInits + + "];\n" + + "};\n" + + "\n" + + "template<class Impl>\n" + + "const JNINativeMethod " + + clsName + + "::Natives<Impl>::methods[] = {" + + nativesInits + + '\n' + + "};\n" + + "\n" + + Utils.getIfdefFooter(options.ifdef)); + return natives.toString(); + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/SDKProcessor.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/SDKProcessor.java new file mode 100644 index 0000000000..c0a69de49c --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/SDKProcessor.java @@ -0,0 +1,578 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors; + +/** + * Generate C++ bindings for SDK classes using a config file. + * + * <p>java SDKProcessor <sdkjar> <max-sdk-version> <outdir> [<configfile> <fileprefix>]+ + * + * <p><sdkjar>: jar file containing the SDK classes (e.g. android.jar) <max-sdk-version>: SDK + * version for generated class members (bindings will not be generated for members with SDK versions + * higher than max-sdk-version) <outdir>: output directory for generated binding files <configfile>: + * config file for generating bindings <fileprefix>: prefix used for generated binding files + * + * <p>Each config file is a text file following the .ini format: + * + * <p>; comment [section1] property = value + * + * <p># comment [section2] property = value + * + * <p>Each section specifies a qualified SDK class. Each property specifies a member of that class. + * The class and/or the property may specify options found in the WrapForJNI annotation. For + * example, + * + * <p># Generate bindings for Bundle using default options: [android.os.Bundle] + * + * <p># Generate bindings for Bundle using class options: [android.os.Bundle = + * exceptionMode:nsresult] + * + * <p># Generate bindings for Bundle using method options: [android.os.Bundle] putInt = + * stubName:PutInteger + * + * <p># Generate bindings for Bundle using class options with method override: # (note that all + * options are overriden at the same time.) [android.os.Bundle = exceptionMode:nsresult] # putInt + * will have stubName "PutInteger", and exceptionMode of "abort" putInt = stubName:PutInteger # + * putChar will have stubName "PutCharacter", and exceptionMode of "nsresult" putChar = + * stubName:PutCharacter, exceptionMode:nsresult + * + * <p># Overloded methods can be specified using its signature [android.os.Bundle] # Skip the copy + * constructor <init>(Landroid/os/Bundle;)V = skip:true + * + * <p># Generic member types can be specified [android.view.KeyEvent = skip:true] # Skip everything + * except fields <field> = skip:false + * + * <p># Skip everything except putInt and putChar [android.os.Bundle = skip:true] putInt = + * skip:false putChar = + * + * <p># Avoid conflicts in native bindings [android.os.Bundle] # Bundle(PersistableBundle) native + * binding can conflict with Bundle(ClassLoader) <init>(Landroid/os/PersistableBundle;)V = + * stubName:NewFromPersistableBundle + * + * <p># Generate a getter instead of a literal for certain runtime constants + * [android.os.Build$VERSION = skip:true] SDK_INT = noLiteral:true + */ +import com.android.tools.lint.LintCliClient; +import com.android.tools.lint.checks.ApiLookup; +import com.android.tools.lint.client.api.LintClient; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.FileReader; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Locale; +import org.mozilla.gecko.annotationProcessors.classloader.AnnotatableEntity; +import org.mozilla.gecko.annotationProcessors.classloader.ClassWithOptions; +import org.mozilla.gecko.annotationProcessors.utils.Utils; + +public class SDKProcessor { + public static final String GENERATED_COMMENT = + "// GENERATED CODE\n" + + "// Generated by the Java program at /build/annotationProcessors at compile time\n" + + "// from annotations on Java methods. To update, change the annotations on the\n" + + "// corresponding Javamethods and rerun the build. Manually updating this file\n" + + "// will cause your build to fail.\n" + + "\n"; + + private static ApiLookup sApiLookup; + private static int sMaxSdkVersion; + + private static class ParseException extends Exception { + public ParseException(final String message) { + super(message); + } + } + + private static class ClassInfo { + public final String name; + + // Map constructor/field/method signature to a set of annotation values. + private final HashMap<String, String> mAnnotations = new HashMap<>(); + // Default set of annotation values to use. + private final String mDefaultAnnotation; + // List of nested classes to forward declare. + private final ArrayList<Class<?>> mNestedClasses; + + public ClassInfo(final String text) { + final String[] mapping = text.split("=", 2); + name = mapping[0].trim(); + mDefaultAnnotation = mapping.length > 1 ? mapping[1].trim() : null; + mNestedClasses = new ArrayList<>(); + } + + public void addAnnotation(final String text) throws ParseException { + final String[] mapping = text.split("=", 2); + final String prop = mapping[0].trim(); + if (prop.isEmpty()) { + throw new ParseException("Missing member name: " + text); + } + if (mapping.length < 2) { + throw new ParseException("Missing equal sign: " + text); + } + if (mAnnotations.get(prop) != null) { + throw new ParseException("Already has member: " + prop); + } + mAnnotations.put(prop, mapping[1].trim()); + } + + public AnnotationInfo getAnnotationInfo(final Member member) throws ParseException { + String stubName = Utils.getNativeName(member); + AnnotationInfo.ExceptionMode mode = AnnotationInfo.ExceptionMode.ABORT; + AnnotationInfo.CallingThread thread = AnnotationInfo.CallingThread.ANY; + AnnotationInfo.DispatchTarget target = AnnotationInfo.DispatchTarget.CURRENT; + boolean noLiteral = false; + boolean isGeneric = false; + + final String name = Utils.getMemberName(member); + String annotation = + mAnnotations.get( + name + (member instanceof Field ? ":" : "") + Utils.getSignature(member)); + if (annotation == null) { + // Match name without signature + annotation = mAnnotations.get(name); + } + if (annotation == null) { + // Match <constructor>, <field>, <method> + annotation = + mAnnotations.get( + "<" + member.getClass().getSimpleName().toLowerCase(Locale.ROOT) + '>'); + isGeneric = true; + } + if (annotation == null) { + // Fallback on class options, if any. + annotation = mDefaultAnnotation; + } + if (annotation == null || annotation.isEmpty()) { + return new AnnotationInfo(stubName, mode, thread, target, noLiteral); + } + + final String[] elements = annotation.split(","); + for (final String element : elements) { + final String[] pair = element.split(":", 2); + if (pair.length < 2) { + throw new ParseException("Missing option value: " + element); + } + final String pairName = pair[0].trim(); + final String pairValue = pair[1].trim(); + switch (pairName) { + case "skip": + if (Boolean.valueOf(pairValue)) { + // Return null to signal skipping current method. + return null; + } + break; + case "stubName": + if (isGeneric) { + // Prevent specifying stubName for class options. + throw new ParseException("stubName doesn't make sense here: " + pairValue); + } + stubName = pairValue; + break; + case "exceptionMode": + mode = Utils.getEnumValue(AnnotationInfo.ExceptionMode.class, pairValue); + break; + case "calledFrom": + thread = Utils.getEnumValue(AnnotationInfo.CallingThread.class, pairValue); + break; + case "dispatchTo": + target = Utils.getEnumValue(AnnotationInfo.DispatchTarget.class, pairValue); + break; + case "noLiteral": + noLiteral = Boolean.valueOf(pairValue); + break; + default: + throw new ParseException("Unknown option: " + pairName); + } + } + return new AnnotationInfo(stubName, mode, thread, target, noLiteral); + } + } + + public static void main(String[] args) throws Exception { + // We expect a list of jars on the commandline. If missing, whinge about it. + if (args.length < 3 || args.length % 2 != 1) { + System.err.println( + "Usage: java SDKProcessor sdkjar max-sdk-version outdir [configfile fileprefix]*"); + System.exit(1); + } + + System.out.println("Processing platform bindings..."); + + final File sdkJar = new File(args[0]); + sMaxSdkVersion = Integer.parseInt(args[1]); + final String outdir = args[2]; + + final LintCliClient lintClient = new LintCliClient(LintClient.CLIENT_CLI); + sApiLookup = ApiLookup.get(lintClient); + + for (int argIndex = 3; argIndex < args.length; argIndex += 2) { + final String configFile = args[argIndex]; + final String generatedFilePrefix = args[argIndex + 1]; + System.out.println("Processing bindings from " + configFile); + + // Start the clock! + long s = System.currentTimeMillis(); + + // Get an iterator over the classes in the jar files given... + // Iterator<ClassWithOptions> jarClassIterator = + // IterableJarLoadingURLClassLoader.getIteratorOverJars(args); + + StringBuilder headerFile = new StringBuilder(GENERATED_COMMENT); + headerFile.append( + "#ifndef " + + generatedFilePrefix + + "_h__\n" + + "#define " + + generatedFilePrefix + + "_h__\n" + + "\n" + + "#include \"mozilla/jni/Refs.h\"\n" + + "\n" + + "namespace mozilla {\n" + + "namespace java {\n" + + "namespace sdk {\n" + + "\n"); + + StringBuilder implementationFile = new StringBuilder(GENERATED_COMMENT); + implementationFile.append( + "#include \"" + + generatedFilePrefix + + ".h\"\n" + + "#include \"mozilla/jni/Accessors.h\"\n" + + "\n" + + "namespace mozilla {\n" + + "namespace java {\n" + + "namespace sdk {\n" + + "\n"); + + // Used to track the calls to the various class-specific initialisation functions. + ClassLoader loader = null; + try { + loader = + URLClassLoader.newInstance( + new URL[] {sdkJar.toURI().toURL()}, SDKProcessor.class.getClassLoader()); + } catch (Exception e) { + throw new RuntimeException(e.toString()); + } + + try { + ClassInfo[] classes = getClassList(configFile); + classes = addNestedClassForwardDeclarations(classes, loader); + + for (final ClassInfo cls : classes) { + System.out.println("Looking up: " + cls.name); + generateClass(Class.forName(cls.name, true, loader), cls, implementationFile, headerFile); + } + } catch (final IllegalStateException | IOException | ParseException e) { + System.err.println("***"); + System.err.println("*** Error parsing config file: " + configFile); + System.err.println("*** " + e); + System.err.println("***"); + if (e.getCause() != null) { + e.getCause().printStackTrace(System.err); + } + System.exit(1); + return; + } + + implementationFile.append("} /* sdk */\n" + "} /* java */\n" + "} /* mozilla */\n"); + + headerFile.append("} /* sdk */\n" + "} /* java */\n" + "} /* mozilla */\n" + "#endif\n"); + + writeOutputFiles(outdir, generatedFilePrefix, headerFile, implementationFile); + long e = System.currentTimeMillis(); + System.out.println("SDK processing complete in " + (e - s) + "ms"); + } + } + + private static int getAPIVersion(Class<?> cls, Member m) { + if (m instanceof Method || m instanceof Constructor) { + return sApiLookup.getMethodVersion( + cls.getName().replace('.', '/'), Utils.getMemberName(m), Utils.getSignature(m)); + } else if (m instanceof Field) { + return sApiLookup.getFieldVersion( + Utils.getClassDescriptor(m.getDeclaringClass()), m.getName()); + } else { + throw new IllegalArgumentException("expected member to be Method, Constructor, or Field"); + } + } + + private static Member[] sortAndFilterMembers(Class<?> cls, Member[] members) { + Arrays.sort( + members, + new Comparator<Member>() { + @Override + public int compare(Member a, Member b) { + int result = a.getName().compareTo(b.getName()); + if (result == 0) { + if (a instanceof Constructor && b instanceof Constructor) { + String sa = Arrays.toString(((Constructor) a).getParameterTypes()); + String sb = Arrays.toString(((Constructor) b).getParameterTypes()); + result = sa.compareTo(sb); + } else if (a instanceof Method && b instanceof Method) { + String sa = Arrays.toString(((Method) a).getParameterTypes()); + String sb = Arrays.toString(((Method) b).getParameterTypes()); + result = sa.compareTo(sb); + } + } + return result; + } + }); + + ArrayList<Member> list = new ArrayList<>(); + for (final Member m : members) { + if (m.getDeclaringClass() == Object.class) { + // Skip methods from Object. + continue; + } + + // Sometimes (e.g. Bundle) has methods that moved to/from a superclass in a later SDK + // version, so we check for both classes and see if we can find a minimum SDK version. + int version = getAPIVersion(cls, m); + final int version2 = getAPIVersion(m.getDeclaringClass(), m); + if (version2 > 0 && version2 < version) { + version = version2; + } + if (version > sMaxSdkVersion) { + System.out.println( + "Skipping " + + m.getDeclaringClass().getName() + + "." + + Utils.getMemberName(m) + + ", version " + + version + + " > " + + sMaxSdkVersion); + continue; + } + + // Sometimes (e.g. KeyEvent) a field can appear in both a class and a superclass. In + // that case we want to filter out the version that appears in the superclass, or + // we'll have bindings with duplicate names. + try { + if (m instanceof Field && !m.equals(cls.getField(m.getName()))) { + // m is a field in a superclass that has been hidden by + // a field with the same name in a subclass. + System.out.println( + "Skipping " + Utils.getMemberName(m) + " from " + m.getDeclaringClass().getName()); + continue; + } + } catch (final NoSuchFieldException e) { + } + + list.add(m); + } + + return list.toArray(new Member[list.size()]); + } + + private static void generateMembers(CodeGenerator generator, ClassInfo clsInfo, Member[] members) + throws ParseException { + for (Member m : members) { + if (!Modifier.isPublic(m.getModifiers())) { + continue; + } + + // Default for SDK bindings. + final AnnotationInfo info = clsInfo.getAnnotationInfo(m); + if (info == null) { + // Skip this member. + continue; + } + final AnnotatableEntity entity = new AnnotatableEntity(m, info); + + if (m instanceof Constructor) { + generator.generateConstructor(entity); + } else if (m instanceof Method) { + generator.generateMethod(entity); + } else if (m instanceof Field) { + generator.generateField(entity); + } else { + throw new IllegalArgumentException("expected member to be Constructor, Method, or Field"); + } + } + } + + private static String getGeneratedName(Class<?> clazz) { + ArrayList<String> classes = new ArrayList<>(); + do { + classes.add(clazz.getSimpleName()); + clazz = clazz.getDeclaringClass(); + } while (clazz != null); + Collections.reverse(classes); + return String.join("::", classes); + } + + private static void generateClass( + Class<?> clazz, ClassInfo clsInfo, StringBuilder implementationFile, StringBuilder headerFile) + throws ParseException { + String generatedName = getGeneratedName(clazz); + + CodeGenerator generator = + new CodeGenerator(new ClassWithOptions(clazz, generatedName, /* ifdef */ "")); + + // Forward declaration for nested classes + ClassWithOptions[] nestedClasses = + clsInfo.mNestedClasses.stream() + .map( + nestedClass -> + new ClassWithOptions(nestedClass, getGeneratedName(nestedClass), null)) + .sorted((a, b) -> a.generatedName.compareTo(b.generatedName)) + .toArray(ClassWithOptions[]::new); + generator.generateClasses(nestedClasses); + + generateMembers(generator, clsInfo, sortAndFilterMembers(clazz, clazz.getConstructors())); + generateMembers(generator, clsInfo, sortAndFilterMembers(clazz, clazz.getMethods())); + generateMembers(generator, clsInfo, sortAndFilterMembers(clazz, clazz.getFields())); + + headerFile.append(generator.getHeaderFileContents()); + implementationFile.append(generator.getWrapperFileContents()); + } + + private static ClassInfo[] getClassList(BufferedReader reader) + throws ParseException, IOException { + final ArrayList<ClassInfo> classes = new ArrayList<>(); + ClassInfo currentClass = null; + String line; + + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + switch (line.charAt(0)) { + case ';': + case '#': + // Comment + continue; + case '[': + // New section + if (line.charAt(line.length() - 1) != ']') { + throw new ParseException("Missing trailing ']': " + line); + } + currentClass = new ClassInfo(line.substring(1, line.length() - 1)); + classes.add(currentClass); + break; + default: + // New mapping + if (currentClass == null) { + throw new ParseException("Missing class: " + line); + } + currentClass.addAnnotation(line); + break; + } + } + if (classes.isEmpty()) { + throw new ParseException("No class found in config file"); + } + return classes.toArray(new ClassInfo[classes.size()]); + } + + private static ClassInfo[] getClassList(final String path) throws ParseException, IOException { + BufferedReader reader = null; + try { + reader = new BufferedReader(new FileReader(path)); + return getClassList(reader); + } finally { + if (reader != null) { + reader.close(); + } + } + } + + /** + * For each nested class we wish to generate bindings for, this ensures that the generated binding + * for its outer class (recursively, until we reach a top-level class) will contain a forward + * declaration of the nested class. + */ + private static ClassInfo[] addNestedClassForwardDeclarations( + final ClassInfo[] classes, final ClassLoader loader) throws ClassNotFoundException { + final HashMap<String, ClassInfo> classMap = new HashMap<>(); + for (final ClassInfo cls : classes) { + classMap.put(cls.name, cls); + } + + for (final ClassInfo classInfo : classes) { + Class<?> innerClass = Class.forName(classInfo.name, true, loader); + while (innerClass.getDeclaringClass() != null) { + Class<?> outerClass = innerClass.getDeclaringClass(); + ClassInfo outerClassInfo = classMap.get(outerClass.getName()); + if (outerClassInfo == null) { + // If there isn't already a ClassInfo object for the outer class then we must insert one. + // This ensures that we actually generate a declaration for the outer class, in which we + // can forward-declare the inner class. "skip:true" ensures we do not generate bindings + // for the outer class' member's, as we simply want to forward declare the inner class. + outerClassInfo = new ClassInfo(String.format("%s = skip:true", outerClass.getName())); + classMap.put(outerClass.getName(), outerClassInfo); + } + // Add the inner class to the outer class' mNestedClasses, ensuring the outer class' + // generated code will forward-declare the inner class. + outerClassInfo.mNestedClasses.add(innerClass); + + innerClass = outerClass; + } + } + + // Sort to ensure we generate the classes in a deterministic order, and that outer classes are + // declared before nested classes. + return classMap.values().stream() + .sorted((a, b) -> a.name.compareTo(b.name)) + .toArray(ClassInfo[]::new); + } + + private static void writeOutputFiles( + String aOutputDir, + String aPrefix, + StringBuilder aHeaderFile, + StringBuilder aImplementationFile) { + FileOutputStream implStream = null; + try { + implStream = new FileOutputStream(new File(aOutputDir, aPrefix + ".cpp")); + implStream.write(aImplementationFile.toString().getBytes()); + } catch (IOException e) { + System.err.println("Unable to write " + aOutputDir + ". Perhaps a permissions issue?"); + e.printStackTrace(System.err); + } finally { + if (implStream != null) { + try { + implStream.close(); + } catch (IOException e) { + System.err.println("Unable to close implStream due to " + e); + e.printStackTrace(System.err); + } + } + } + + FileOutputStream headerStream = null; + try { + headerStream = new FileOutputStream(new File(aOutputDir, aPrefix + ".h")); + headerStream.write(aHeaderFile.toString().getBytes()); + } catch (IOException e) { + System.err.println("Unable to write " + aOutputDir + ". Perhaps a permissions issue?"); + e.printStackTrace(System.err); + } finally { + if (headerStream != null) { + try { + headerStream.close(); + } catch (IOException e) { + System.err.println("Unable to close headerStream due to " + e); + e.printStackTrace(System.err); + } + } + } + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/AnnotatableEntity.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/AnnotatableEntity.java new file mode 100644 index 0000000000..b2df6587c1 --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/AnnotatableEntity.java @@ -0,0 +1,68 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors.classloader; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import org.mozilla.gecko.annotationProcessors.AnnotationInfo; + +/** + * Union type to hold either a method, field, or ctor. Allows us to iterate "The generatable stuff", + * despite the fact that such things can be of either flavour. + */ +public class AnnotatableEntity { + public enum ENTITY_TYPE { + METHOD, + NATIVE, + FIELD, + CONSTRUCTOR + } + + private final Member mMember; + public final ENTITY_TYPE mEntityType; + + public final AnnotationInfo mAnnotationInfo; + + public AnnotatableEntity(Member aObject, AnnotationInfo aAnnotationInfo) { + mMember = aObject; + mAnnotationInfo = aAnnotationInfo; + + if (aObject instanceof Method) { + if (Modifier.isNative(aObject.getModifiers())) { + mEntityType = ENTITY_TYPE.NATIVE; + } else { + mEntityType = ENTITY_TYPE.METHOD; + } + } else if (aObject instanceof Field) { + mEntityType = ENTITY_TYPE.FIELD; + } else { + mEntityType = ENTITY_TYPE.CONSTRUCTOR; + } + } + + public Method getMethod() { + if (mEntityType != ENTITY_TYPE.METHOD && mEntityType != ENTITY_TYPE.NATIVE) { + throw new UnsupportedOperationException("Attempt to cast to unsupported member type."); + } + return (Method) mMember; + } + + public Field getField() { + if (mEntityType != ENTITY_TYPE.FIELD) { + throw new UnsupportedOperationException("Attempt to cast to unsupported member type."); + } + return (Field) mMember; + } + + public Constructor<?> getConstructor() { + if (mEntityType != ENTITY_TYPE.CONSTRUCTOR) { + throw new UnsupportedOperationException("Attempt to cast to unsupported member type."); + } + return (Constructor<?>) mMember; + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/ClassWithOptions.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/ClassWithOptions.java new file mode 100644 index 0000000000..d1e4e6cfe0 --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/ClassWithOptions.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.annotationProcessors.classloader; + +import org.mozilla.gecko.annotationProcessors.utils.GeneratableElementIterator; + +public class ClassWithOptions { + public final Class<?> wrappedClass; + public final String generatedName; + public final String ifdef; + + public ClassWithOptions(Class<?> someClass, String name, String ifdef) { + wrappedClass = someClass; + generatedName = name; + this.ifdef = ifdef; + } + + public boolean hasGenerated() { + final GeneratableElementIterator methodIterator = new GeneratableElementIterator(this); + + if (methodIterator.hasNext()) { + return true; + } + + final ClassWithOptions[] innerClasses = methodIterator.getInnerClasses(); + for (ClassWithOptions innerClass : innerClasses) { + if (innerClass.hasGenerated()) { + return true; + } + } + + return false; + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/IterableJarLoadingURLClassLoader.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/IterableJarLoadingURLClassLoader.java new file mode 100644 index 0000000000..50200ef3ec --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/IterableJarLoadingURLClassLoader.java @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors.classloader; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * A classloader which can be initialised with a list of jar files and which can provide an iterator + * over the top level classes in the jar files it was initialised with. classNames is kept sorted to + * ensure iteration order is consistent across program invocations. Otherwise, we'd forever be + * reporting the outdatedness of the generated code as we permute its contents. + */ +public class IterableJarLoadingURLClassLoader extends URLClassLoader { + LinkedList<String> classNames = new LinkedList<String>(); + + /** + * Create an instance and return its iterator. Provides an iterator over the classes in the jar + * files provided as arguments. Inner classes are not supported. + * + * @param args A list of jar file names an iterator over the classes of which is desired. + * @return An iterator over the top level classes in the jar files provided, in arbitrary order. + */ + public static Iterator<ClassWithOptions> getIteratorOverJars(String[] args) { + URL[] urlArray = new URL[args.length]; + LinkedList<String> aClassNames = new LinkedList<String>(); + + for (int i = 0; i < args.length; i++) { + try { + urlArray[i] = (new File(args[i])).toURI().toURL(); + + Enumeration<JarEntry> entries = new JarFile(args[i]).entries(); + while (entries.hasMoreElements()) { + JarEntry e = entries.nextElement(); + String fName = e.getName(); + if (!fName.endsWith(".class")) { + continue; + } + final String className = fName.substring(0, fName.length() - 6).replace('/', '.'); + + aClassNames.add(className); + } + } catch (IOException e) { + System.err.println("Error loading jar file \"" + args[i] + '"'); + e.printStackTrace(System.err); + } + } + Collections.sort(aClassNames); + return new JarClassIterator(new IterableJarLoadingURLClassLoader(urlArray, aClassNames)); + } + + /** + * Constructs a classloader capable of loading all classes given as URLs in urls. Used by static + * method above. + * + * @param urls URLs for all classes the new instance shall be capable of loading. + * @param aClassNames A list of names of the classes this instance shall be capable of loading. + */ + protected IterableJarLoadingURLClassLoader( + URL[] urls, + LinkedList<String> + aClassNames) { // Array to populate with URLs for each class in the given jars. + super(urls); + classNames = aClassNames; + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/JarClassIterator.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/JarClassIterator.java new file mode 100644 index 0000000000..f93667be8d --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/classloader/JarClassIterator.java @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors.classloader; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.Iterator; + +/** + * Class for iterating over an IterableJarLoadingURLClassLoader's classes. + * + * <p>This class is not thread safe: use it only from a single thread. + */ +public class JarClassIterator implements Iterator<ClassWithOptions> { + private IterableJarLoadingURLClassLoader mTarget; + private Iterator<String> mTargetClassListIterator; + + private ClassWithOptions lookAhead; + + public JarClassIterator(IterableJarLoadingURLClassLoader aTarget) { + mTarget = aTarget; + mTargetClassListIterator = aTarget.classNames.iterator(); + } + + @Override + public boolean hasNext() { + return fillLookAheadIfPossible(); + } + + @Override + public ClassWithOptions next() { + if (!fillLookAheadIfPossible()) { + throw new IllegalStateException("Failed to look ahead in next()!"); + } + ClassWithOptions next = lookAhead; + lookAhead = null; + return next; + } + + private boolean fillLookAheadIfPossible() { + if (lookAhead != null) { + return true; + } + + if (!mTargetClassListIterator.hasNext()) { + return false; + } + + String className = mTargetClassListIterator.next(); + try { + Class<?> ret = mTarget.loadClass(className); + + // Incremental builds can leave stale classfiles in the jar. Such classfiles will cause + // an exception at this point. We can safely ignore these classes - they cannot possibly + // ever be loaded as they conflict with their parent class and will be killed by + // Proguard later on anyway. + final Class<?> enclosingClass; + try { + enclosingClass = ret.getEnclosingClass(); + } catch (IncompatibleClassChangeError e) { + return fillLookAheadIfPossible(); + } + + if (enclosingClass != null) { + // Anonymous inner class - unsupported. + // Or named inner class, which will be processed when we process the outer class. + return fillLookAheadIfPossible(); + } + + String ifdef = ""; + for (final Annotation annotation : ret.getDeclaredAnnotations()) { + Class<? extends Annotation> annotationType = annotation.annotationType(); + if (!annotationType.getName().equals("org.mozilla.gecko.annotation.BuildFlag")) { + continue; + } + + try { + final Method valueMethod = annotationType.getDeclaredMethod("value"); + valueMethod.setAccessible(true); + ifdef = (String) valueMethod.invoke(annotation); + break; + } catch (final Exception e) { + System.err.println("Unable to read BuildFlag annotation."); + e.printStackTrace(System.err); + System.exit(1); + } + } + + lookAhead = new ClassWithOptions(ret, ret.getSimpleName(), ifdef); + return true; + } catch (ClassNotFoundException e) { + System.err.println("Unable to enumerate class: " + className + ". Corrupted jar file?"); + e.printStackTrace(); + System.exit(2); + } + return false; + } + + @Override + public void remove() { + throw new UnsupportedOperationException("Removal of classes from iterator not supported."); + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/AlphabeticAnnotatableEntityComparator.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/AlphabeticAnnotatableEntityComparator.java new file mode 100644 index 0000000000..47d8b82fba --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/AlphabeticAnnotatableEntityComparator.java @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors.utils; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.util.Comparator; + +public class AlphabeticAnnotatableEntityComparator<T extends Member> implements Comparator<T> { + @Override + public int compare(T aLhs, T aRhs) { + // Constructors, Methods, Fields. + boolean lIsConstructor = aLhs instanceof Constructor; + boolean rIsConstructor = aRhs instanceof Constructor; + boolean lIsMethod = aLhs instanceof Method; + boolean rIsField = aRhs instanceof Field; + + if (lIsConstructor) { + if (!rIsConstructor) { + return -1; + } + } else if (lIsMethod) { + if (rIsConstructor) { + return 1; + } else if (rIsField) { + return -1; + } + } else { + if (!rIsField) { + return 1; + } + } + + // Verify these objects are the same type and cast them. + if (aLhs instanceof Method) { + return compare((Method) aLhs, (Method) aRhs); + } else if (aLhs instanceof Field) { + return compare((Field) aLhs, (Field) aRhs); + } else { + return compare((Constructor) aLhs, (Constructor) aRhs); + } + } + + // Alas, the type system fails us. + private static int compare(Method aLhs, Method aRhs) { + // Initially, attempt to differentiate the methods be name alone.. + String lName = aLhs.getName(); + String rName = aRhs.getName(); + + int ret = lName.compareTo(rName); + if (ret != 0) { + return ret; + } + + // The names were the same, so we need to compare signatures to find their uniqueness.. + lName = Utils.getSignature(aLhs); + rName = Utils.getSignature(aRhs); + + return lName.compareTo(rName); + } + + private static int compare(Constructor<?> aLhs, Constructor<?> aRhs) { + // The names will be the same, so we need to compare signatures to find their uniqueness.. + String lName = Utils.getSignature(aLhs); + String rName = Utils.getSignature(aRhs); + + return lName.compareTo(rName); + } + + private static int compare(Field aLhs, Field aRhs) { + // Compare field names.. + String lName = aLhs.getName(); + String rName = aRhs.getName(); + + return lName.compareTo(rName); + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/GeneratableElementIterator.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/GeneratableElementIterator.java new file mode 100644 index 0000000000..0ef25cab52 --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/GeneratableElementIterator.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.annotationProcessors.utils; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; +import org.mozilla.gecko.annotationProcessors.AnnotationInfo; +import org.mozilla.gecko.annotationProcessors.classloader.AnnotatableEntity; +import org.mozilla.gecko.annotationProcessors.classloader.ClassWithOptions; + +/** + * Iterator over the methods in a given method list which have the WrappedJNIMethod annotation. + * Returns an object containing both the annotation (Which may contain interesting parameters) and + * the argument. + */ +public class GeneratableElementIterator implements Iterator<AnnotatableEntity> { + private final ClassWithOptions mClass; + private final Member[] mObjects; + private AnnotatableEntity mNextReturnValue; + private int mElementIndex; + private AnnotationInfo mClassInfo; + + private boolean mIterateEveryEntry; + private boolean mIterateEnumValues; + private boolean mSkipCurrentEntry; + + public GeneratableElementIterator(ClassWithOptions annotatedClass) { + mClass = annotatedClass; + + final Class<?> aClass = annotatedClass.wrappedClass; + // Get all the elements of this class as AccessibleObjects. + Member[] aMethods = aClass.getDeclaredMethods(); + Member[] aFields = aClass.getDeclaredFields(); + Member[] aCtors = aClass.getDeclaredConstructors(); + + // Shove them all into one buffer. + Member[] objs = new Member[aMethods.length + aFields.length + aCtors.length]; + + int offset = 0; + System.arraycopy(aMethods, 0, objs, 0, aMethods.length); + offset += aMethods.length; + System.arraycopy(aFields, 0, objs, offset, aFields.length); + offset += aFields.length; + System.arraycopy(aCtors, 0, objs, offset, aCtors.length); + + // Sort the elements to ensure determinism. + Arrays.sort(objs, new AlphabeticAnnotatableEntityComparator<Member>()); + mObjects = objs; + + // Check for "Wrap ALL the things" flag. + for (Annotation annotation : aClass.getDeclaredAnnotations()) { + mClassInfo = buildAnnotationInfo(aClass, annotation); + if (mClassInfo != null) { + if (aClass.isEnum()) { + // We treat "Wrap ALL the things" differently for enums. See the javadoc for + // isAnnotatedEnumField for more information. + mIterateEnumValues = true; + } else { + mIterateEveryEntry = true; + } + break; + } + } + + if (mSkipCurrentEntry) { + throw new IllegalArgumentException("Cannot skip entire class"); + } + + findNextValue(); + } + + private Class<?>[] getFilteredInnerClasses() { + // Go through all inner classes and see which ones we want to generate. + final Class<?>[] candidates = mClass.wrappedClass.getDeclaredClasses(); + int count = 0; + + for (int i = 0; i < candidates.length; ++i) { + final GeneratableElementIterator testIterator = + new GeneratableElementIterator(new ClassWithOptions(candidates[i], null, /* ifdef */ "")); + if (testIterator.hasNext() || testIterator.getFilteredInnerClasses() != null) { + count++; + continue; + } + // Clear out ones that don't match. + candidates[i] = null; + } + return count > 0 ? candidates : null; + } + + public ClassWithOptions[] getInnerClasses() { + final Class<?>[] candidates = getFilteredInnerClasses(); + if (candidates == null) { + return new ClassWithOptions[0]; + } + + int count = 0; + for (Class<?> candidate : candidates) { + if (candidate != null) { + count++; + } + } + + final ClassWithOptions[] ret = new ClassWithOptions[count]; + count = 0; + for (Class<?> candidate : candidates) { + if (candidate != null) { + ret[count++] = + new ClassWithOptions( + candidate, mClass.generatedName + "::" + candidate.getSimpleName(), /* ifdef */ ""); + } + } + assert ret.length == count; + + Arrays.sort( + ret, + new Comparator<ClassWithOptions>() { + @Override + public int compare(ClassWithOptions lhs, ClassWithOptions rhs) { + return lhs.generatedName.compareTo(rhs.generatedName); + } + }); + return ret; + } + + private AnnotationInfo buildAnnotationInfo(AnnotatedElement element, Annotation annotation) { + Class<? extends Annotation> annotationType = annotation.annotationType(); + final String annotationTypeName = annotationType.getName(); + if (!annotationTypeName.equals("org.mozilla.gecko.annotation.WrapForJNI")) { + return null; + } + + String stubName = null; + AnnotationInfo.ExceptionMode exceptionMode = null; + AnnotationInfo.CallingThread callingThread = null; + AnnotationInfo.DispatchTarget dispatchTarget = null; + boolean noLiteral = false; + + try { + final Method skipMethod = annotationType.getDeclaredMethod("skip"); + skipMethod.setAccessible(true); + if ((Boolean) skipMethod.invoke(annotation)) { + mSkipCurrentEntry = true; + return null; + } + + // Determine the explicitly-given name of the stub to generate, if any. + final Method stubNameMethod = annotationType.getDeclaredMethod("stubName"); + stubNameMethod.setAccessible(true); + stubName = (String) stubNameMethod.invoke(annotation); + + final Method exceptionModeMethod = annotationType.getDeclaredMethod("exceptionMode"); + exceptionModeMethod.setAccessible(true); + exceptionMode = + Utils.getEnumValue( + AnnotationInfo.ExceptionMode.class, (String) exceptionModeMethod.invoke(annotation)); + + final Method calledFromMethod = annotationType.getDeclaredMethod("calledFrom"); + calledFromMethod.setAccessible(true); + callingThread = + Utils.getEnumValue( + AnnotationInfo.CallingThread.class, (String) calledFromMethod.invoke(annotation)); + + final Method dispatchToMethod = annotationType.getDeclaredMethod("dispatchTo"); + dispatchToMethod.setAccessible(true); + dispatchTarget = + Utils.getEnumValue( + AnnotationInfo.DispatchTarget.class, (String) dispatchToMethod.invoke(annotation)); + + final Method noLiteralMethod = annotationType.getDeclaredMethod("noLiteral"); + noLiteralMethod.setAccessible(true); + noLiteral = (Boolean) noLiteralMethod.invoke(annotation); + + } catch (NoSuchMethodException e) { + System.err.println( + "Unable to find expected field on WrapForJNI annotation. Did the signature change?"); + e.printStackTrace(System.err); + System.exit(3); + } catch (IllegalAccessException e) { + System.err.println( + "IllegalAccessException reading fields on WrapForJNI annotation. Seems the semantics of Reflection have changed..."); + e.printStackTrace(System.err); + System.exit(4); + } catch (InvocationTargetException e) { + System.err.println( + "InvocationTargetException reading fields on WrapForJNI annotation. This really shouldn't happen."); + e.printStackTrace(System.err); + System.exit(5); + } + + // If the method name was not explicitly given in the annotation generate one... + if (stubName.isEmpty()) { + stubName = Utils.getNativeName(element); + } + + return new AnnotationInfo(stubName, exceptionMode, callingThread, dispatchTarget, noLiteral); + } + + /** + * Find and cache the next appropriately annotated method, plus the annotation parameter, if one + * exists. Otherwise cache null, so hasNext returns false. + */ + private void findNextValue() { + while (mElementIndex < mObjects.length) { + Member candidateElement = mObjects[mElementIndex]; + mElementIndex++; + for (Annotation annotation : ((AnnotatedElement) candidateElement).getDeclaredAnnotations()) { + AnnotationInfo info = buildAnnotationInfo((AnnotatedElement) candidateElement, annotation); + if (info != null) { + mNextReturnValue = new AnnotatableEntity(candidateElement, info); + return; + } + } + + if (mSkipCurrentEntry) { + mSkipCurrentEntry = false; + continue; + } + + // If no annotation found, we might be expected to generate anyway + // using default arguments, thanks to the "Generate everything" annotation. + if (mIterateEveryEntry || isAnnotatedEnumField(candidateElement)) { + AnnotationInfo annotationInfo = + new AnnotationInfo( + Utils.getNativeName(candidateElement), + mClassInfo.exceptionMode, + mClassInfo.callingThread, + mClassInfo.dispatchTarget, + mClassInfo.noLiteral); + mNextReturnValue = new AnnotatableEntity(candidateElement, annotationInfo); + return; + } + } + mNextReturnValue = null; + } + + /** + * For enums that are annotated in their entirety, we typically only need to generate the + * enumerated values, but not other members. This method determines whether the given member is + * likely to be one of the enumerated values: We look for public, static, final fields that share + * the same class as the declaring enum's class. + * + * <p>Note that any additional members that should be wrapped may be explicitly annotated on a + * case-by-case basis. + */ + private boolean isAnnotatedEnumField(final Member member) { + if (!mIterateEnumValues) { + return false; + } + + if (!Utils.isPublic(member) + || !Utils.isStatic(member) + || !Utils.isFinal(member) + || !(member instanceof Field)) { + return false; + } + + final Class<?> enumClass = mClass.wrappedClass; + + final Field field = (Field) member; + final Class<?> fieldClass = field.getType(); + + return enumClass.equals(fieldClass); + } + + @Override + public boolean hasNext() { + return mNextReturnValue != null; + } + + @Override + public AnnotatableEntity next() { + AnnotatableEntity ret = mNextReturnValue; + findNextValue(); + return ret; + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "Removal of methods from GeneratableElementIterator not supported."); + } +} diff --git a/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/Utils.java b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/Utils.java new file mode 100644 index 0000000000..3ed9546223 --- /dev/null +++ b/mobile/android/annotations/src/main/java/org/mozilla/gecko/annotationProcessors/utils/Utils.java @@ -0,0 +1,480 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.annotationProcessors.utils; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Locale; +import org.mozilla.gecko.annotationProcessors.AnnotationInfo; + +/** A collection of utility methods used by CodeGenerator. Largely used for translating types. */ +public class Utils { + + // A collection of lookup tables to simplify the functions to follow... + private static final HashMap<String, String> NATIVE_TYPES = new HashMap<String, String>(); + + static { + NATIVE_TYPES.put("void", "void"); + NATIVE_TYPES.put("boolean", "bool"); + NATIVE_TYPES.put("byte", "int8_t"); + NATIVE_TYPES.put("char", "char16_t"); + NATIVE_TYPES.put("short", "int16_t"); + NATIVE_TYPES.put("int", "int32_t"); + NATIVE_TYPES.put("long", "int64_t"); + NATIVE_TYPES.put("float", "float"); + NATIVE_TYPES.put("double", "double"); + } + + private static final HashMap<String, String> NATIVE_ARRAY_TYPES = new HashMap<String, String>(); + + static { + NATIVE_ARRAY_TYPES.put("boolean", "mozilla::jni::BooleanArray"); + NATIVE_ARRAY_TYPES.put("byte", "mozilla::jni::ByteArray"); + NATIVE_ARRAY_TYPES.put("char", "mozilla::jni::CharArray"); + NATIVE_ARRAY_TYPES.put("short", "mozilla::jni::ShortArray"); + NATIVE_ARRAY_TYPES.put("int", "mozilla::jni::IntArray"); + NATIVE_ARRAY_TYPES.put("long", "mozilla::jni::LongArray"); + NATIVE_ARRAY_TYPES.put("float", "mozilla::jni::FloatArray"); + NATIVE_ARRAY_TYPES.put("double", "mozilla::jni::DoubleArray"); + } + + private static final HashMap<String, String> CLASS_DESCRIPTORS = new HashMap<String, String>(); + + static { + CLASS_DESCRIPTORS.put("void", "V"); + CLASS_DESCRIPTORS.put("boolean", "Z"); + CLASS_DESCRIPTORS.put("byte", "B"); + CLASS_DESCRIPTORS.put("char", "C"); + CLASS_DESCRIPTORS.put("short", "S"); + CLASS_DESCRIPTORS.put("int", "I"); + CLASS_DESCRIPTORS.put("long", "J"); + CLASS_DESCRIPTORS.put("float", "F"); + CLASS_DESCRIPTORS.put("double", "D"); + } + + private static boolean isMozClass(final Class<?> type) { + return type.getName().startsWith("org.mozilla."); + } + + private static boolean useObjectForType(final Class<?> type, final boolean isHint) { + // Essentially we want to know whether we can use generated wrappers or not: + // If |type| is not ours, then it most likely doesn't have generated C++ wrappers. + // Furthermore, we skip interfaces as we generally do not wrap those. + return !isHint || type.equals(Object.class) || !isMozClass(type) || type.isInterface(); + } + + /** + * Returns the simplified name of a class that includes any outer classes but excludes + * package/namespace qualifiers. + * + * @param genScope The current scope of the class containing the current declaration. @Param type + * The class whose simplified name is to be generated. @Param connector String to be used for + * concatenating scopes. + * @return String containing the result + */ + private static String getSimplifiedClassName( + final Class<?> genScope, final Class<?> type, final String connector) { + final ArrayList<String> names = new ArrayList<>(); + + // Starting with |type|, walk up our enclosing classes until we either reach genScope or we + // have reached the outermost scope. We save them to a list because we need to reverse them + // during output. + Class<?> c = type; + do { + names.add(c.getSimpleName()); + c = c.getEnclosingClass(); + } while (c != null && (genScope == null || !genScope.equals(c))); + + // Walk through names in reverse order, joining them using |connector| + final StringBuilder builder = new StringBuilder(); + for (int i = names.size() - 1; i >= 0; --i) { + builder.append(names.get(i)); + if (i > 0) { + builder.append(connector); + } + } + + return builder.toString(); + } + + /** + * Returns the simplified name of a Java class that includes any outer classes but excludes + * package qualifiers. Used for Java signature hints. + * + * @param genScope The current scope of the class containing the current declaration. @Param type + * The class whose simplified name is to be generated. + * @return String containing the result + */ + public static String getSimplifiedJavaClassName(final Class<?> genScope, final Class<?> type) { + return getSimplifiedClassName(genScope, type, "."); + } + + /** Returns the fully-qualified name of the native class wrapper for the given type. */ + public static String getWrappedNativeClassName(final Class<?> type) { + return "mozilla::java::" + getSimplifiedClassName(null, type, "::"); + } + + /** + * Get the C++ parameter type corresponding to the provided type parameter. + * + * @param type Class to determine the corresponding JNI type for. + * @return C++ type as a String + */ + public static String getNativeParameterType(Class<?> type, AnnotationInfo info) { + return getNativeParameterType(type, info, false); + } + + /** + * Get the C++ hint type corresponding to the provided type parameter. The returned type may be + * more specific than the type returned by getNativeParameterType, as this method is used for + * generating comments instead of machine-readable code. + * + * @param type Class to determine the corresponding JNI type for. + * @return C++ type as a String + */ + public static String getNativeParameterTypeHint(Class<?> type, AnnotationInfo info) { + return getNativeParameterType(type, info, true); + } + + private static String getNativeParameterType( + final Class<?> type, final AnnotationInfo info, final boolean isHint) { + final String name = type.getName().replace('.', '/'); + + String value = NATIVE_TYPES.get(name); + if (value != null) { + return value; + } + + if (type.isArray()) { + final String compName = type.getComponentType().getName(); + value = NATIVE_ARRAY_TYPES.get(compName); + if (value != null) { + return value + "::Param"; + } + return "mozilla::jni::ObjectArray::Param"; + } + + if (type.equals(String.class) || type.equals(CharSequence.class)) { + return "mozilla::jni::String::Param"; + } + + if (type.equals(Class.class)) { + // You're doing reflection on Java objects from inside C, returning Class objects + // to C, generating the corresponding code using this Java program. Really?! + return "mozilla::jni::Class::Param"; + } + + if (type.equals(Throwable.class)) { + return "mozilla::jni::Throwable::Param"; + } + + if (type.equals(ByteBuffer.class)) { + return "mozilla::jni::ByteBuffer::Param"; + } + + if (useObjectForType(type, isHint)) { + return "mozilla::jni::Object::Param"; + } + + return getWrappedNativeClassName(type) + "::Param"; + } + + /** + * Get the C++ return type corresponding to the provided type parameter. + * + * @param type Class to determine the corresponding JNI type for. + * @return C++ type as a String + */ + public static String getNativeReturnType(Class<?> type, AnnotationInfo info) { + return getNativeReturnType(type, info, false); + } + + /** + * Get the C++ hint return type corresponding to the provided type parameter. The returned type + * may be more specific than the type returned by getNativeReturnType, as this method is used for + * generating comments instead of machine-readable code. + * + * @param type Class to determine the corresponding JNI type for. + * @return C++ type as a String + */ + public static String getNativeReturnTypeHint(Class<?> type, AnnotationInfo info) { + return getNativeReturnType(type, info, true); + } + + private static String getNativeReturnType( + final Class<?> type, final AnnotationInfo info, final boolean isHint) { + final String name = type.getName().replace('.', '/'); + + String value = NATIVE_TYPES.get(name); + if (value != null) { + return value; + } + + if (type.isArray()) { + final String compName = type.getComponentType().getName(); + value = NATIVE_ARRAY_TYPES.get(compName); + if (value != null) { + return value + "::LocalRef"; + } + return "mozilla::jni::ObjectArray::LocalRef"; + } + + if (type.equals(String.class)) { + return "mozilla::jni::String::LocalRef"; + } + + if (type.equals(Class.class)) { + // You're doing reflection on Java objects from inside C, returning Class objects + // to C, generating the corresponding code using this Java program. Really?! + return "mozilla::jni::Class::LocalRef"; + } + + if (type.equals(Throwable.class)) { + return "mozilla::jni::Throwable::LocalRef"; + } + + if (type.equals(ByteBuffer.class)) { + return "mozilla::jni::ByteBuffer::LocalRef"; + } + + if (useObjectForType(type, isHint)) { + return "mozilla::jni::Object::LocalRef"; + } + + return getWrappedNativeClassName(type) + "::LocalRef"; + } + + /** + * Get the JNI class descriptor corresponding to the provided type parameter. + * + * @param type Class to determine the corresponding JNI descriptor for. + * @return Class descripor as a String + */ + public static String getClassDescriptor(Class<?> type) { + final String name = type.getName().replace('.', '/'); + + final String classDescriptor = CLASS_DESCRIPTORS.get(name); + if (classDescriptor != null) { + return classDescriptor; + } + + if (type.isArray()) { + // Array names are already in class descriptor form. + return name; + } + + return "L" + name + ';'; + } + + /** + * Get the JNI signaure for a member. + * + * @param member Member to get the signature for. + * @return JNI signature as a string + */ + public static String getSignature(Member member) { + return member instanceof Field + ? getSignature((Field) member) + : member instanceof Method + ? getSignature((Method) member) + : getSignature((Constructor<?>) member); + } + + /** + * Get the JNI signaure for a field. + * + * @param member Field to get the signature for. + * @return JNI signature as a string + */ + public static String getSignature(Field member) { + return getClassDescriptor(member.getType()); + } + + private static String getSignature(Class<?>[] args, Class<?> ret) { + final StringBuilder sig = new StringBuilder("("); + for (int i = 0; i < args.length; i++) { + sig.append(getClassDescriptor(args[i])); + } + return sig.append(')').append(getClassDescriptor(ret)).toString(); + } + + /** + * Get the JNI signaure for a method. + * + * @param member Method to get the signature for. + * @return JNI signature as a string + */ + public static String getSignature(Method member) { + return getSignature(member.getParameterTypes(), member.getReturnType()); + } + + /** + * Get the JNI signaure for a constructor. + * + * @param member Constructor to get the signature for. + * @return JNI signature as a string + */ + public static String getSignature(Constructor<?> member) { + return getSignature(member.getParameterTypes(), void.class); + } + + /** + * Get the C++ name for a member. + * + * @param member Member to get the name for. + * @return JNI name as a string + */ + public static String getNativeName(Member member) { + final String name = getMemberName(member); + return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1); + } + + /** + * Get the C++ name for a member. + * + * @param member Member to get the name for. + * @return JNI name as a string + */ + public static String getNativeName(Class<?> clz) { + final String name = clz.getName(); + return name.substring(0, 1).toUpperCase(Locale.ROOT) + name.substring(1); + } + + /** + * Get the C++ name for a member. + * + * @param member Member to get the name for. + * @return JNI name as a string + */ + public static String getNativeName(AnnotatedElement element) { + if (element instanceof Class<?>) { + return getNativeName((Class<?>) element); + } else if (element instanceof Member) { + return getNativeName((Member) element); + } else { + return null; + } + } + + /** + * Get the JNI name for a member. + * + * @param member Member to get the name for. + * @return JNI name as a string + */ + public static String getMemberName(Member member) { + if (member instanceof Constructor) { + return "<init>"; + } + return member.getName(); + } + + public static String getUnqualifiedName(String name) { + return name.substring(name.lastIndexOf(':') + 1); + } + + /** + * Determine if a member is declared static. + * + * @param member The Member to check. + * @return true if the member is declared static, false otherwise. + */ + public static boolean isStatic(final Member member) { + return Modifier.isStatic(member.getModifiers()); + } + + /** + * Determine if a member is declared final. + * + * @param member The Member to check. + * @return true if the member is declared final, false otherwise. + */ + public static boolean isFinal(final Member member) { + return Modifier.isFinal(member.getModifiers()); + } + + /** + * Determine if a member is declared public. + * + * @param member The Member to check. + * @return true if the member is declared public, false otherwise. + */ + public static boolean isPublic(final Member member) { + return Modifier.isPublic(member.getModifiers()); + } + + /** + * Return an enum value with the given name. + * + * @param type Enum class type. + * @param name Enum value name. + * @return Enum value with the given name. + */ + public static <T extends Enum<T>> T getEnumValue(Class<T> type, String name) { + try { + return Enum.valueOf(type, name.toUpperCase(Locale.ROOT)); + + } catch (IllegalArgumentException e) { + final Object[] values; + try { + values = (Object[]) type.getDeclaredMethod("values").invoke(null); + } catch (final NoSuchMethodException + | IllegalAccessException + | InvocationTargetException exception) { + throw new RuntimeException("Cannot access enum: " + type, exception); + } + + StringBuilder names = new StringBuilder(); + + for (int i = 0; i < values.length; i++) { + if (i != 0) { + names.append(", "); + } + names.append(values[i].toString().toLowerCase(Locale.ROOT)); + } + + System.err.println("***"); + System.err.println("*** Invalid value \"" + name + "\" for " + type.getSimpleName()); + System.err.println("*** Specify one of " + names.toString()); + System.err.println("***"); + e.printStackTrace(System.err); + System.exit(1); + return null; + } + } + + public static String getIfdefHeader(String ifdef) { + if (ifdef.isEmpty()) { + return ""; + } else if (ifdef.startsWith("!")) { + return "#ifndef " + ifdef.substring(1) + "\n"; + } + return "#ifdef " + ifdef + "\n"; + } + + public static String getIfdefFooter(String ifdef) { + if (ifdef.isEmpty()) { + return ""; + } + return "#endif // " + ifdef + "\n"; + } + + public static boolean isJNIObject(Class<?> cls) { + for (; cls != null; cls = cls.getSuperclass()) { + if (cls.getName().equals("org.mozilla.gecko.mozglue.JNIObject")) { + return true; + } + } + return false; + } +} diff --git a/mobile/android/app.mozbuild b/mobile/android/app.mozbuild new file mode 100644 index 0000000000..41d998ab8e --- /dev/null +++ b/mobile/android/app.mozbuild @@ -0,0 +1,11 @@ +# 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/. + +include("/toolkit/toolkit.mozbuild") + +DIRS += [ + "/%s" % CONFIG["MOZ_BRANDING_DIRECTORY"], + "/mobile/android", +] diff --git a/mobile/android/app/geckoview-prefs.js b/mobile/android/app/geckoview-prefs.js new file mode 100644 index 0000000000..d71f7f49e8 --- /dev/null +++ b/mobile/android/app/geckoview-prefs.js @@ -0,0 +1,427 @@ +#filter dumbComments emptyLines substitution + +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Non-static prefs that are specific to GeckoView belong in this file. +// +// Please indent all prefs defined within #ifdef/#ifndef conditions. This +// improves readability, particular for conditional blocks that exceed a single +// screen. + +// Caret browsing is disabled on mobile (bug 476009) +pref("accessibility.browsewithcaret_shortcut.enabled", false); + +pref("accessibility.typeaheadfind", false); +pref("accessibility.typeaheadfind.flashBar", 1); +pref("accessibility.typeaheadfind.linksonly", false); +pref("accessibility.typeaheadfind.timeout", 5000); + +pref("app.support.baseURL", "https://support.mozilla.org/1/mobile/%VERSION%/%OS%/%LOCALE%/"); + +#ifdef MOZ_UPDATER + pref("app.update.channel", "@MOZ_UPDATE_CHANNEL@"); +#endif + +// Prefs used by UpdateTimerManager (including blocklist pings) (bug 783909) +pref("app.update.timerFirstInterval", 30000); // milliseconds +pref("app.update.timerMinimumDelay", 30); // seconds + +// Use a breakout angle of 45° (bug 1226655) +pref("apz.axis_lock.breakout_angle", "0.7853982"); + +// APZ content response timeout (bug 1247280) +pref("apz.content_response_timeout", 600); + +// Disable scrollbar dragging on Android (bug 1339831) +pref("apz.drag.enabled", false); + +// Tweak fling curving to make medium-length flings go a bit faster (bug 1243854) +pref("apz.fling_curve_function_x1", "0.59"); +pref("apz.fling_curve_function_x2", "0.05"); +pref("apz.fling_curve_function_y1", "0.46"); +pref("apz.fling_curve_function_y2", "1.00"); + +// :gordonb from UX said this value makes fling curving +// feel a lot better (bug 1095727) +pref("apz.fling_curve_threshold_inches_per_ms", "0.01"); + +// Adjust fling physics based on UX feedback (bug 1229839) +pref("apz.fling_friction", "0.004"); + +// Use Android OverScroller class for fling animation (bug 1229462) +pref("apz.fling_stopped_threshold", "0.0"); + +// :gordonb from UX said this value makes fling curving +// feel a lot better (bug 1095727) +pref("apz.max_velocity_inches_per_ms", "0.07"); + +// Enable overscroll on Android (bug 1230674) +pref("apz.overscroll.enabled", true); + +// Don't allow a faraway second tap to start a one-touch pinch gesture (bug 1391770) +pref("apz.second_tap_tolerance", "0.3"); + +// This value originates from bug 1174532 +pref("apz.touch_move_tolerance", "0.03"); + +// Lower the touch-start tolerance threshold to reduce scroll lag with APZ (bug 1230077) +pref("apz.touch_start_tolerance", "0.06"); + +// The breakpad report server to link to in about:crashes +pref("breakpad.reportURL", "https://crash-stats.mozilla.org/report/index/"); + +// Prevent tooltips from showing up (bug 623063) +pref("browser.chrome.toolbar_tips", false); + +// True if you always want dump() to work +// +// On Android, you also need to do the following for the output +// to show up in logcat: +// +// $ adb shell stop +// $ adb shell setprop log.redirect-stdio true +// $ adb shell start +pref("browser.dom.window.dump.enabled", true); + +// Default to ~/Downloads (bug 437954) +pref("browser.download.folderList", 1); + +// Use Android DownloadManager for scanning downloads (bug 816318) +pref("browser.download.manager.addToRecentDocs", true); + +// Load PDF files inline with PDF.js (bug 1754499) +pref("browser.download.open_pdf_attachments_inline", true); + +pref("browser.download.useDownloadDir", true); + +// When enabled, Services.uriFixup.isDomainKnown('localhost') will return true +pref("browser.fixup.domainwhitelist.localhost", true); + +// Open in tab preferences +pref("browser.link.open_newwindow", 3); + +// Supported values: +// - 0: Force all new windows to tabs +// - 1: Don't force +// - 2: Only force those with no features set +pref("browser.link.open_newwindow.restriction", 0); + +// Don't allow meta-refresh when backgrounded (bug 518805) +pref("browser.meta_refresh_when_inactive.disabled", true); + +// The download protection UI is not implemented yet (bug 1239094). +pref("browser.safebrowsing.downloads.enabled", false); + +pref("browser.safebrowsing.features.cryptomining.update", true); +pref("browser.safebrowsing.features.fingerprinting.update", true); +pref("browser.safebrowsing.features.malware.update", true); +pref("browser.safebrowsing.features.phishing.update", true); +pref("browser.safebrowsing.features.trackingAnnotation.update", true); +pref("browser.safebrowsing.features.trackingProtection.update", true); + +// Disable search suggestions by default (bug 784104) +pref("browser.search.suggest.enabled", false); + +// Disable search engine updating (bug 431842) +pref("browser.search.update", false); + +// Session history +pref("browser.sessionhistory.contentViewerTimeout", 360); +pref("browser.sessionhistory.max_entries", 50); + +// Session store +pref("browser.sessionstore.interval", 10000); // milliseconds +pref("browser.sessionstore.max_resumed_crashes", 2); +pref("browser.sessionstore.max_tabs_undo", 10); +// Supported values: +// - 0: all +// - 1: unencrypted sites +// - 2: never +pref("browser.sessionstore.privacy_level", 0); +pref("browser.sessionstore.resume_from_crash", true); + +// Bug 1809922 to enable translations +#ifdef NIGHTLY_BUILD + pref("browser.translations.enable", true); + // Used for mocking data for GeckoView Translations tests, should use in addition with an automation check. + pref("browser.translations.geckoview.enableAllTestMocks", false); +#endif + +// SSL error page behaviour (bug 437372) +pref("browser.xul.error_pages.expert_bad_cert", false); + +// Enable sparse localization by setting a few package locale overrides (bug 792077) +pref("chrome.override_package.global", "browser"); +pref("chrome.override_package.mozapps", "browser"); +pref("chrome.override_package.passwordmgr", "browser"); + +// Allow Console API to log messages on stdout (bug 1480544) +pref("devtools.console.stdout.chrome", true); + +// Absolute path to the devtools unix domain socket file used +// to communicate with a usb cable via adb forward. +pref("devtools.debugger.unix-domain-socket", "@ANDROID_PACKAGE_NAME@/firefox-debugger-socket"); + +// Enable capture attribute for file input (bug 1553603) +pref("dom.capture.enabled", true); + +// Block popups by default (bug 436057) +pref("dom.disable_open_during_load", true); + +// Don't allow JS to move and resize existing windows (bug 456081) +pref("dom.disable_window_move_resize", true); + +// "graceful" process termination is misinterpreted as a process crash. +// To avoid this issue, we set dom.ipc.keepProcessesAlive.extension to 1. +// This stops Gecko from terminating the extension process. This also reduces +// the overhead of resuming a suspended (background) extension page. +// Note that this only covers "graceful" termination by Gecko. +// Android-triggered force-kills and OOM are not prevented and should still +// be accounted for. See bug 1847608 for more info +pref("dom.ipc.keepProcessesAlive.extension", 1); + +// Keep empty content process alive on Android (bug 1447393) +pref("dom.ipc.keepProcessesAlive.web", 1); + +// This value is derived from the calculation: +// MOZ_ANDROID_CONTENT_SERVICE_COUNT - dom.ipc.processCount +// (dom.ipc.processCount is set in GeckoRuntimeSettings.java) (bug 1619655) +pref("dom.ipc.processCount.webCOOP+COEP", 38); + +// Disable the preallocated process on Android +pref("dom.ipc.processPrelaunch.enabled", false); + +// Increase script timeouts (bug 485610) +pref("dom.max_script_run_time", 20); + +// Enable meta-viewport support for font inflation code (bug 1106255) +pref("dom.meta-viewport.enabled", true); + +// The maximum number of recent message IDs to store for each push +// subscription, to avoid duplicates for unacknowledged messages (bug 1207743) +pref("dom.push.maxRecentMessageIDsPerSubscription", 0); + +// Allow service workers to open windows for a longer period after a notification +// click on mobile. This is to account for some devices being quite slow (bug 1409761) +pref("dom.serviceWorkers.disable_open_click_delay", 5000); + +// Enable WebShare support (bug 1402369) +pref("dom.webshare.enabled", true); + +// The abuse report feature needs some UI that we do not have on mobile +pref("extensions.abuseReport.amWebAPI.enabled", false); + +// Disable add-ons that are not installed by the user in all scopes by default (See the SCOPE +// constants in AddonManager.jsm for values to use here, and Bug 1405528 for a rationale) +pref("extensions.autoDisableScopes", 15); + +pref("extensions.enabledScopes", 5); + +// If true, unprivileged extensions may use experimental APIs +pref("extensions.experiments.enabled", false); + +// Support credit cards in GV autocomplete API (bug 1691819) +pref("extensions.formautofill.addresses.capture.enabled", true); + +pref("extensions.getAddons.browseAddons", "https://addons.mozilla.org/%LOCALE%/android/collections/4757633/mob/?page=1&collection_sort=-popularity"); +pref("extensions.getAddons.cache.enabled", true); +pref("extensions.getAddons.get.url", "https://services.addons.mozilla.org/api/v4/addons/search/?guid=%IDS%&lang=%LOCALE%"); +pref("extensions.getAddons.langpacks.url", "https://services.addons.mozilla.org/api/v4/addons/language-tools/?app=android&type=language&appversion=%VERSION%"); +pref("extensions.getAddons.search.browseURL", "https://addons.mozilla.org/%LOCALE%/android/search?q=%TERMS%&platform=%OS%&appver=%VERSION%"); + +// Don't let XPIProvider install distribution add-ons; we do our own thing on mobile +pref("extensions.installDistroAddons", false); + +// Require language packs to be signed (bug 1454141) +pref("extensions.langpacks.signatures.required", true); + +// Enables some extra Extension System Logging (can reduce performance) +pref("extensions.logging.enabled", false); + +// Whether MV3 restrictions for actions popup urls should be extended to MV2 extensions +// (only allowing same extension urls to be used as action popup urls) +pref("extensions.manifestV2.actionsPopupURLRestricted", true); + +// Disables strict compatibility, making addons compatible-by-default +pref("extensions.strictCompatibility", false); + +// Allow system add-on updates (bug 1260213) +pref("extensions.systemAddon.update.enabled", true); +pref("extensions.systemAddon.update.url", "https://aus5.mozilla.org/update/3/SystemAddons/%VERSION%/%BUILD_ID%/%BUILD_TARGET%/%LOCALE%/%CHANNEL%/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/update.xml"); + +pref("extensions.update.background.url", "https://versioncheck-bg.addons.mozilla.org/update/VersionCheck.php?reqVersion=%REQ_VERSION%&id=%ITEM_ID%&version=%ITEM_VERSION%&maxAppVersion=%ITEM_MAXAPPVERSION%&status=%ITEM_STATUS%&appID=%APP_ID%&appVersion=%APP_VERSION%&appOS=%APP_OS%&appABI=%APP_ABI%&locale=%APP_LOCALE%¤tAppVersion=%CURRENT_APP_VERSION%&updateType=%UPDATE_TYPE%&compatMode=%COMPATIBILITY_MODE%"); +pref("extensions.update.enabled", true); +pref("extensions.update.interval", 86400); +pref("extensions.update.url", "https://versioncheck.addons.mozilla.org/update/VersionCheck.php?reqVersion=%REQ_VERSION%&id=%ITEM_ID%&version=%ITEM_VERSION%&maxAppVersion=%ITEM_MAXAPPVERSION%&status=%ITEM_STATUS%&appID=%APP_ID%&appVersion=%APP_VERSION%&appOS=%APP_OS%&appABI=%APP_ABI%&locale=%APP_LOCALE%¤tAppVersion=%CURRENT_APP_VERSION%&updateType=%UPDATE_TYPE%&compatMode=%COMPATIBILITY_MODE%"); + +// Enable prompts for browser.permissions.request() (bug 1392176) +pref("extensions.webextOptionalPermissionPrompts", true); + +// Start (proxy) extensions as soon as a network request is observed, instead +// of waiting until the first browser window has opened. This is needed because +// GeckoView can trigger requests without opening geckoview.xhtml. +pref("extensions.webextensions.early_background_wakeup_on_request", true); + +// Scroll and zoom into editable form fields (bug 834613) +pref("formhelper.autozoom", true); + +// Optionally send web console output to logcat (bug 1415318) +pref("geckoview.console.enabled", false); + +#ifdef NIGHTLY_BUILD + // Used for mocking data for GeckoView shopping tests, should use in addition with an automation check. + pref("geckoview.shopping.mock_test_response", false); +#endif + +pref("image.cache.size", 1048576); // bytes + +// Inherit locale from the OS, used for multi-locale builds +pref("intl.locale.requested", ""); + +pref("keyword.enabled", true); + +// Always tilt the caret to match the text selection guideline (bug 1097398) +pref("layout.accessiblecaret.always_tilt", true); + +// Show the caret when long tapping on empty content (bug 1246064) +pref("layout.accessiblecaret.caret_shown_when_long_tapping_on_empty_content", true); + +// Initial text selection on long-press is enhanced to provide +// a smarter phone-number selection for direct-dial ActionBar action (bug 1235508) +pref("layout.accessiblecaret.extend_selection_for_phone_number", true); + +// Provide haptic feedback on longPress selection events (bug 1230613) +pref("layout.accessiblecaret.hapticfeedback", true); + +// AccessibleCaret css for Android L style assets (bug 1097398) +pref("layout.accessiblecaret.height", "22.0"); +pref("layout.accessiblecaret.margin-left", "-11.5"); +pref("layout.accessiblecaret.width", "22.0"); + +// Update any visible carets for selection changes due to JS calls, +// but don't show carets if carets are hidden (bug 1463576) +pref("layout.accessiblecaret.script_change_update_mode", 1); + +// Disable CSS error reporting by default to improve performance (bug 831123) +pref("layout.css.report_errors", false); + +pref("layout.spellcheckDefault", 0); + +// Enable EME permission prompts (bug 1620102) +pref("media.eme.require-app-approval", true); + +// Enable autoplay permission prompts (bug 1577596) +pref("media.geckoview.autoplay.request", true); + +// Disable future downloads of OpenH264 on Android (bug 1548679) +pref("media.gmp-gmpopenh264.autoupdate", false); + +// Keep OpenH264 if already installed before. (bug 1532578) +pref("media.gmp-gmpopenh264.enabled", true); +pref("media.gmp-gmpopenh264.visible", true); + +// Enable GMP support in the addon manager (bug 1089867) +pref("media.gmp-provider.enabled", true); + +// Enable Widevine MediaKeySystem (bug 1306219) +pref("media.mediadrm-widevinecdm.visible", true); + +// Ask for permission when enumerating WebRTC devices (bug 1369108) +pref("media.navigator.permission.device", true); + +// On mobile we throttle the download once the readahead_limit is hit +// if we're using a cellular connection, even if the download is slow, +// this is to preserve battery and data (bug 1540573) +pref("media.throttle-cellular-regardless-of-download-rate", true); + +// Number of video frames we buffer while decoding video. +// On Android this is decided by a similar value which varies for +// each OMX decoder |OMX_PARAM_PORTDEFINITIONTYPE::nBufferCountMin|. This +// number must be less than the OMX equivalent or gecko will think it is +// chronically starved of video frames. All decoders seen so far have a value +// of at least 4. (bug 973408) +pref("media.video-queue.default-size", 3); + +// The maximum number of queued frames to send to the compositor. +// On Android, it needs to be throttled because SurfaceTexture contains only one +// (the most recent) image data. (bug 1299068) +pref("media.video-queue.send-to-compositor-size", 1); + +// Increase necko buffer sizes for Android (bug 560591) +pref("network.buffer.cache.size", 16384); + +// CookieBehavior setting for private browsing (bug 1695050) +pref("network.cookie.cookieBehavior.pbmode", 4); + +// Set HPACK receive buffer size appropriately for Android (bug 1296280) +pref("network.http.http2.default-hpack-buffer", 4096); + +// HTTP/2 Server Push (bug 790388) +pref("network.http.http2.push-allowance", 32768); + +// Reduce HTTP Idle connection timeout on Android to improve battery life (bug 1007959) +pref("network.http.keep-alive.timeout", 109); + +// Update connection limits; especially for proxies (bug 648603) +pref("network.http.max-persistent-connections-per-proxy", 20); + +// Disable warning for mailto and tel protocols (bug 589403) +pref("network.protocol-handler.warn-external.mailto", false); +pref("network.protocol-handler.warn-external.tel", false); + +// Disable warning for sms protocol (bug 819554) +pref("network.protocol-handler.warn-external.sms", false); + +// Do not warn when opening YouTube (bug 630364) +pref("network.protocol-handler.warn-external.vnd.youtube", false); + +// Transmit UDP busy-work to the LAN when anticipating low latency +// network reads and on wifi to mitigate 802.11 Power Save Polling delays +// (bug 888268) +pref("network.tickle-wifi.enabled", true); + +// Editing PDFs is not supported on mobile +pref("pdfjs.annotationEditorMode", -1); + +// Enable the floating PDF.js toolbar on GeckoView (bug 1829366) +pref("pdfjs.enableFloatingToolbar", true); + +// Try to convert PDFs sent as octet-stream (bug 1754499) +pref("pdfjs.handleOctetStream", true); + +// Disable tracking protection in PBM for GeckoView (bug 1436887) +pref("privacy.trackingprotection.pbmode.enabled", false); + +pref("privacy.fingerprintingProtection.pbmode", true); + +// Relay integration is not supported on mobile +pref("signon.firefoxRelay.feature", "not available"); + +pref("signon.showAutoCompleteFooter", true); + +// Delegate autocomplete to GeckoView (bug 1618058) +pref("toolkit.autocomplete.delegate", true); + +// Locked because any other value would break GeckoView +pref("toolkit.defaultChromeURI", "chrome://geckoview/content/geckoview.xhtml", locked); + +// Whether to use unified telemetry behavior; requires a restart to take effect +pref("toolkit.telemetry.unified", false); + +// Download protection lists are not available on Android (bug 1397938, bug 1394017) +pref("urlclassifier.downloadAllowTable", ""); +pref("urlclassifier.downloadBlockTable", ""); + +// The Potentially Harmful Apps list replaces the malware one on Android (bug 1394017) +pref("urlclassifier.malwareTable", "goog-harmful-proto,goog-unwanted-proto,moztest-harmful-simple,moztest-malware-simple,moztest-unwanted-simple"); + +// Android doesn't support the new sync storage yet (bug 1625257) +pref("webextensions.storage.sync.kinto", true); + +// Require extensions to be signed (bug 1244329) +pref("xpinstall.signatures.required", true); + +pref("xpinstall.whitelist.add", "https://addons.mozilla.org"); +pref("xpinstall.whitelist.fileRequest", false); diff --git a/mobile/android/app/moz.build b/mobile/android/app/moz.build new file mode 100644 index 0000000000..631f18939a --- /dev/null +++ b/mobile/android/app/moz.build @@ -0,0 +1,36 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +for var in ("APP_NAME", "APP_VERSION"): + DEFINES[var] = CONFIG["MOZ_%s" % var] + +for var in ("MOZ_UPDATER", "MOZ_APP_UA_NAME", "ANDROID_PACKAGE_NAME", "TARGET_CPU"): + DEFINES[var] = CONFIG[var] + +if CONFIG["MOZ_PKG_SPECIAL"]: + DEFINES["MOZ_PKG_SPECIAL"] = CONFIG["MOZ_PKG_SPECIAL"] + +if not CONFIG["MOZ_ANDROID_FAT_AAR_ARCHITECTURES"]: + # Equivalent to JS_PREFERENCE_PP_FILES[CONFIG['ANDROID_CPU_ARCH']], + # which isn't supported out of the box. + FINAL_TARGET_PP_FILES.defaults.pref[CONFIG["ANDROID_CPU_ARCH"]] += [ + "geckoview-prefs.js", + ] +else: + for arch in CONFIG["MOZ_ANDROID_FAT_AAR_ARCHITECTURES"]: + FINAL_TARGET_FILES.defaults.pref[arch] += [ + "!/dist/fat-aar/output/defaults/pref/{arch}/geckoview-prefs.js".format( + arch=arch + ), + ] + +if CONFIG["MOZ_ANDROID_GOOGLE_VR"]: + FINAL_TARGET_FILES += [ + "/" + CONFIG["MOZ_ANDROID_GOOGLE_VR_LIBS"] + "libgvr.so", + ] diff --git a/mobile/android/branding/beta/configure.sh b/mobile/android/branding/beta/configure.sh new file mode 100644 index 0000000000..1c76b657c6 --- /dev/null +++ b/mobile/android/branding/beta/configure.sh @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +MOZ_APP_DISPLAYNAME="Firefox Beta" +ANDROID_PACKAGE_NAME=org.mozilla.firefox_beta diff --git a/mobile/android/branding/beta/content/about.png b/mobile/android/branding/beta/content/about.png Binary files differnew file mode 100644 index 0000000000..d56ec281f7 --- /dev/null +++ b/mobile/android/branding/beta/content/about.png diff --git a/mobile/android/branding/beta/content/favicon32.png b/mobile/android/branding/beta/content/favicon32.png Binary files differnew file mode 100644 index 0000000000..f4701edab6 --- /dev/null +++ b/mobile/android/branding/beta/content/favicon32.png diff --git a/mobile/android/branding/beta/content/favicon64.png b/mobile/android/branding/beta/content/favicon64.png Binary files differnew file mode 100644 index 0000000000..9fe310f346 --- /dev/null +++ b/mobile/android/branding/beta/content/favicon64.png diff --git a/mobile/android/branding/beta/content/jar.mn b/mobile/android/branding/beta/content/jar.mn new file mode 100644 index 0000000000..9e23656457 --- /dev/null +++ b/mobile/android/branding/beta/content/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +geckoview.jar: +% content branding %content/branding/ contentaccessible=yes + content/branding/about.png (about.png) + content/branding/favicon32.png (favicon32.png) + content/branding/favicon64.png (favicon64.png) diff --git a/mobile/android/branding/beta/content/moz.build b/mobile/android/branding/beta/content/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/beta/content/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/beta/locales/en-US/brand.ftl b/mobile/android/branding/beta/locales/en-US/brand.ftl new file mode 100644 index 0000000000..363ea19be8 --- /dev/null +++ b/mobile/android/branding/beta/locales/en-US/brand.ftl @@ -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/. + +## Firefox Brand +## +## Firefox must be treated as a brand, and kept in English. +## It cannot be: +## - Declined to adapt to grammatical case. +## - Transliterated. +## - Translated. +## +## Reference: https://www.mozilla.org/styleguide/communications/translation/ + +-brand-short-name = Firefox Beta +-brand-full-name = Mozilla Firefox Beta +# This brand name can be used in messages where the product name needs to +# remain unchanged across different versions (Nightly, Beta, etc.). +-brand-product-name = Firefox +-vendor-short-name = Mozilla diff --git a/mobile/android/branding/beta/locales/en-US/brand.properties b/mobile/android/branding/beta/locales/en-US/brand.properties new file mode 100644 index 0000000000..8d8a8b8e3e --- /dev/null +++ b/mobile/android/branding/beta/locales/en-US/brand.properties @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +brandShortName=Firefox Beta +brandFullName=Mozilla Firefox Beta diff --git a/mobile/android/branding/beta/locales/jar.mn b/mobile/android/branding/beta/locales/jar.mn new file mode 100644 index 0000000000..51780cce93 --- /dev/null +++ b/mobile/android/branding/beta/locales/jar.mn @@ -0,0 +1,12 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[localization] @AB_CD@.jar: + branding (en-US/**/*.ftl) + +@AB_CD@.jar: +% locale branding @AB_CD@ %locale/branding/ +# Branding only exists in en-US + locale/branding/brand.properties (en-US/brand.properties) diff --git a/mobile/android/branding/beta/locales/moz.build b/mobile/android/branding/beta/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/beta/locales/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/beta/moz.build b/mobile/android/branding/beta/moz.build new file mode 100644 index 0000000000..4b641bad32 --- /dev/null +++ b/mobile/android/branding/beta/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +DIRS += ["content", "locales"] diff --git a/mobile/android/branding/nightly/configure.sh b/mobile/android/branding/nightly/configure.sh new file mode 100644 index 0000000000..10e4e9b08e --- /dev/null +++ b/mobile/android/branding/nightly/configure.sh @@ -0,0 +1,5 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +MOZ_APP_DISPLAYNAME="Firefox Nightly" diff --git a/mobile/android/branding/nightly/content/about.png b/mobile/android/branding/nightly/content/about.png Binary files differnew file mode 100644 index 0000000000..2ca32a355f --- /dev/null +++ b/mobile/android/branding/nightly/content/about.png diff --git a/mobile/android/branding/nightly/content/favicon32.png b/mobile/android/branding/nightly/content/favicon32.png Binary files differnew file mode 100644 index 0000000000..23830c03fc --- /dev/null +++ b/mobile/android/branding/nightly/content/favicon32.png diff --git a/mobile/android/branding/nightly/content/favicon64.png b/mobile/android/branding/nightly/content/favicon64.png Binary files differnew file mode 100644 index 0000000000..d2214dcefe --- /dev/null +++ b/mobile/android/branding/nightly/content/favicon64.png diff --git a/mobile/android/branding/nightly/content/jar.mn b/mobile/android/branding/nightly/content/jar.mn new file mode 100644 index 0000000000..9e23656457 --- /dev/null +++ b/mobile/android/branding/nightly/content/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +geckoview.jar: +% content branding %content/branding/ contentaccessible=yes + content/branding/about.png (about.png) + content/branding/favicon32.png (favicon32.png) + content/branding/favicon64.png (favicon64.png) diff --git a/mobile/android/branding/nightly/content/moz.build b/mobile/android/branding/nightly/content/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/nightly/content/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/nightly/locales/en-US/brand.ftl b/mobile/android/branding/nightly/locales/en-US/brand.ftl new file mode 100644 index 0000000000..57b76f8729 --- /dev/null +++ b/mobile/android/branding/nightly/locales/en-US/brand.ftl @@ -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/. + +## Firefox Brand +## +## Firefox must be treated as a brand, and kept in English. +## It cannot be: +## - Declined to adapt to grammatical case. +## - Transliterated. +## - Translated. +## +## Reference: https://www.mozilla.org/styleguide/communications/translation/ + +-brand-short-name = Nightly +-brand-full-name = Mozilla Nightly +# This brand name can be used in messages where the product name needs to +# remain unchanged across different versions (Nightly, Beta, etc.). +-brand-product-name = Firefox +-vendor-short-name = Mozilla diff --git a/mobile/android/branding/nightly/locales/en-US/brand.properties b/mobile/android/branding/nightly/locales/en-US/brand.properties new file mode 100644 index 0000000000..d060536147 --- /dev/null +++ b/mobile/android/branding/nightly/locales/en-US/brand.properties @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +brandShortName=Nightly +brandFullName=Mozilla Nightly diff --git a/mobile/android/branding/nightly/locales/jar.mn b/mobile/android/branding/nightly/locales/jar.mn new file mode 100644 index 0000000000..dbab394236 --- /dev/null +++ b/mobile/android/branding/nightly/locales/jar.mn @@ -0,0 +1,12 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[localization] @AB_CD@.jar: + branding (en-US/**/*.ftl) + +@AB_CD@.jar: +% locale branding @AB_CD@ %locale/branding/ +# Nightly branding only exists in en-US + locale/branding/brand.properties (en-US/brand.properties) diff --git a/mobile/android/branding/nightly/locales/moz.build b/mobile/android/branding/nightly/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/nightly/locales/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/nightly/moz.build b/mobile/android/branding/nightly/moz.build new file mode 100644 index 0000000000..4b641bad32 --- /dev/null +++ b/mobile/android/branding/nightly/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +DIRS += ["content", "locales"] diff --git a/mobile/android/branding/official/configure.sh b/mobile/android/branding/official/configure.sh new file mode 100644 index 0000000000..939124285d --- /dev/null +++ b/mobile/android/branding/official/configure.sh @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +MOZ_APP_DISPLAYNAME=Firefox +ANDROID_PACKAGE_NAME=org.mozilla.firefox diff --git a/mobile/android/branding/official/content/about.png b/mobile/android/branding/official/content/about.png Binary files differnew file mode 100644 index 0000000000..d56ec281f7 --- /dev/null +++ b/mobile/android/branding/official/content/about.png diff --git a/mobile/android/branding/official/content/favicon32.png b/mobile/android/branding/official/content/favicon32.png Binary files differnew file mode 100644 index 0000000000..b6eb660687 --- /dev/null +++ b/mobile/android/branding/official/content/favicon32.png diff --git a/mobile/android/branding/official/content/favicon64.png b/mobile/android/branding/official/content/favicon64.png Binary files differnew file mode 100644 index 0000000000..1a8fc1ad0a --- /dev/null +++ b/mobile/android/branding/official/content/favicon64.png diff --git a/mobile/android/branding/official/content/jar.mn b/mobile/android/branding/official/content/jar.mn new file mode 100644 index 0000000000..9e23656457 --- /dev/null +++ b/mobile/android/branding/official/content/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +geckoview.jar: +% content branding %content/branding/ contentaccessible=yes + content/branding/about.png (about.png) + content/branding/favicon32.png (favicon32.png) + content/branding/favicon64.png (favicon64.png) diff --git a/mobile/android/branding/official/content/moz.build b/mobile/android/branding/official/content/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/official/content/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/official/locales/en-US/brand.ftl b/mobile/android/branding/official/locales/en-US/brand.ftl new file mode 100644 index 0000000000..d0b052be82 --- /dev/null +++ b/mobile/android/branding/official/locales/en-US/brand.ftl @@ -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/. + +## Firefox Brand +## +## Firefox must be treated as a brand, and kept in English. +## It cannot be: +## - Declined to adapt to grammatical case. +## - Transliterated. +## - Translated. +## +## Reference: https://www.mozilla.org/styleguide/communications/translation/ + +-brand-short-name = Firefox +-brand-full-name = Mozilla Firefox +# This brand name can be used in messages where the product name needs to +# remain unchanged across different versions (Nightly, Beta, etc.). +-brand-product-name = Firefox +-vendor-short-name = Mozilla diff --git a/mobile/android/branding/official/locales/en-US/brand.properties b/mobile/android/branding/official/locales/en-US/brand.properties new file mode 100644 index 0000000000..d0203e35a4 --- /dev/null +++ b/mobile/android/branding/official/locales/en-US/brand.properties @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +brandShortName=Firefox +brandFullName=Mozilla Firefox diff --git a/mobile/android/branding/official/locales/jar.mn b/mobile/android/branding/official/locales/jar.mn new file mode 100644 index 0000000000..51780cce93 --- /dev/null +++ b/mobile/android/branding/official/locales/jar.mn @@ -0,0 +1,12 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[localization] @AB_CD@.jar: + branding (en-US/**/*.ftl) + +@AB_CD@.jar: +% locale branding @AB_CD@ %locale/branding/ +# Branding only exists in en-US + locale/branding/brand.properties (en-US/brand.properties) diff --git a/mobile/android/branding/official/locales/moz.build b/mobile/android/branding/official/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/official/locales/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/official/moz.build b/mobile/android/branding/official/moz.build new file mode 100644 index 0000000000..4b641bad32 --- /dev/null +++ b/mobile/android/branding/official/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +DIRS += ["content", "locales"] diff --git a/mobile/android/branding/unofficial/configure.sh b/mobile/android/branding/unofficial/configure.sh new file mode 100644 index 0000000000..5ada812310 --- /dev/null +++ b/mobile/android/branding/unofficial/configure.sh @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +ANDROID_PACKAGE_NAME=org.mozilla.fennec_`echo ${USER:-unknown} | sed 's/-/_/g'` +MOZ_APP_DISPLAYNAME="Fennec `echo ${USER:-unknown} | sed 's/-/_/g'`" diff --git a/mobile/android/branding/unofficial/content/about.png b/mobile/android/branding/unofficial/content/about.png Binary files differnew file mode 100644 index 0000000000..a52a2f7ce3 --- /dev/null +++ b/mobile/android/branding/unofficial/content/about.png diff --git a/mobile/android/branding/unofficial/content/favicon32.png b/mobile/android/branding/unofficial/content/favicon32.png Binary files differnew file mode 100644 index 0000000000..020227b08b --- /dev/null +++ b/mobile/android/branding/unofficial/content/favicon32.png diff --git a/mobile/android/branding/unofficial/content/favicon64.png b/mobile/android/branding/unofficial/content/favicon64.png Binary files differnew file mode 100644 index 0000000000..268902ddcd --- /dev/null +++ b/mobile/android/branding/unofficial/content/favicon64.png diff --git a/mobile/android/branding/unofficial/content/jar.mn b/mobile/android/branding/unofficial/content/jar.mn new file mode 100644 index 0000000000..9e23656457 --- /dev/null +++ b/mobile/android/branding/unofficial/content/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +geckoview.jar: +% content branding %content/branding/ contentaccessible=yes + content/branding/about.png (about.png) + content/branding/favicon32.png (favicon32.png) + content/branding/favicon64.png (favicon64.png) diff --git a/mobile/android/branding/unofficial/content/moz.build b/mobile/android/branding/unofficial/content/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/unofficial/content/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/unofficial/locales/en-US/brand.ftl b/mobile/android/branding/unofficial/locales/en-US/brand.ftl new file mode 100644 index 0000000000..f69d87c050 --- /dev/null +++ b/mobile/android/branding/unofficial/locales/en-US/brand.ftl @@ -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/. + +## Firefox Brand +## +## Firefox must be treated as a brand, and kept in English. +## It cannot be: +## - Declined to adapt to grammatical case. +## - Transliterated. +## - Translated. +## +## Reference: https://www.mozilla.org/styleguide/communications/translation/ + +-brand-short-name = Fennec +-brand-full-name = Mozilla Fennec +# This brand name can be used in messages where the product name needs to +# remain unchanged across different versions (Nightly, Beta, etc.). +-brand-product-name = Firefox +-vendor-short-name = Mozilla diff --git a/mobile/android/branding/unofficial/locales/en-US/brand.properties b/mobile/android/branding/unofficial/locales/en-US/brand.properties new file mode 100644 index 0000000000..9cedd01afc --- /dev/null +++ b/mobile/android/branding/unofficial/locales/en-US/brand.properties @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +brandShortName=Fennec +brandFullName=Mozilla Fennec diff --git a/mobile/android/branding/unofficial/locales/jar.mn b/mobile/android/branding/unofficial/locales/jar.mn new file mode 100644 index 0000000000..dbab394236 --- /dev/null +++ b/mobile/android/branding/unofficial/locales/jar.mn @@ -0,0 +1,12 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[localization] @AB_CD@.jar: + branding (en-US/**/*.ftl) + +@AB_CD@.jar: +% locale branding @AB_CD@ %locale/branding/ +# Nightly branding only exists in en-US + locale/branding/brand.properties (en-US/brand.properties) diff --git a/mobile/android/branding/unofficial/locales/moz.build b/mobile/android/branding/unofficial/locales/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/branding/unofficial/locales/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/branding/unofficial/moz.build b/mobile/android/branding/unofficial/moz.build new file mode 100644 index 0000000000..4b641bad32 --- /dev/null +++ b/mobile/android/branding/unofficial/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +DIRS += ["content", "locales"] diff --git a/mobile/android/build.mk b/mobile/android/build.mk new file mode 100644 index 0000000000..ef23bc254d --- /dev/null +++ b/mobile/android/build.mk @@ -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/. + +include $(topsrcdir)/toolkit/mozapps/installer/package-name.mk + +installer: + @$(MAKE) -C mobile/android/installer installer + +package: + @$(MAKE) -C mobile/android/installer + +stage-package: + $(MAKE) -C mobile/android/installer stage-package + +deb: package + @$(MAKE) -C mobile/android/installer deb + +upload:: + @$(MAKE) -C mobile/android/installer upload + +wget-en-US: + @$(MAKE) -C mobile/android/locales $@ + +# make -j1 because dependencies in l10n build targets don't work +# with parallel builds +merge-% chrome-%: + $(MAKE) -j1 -C mobile/android/locales $@ + +ifdef ENABLE_TESTS +# Implemented in testing/testsuite-targets.mk + +mochitest-browser-chrome: + $(RUN_MOCHITEST) --flavor=browser + $(CHECK_TEST_ERROR) + +mochitest:: mochitest-browser-chrome + +.PHONY: mochitest-browser-chrome +endif + +ifeq ($(OS_TARGET),Linux) +deb: installer +endif diff --git a/mobile/android/chrome/geckoview/SessionStateAggregator.js b/mobile/android/chrome/geckoview/SessionStateAggregator.js new file mode 100644 index 0000000000..ede17328ac --- /dev/null +++ b/mobile/android/chrome/geckoview/SessionStateAggregator.js @@ -0,0 +1,677 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +const { GeckoViewChildModule } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewChildModule.sys.mjs" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeoutWithTarget: "resource://gre/modules/Timer.sys.mjs", +}); + +const NO_INDEX = Number.MAX_SAFE_INTEGER; +const LAST_INDEX = Number.MAX_SAFE_INTEGER - 1; +const DEFAULT_INTERVAL_MS = 1500; + +// This pref controls whether or not we send updates to the parent on a timeout +// or not, and should only be used for tests or debugging. +const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; + +const PREF_INTERVAL = "browser.sessionstore.interval"; +const PREF_SESSION_COLLECTION = "browser.sessionstore.platform_collection"; + +class Handler { + constructor(store) { + this.store = store; + } + + get mm() { + return this.store.mm; + } + + get eventDispatcher() { + return this.store.eventDispatcher; + } + + get messageQueue() { + return this.store.messageQueue; + } + + get stateChangeNotifier() { + return this.store.stateChangeNotifier; + } +} + +/** + * Listens for state change notifcations from webProgress and notifies each + * registered observer for either the start of a page load, or its completion. + */ +class StateChangeNotifier extends Handler { + constructor(store) { + super(store); + + this._observers = new Set(); + const ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor); + const webProgress = ifreq.getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + } + + /** + * Adds a given observer |obs| to the set of observers that will be notified + * when when a new document starts or finishes loading. + * + * @param obs (object) + */ + addObserver(obs) { + this._observers.add(obs); + } + + /** + * Notifies all observers that implement the given |method|. + * + * @param method (string) + */ + notifyObservers(method) { + for (const obs of this._observers) { + if (typeof obs[method] == "function") { + obs[method](); + } + } + } + + /** + * @see nsIWebProgressListener.onStateChange + */ + onStateChange(webProgress, request, stateFlags, status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { + return; + } + + // onStateChange will be fired when loading the initial about:blank URI for + // a browser, which we don't actually care about. This is particularly for + // the case of unrestored background tabs, where the content has not yet + // been restored: we don't want to accidentally send any updates to the + // parent when the about:blank placeholder page has loaded. + if (!this.mm.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.notifyObservers("onPageLoadStarted"); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + this.notifyObservers("onPageLoadCompleted"); + } + } +} +StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", +]); + +/** + * Listens for changes to the session history. Whenever the user navigates + * we will collect URLs and everything belonging to session history. + * + * Causes a SessionStore:update message to be sent that contains the current + * session history. + * + * Example: + * {entries: [{url: "about:mozilla", ...}, ...], index: 1} + */ +class SessionHistoryListener extends Handler { + constructor(store) { + super(store); + + this._fromIdx = NO_INDEX; + + // The state change observer is needed to handle initial subframe loads. + // It will redundantly invalidate with the SHistoryListener in some cases + // but these invalidations are very cheap. + this.stateChangeNotifier.addObserver(this); + + // By adding the SHistoryListener immediately, we will unfortunately be + // notified of every history entry as the tab is restored. We don't bother + // waiting to add the listener later because these notifications are cheap. + // We will likely only collect once since we are batching collection on + // a delay. + this.mm.docShell + .QueryInterface(Ci.nsIWebNavigation) + .sessionHistory.legacySHistory.addSHistoryListener(this); + + // Listen for page title changes. + this.mm.addEventListener("DOMTitleChanged", this); + } + + uninit() { + const sessionHistory = this.mm.docShell.QueryInterface( + Ci.nsIWebNavigation + ).sessionHistory; + if (sessionHistory) { + sessionHistory.legacySHistory.removeSHistoryListener(this); + } + } + + collect() { + // We want to send down a historychange even for full collects in case our + // session history is a partial session history, in which case we don't have + // enough information for a full update. collectFrom(-1) tells the collect + // function to collect all data avaliable in this process. + if (this.mm.docShell) { + this.collectFrom(-1); + } + } + + // History can grow relatively big with the nested elements, so if we don't have to, we + // don't want to send the entire history all the time. For a simple optimization + // we keep track of the smallest index from after any change has occured and we just send + // the elements from that index. If something more complicated happens we just clear it + // and send the entire history. We always send the additional info like the current selected + // index (so for going back and forth between history entries we set the index to LAST_INDEX + // if nothing else changed send an empty array and the additonal info like the selected index) + collectFrom(idx) { + if (this._fromIdx <= idx) { + // If we already know that we need to update history fromn index N we can ignore any changes + // tha happened with an element with index larger than N. + // Note: initially we use NO_INDEX which is MAX_SAFE_INTEGER which means we don't ignore anything + // here, and in case of navigation in the history back and forth we use LAST_INDEX which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + this._fromIdx = idx; + this.messageQueue.push("historychange", () => { + if (this._fromIdx === NO_INDEX) { + return null; + } + + const history = SessionHistory.collect(this.mm.docShell, this._fromIdx); + this._fromIdx = NO_INDEX; + return history; + }); + } + + handleEvent(event) { + this.collect(); + } + + onPageLoadCompleted() { + this.collect(); + } + + onPageLoadStarted() { + this.collect(); + } + + OnHistoryNewEntry(newURI, oldIndex) { + // We ought to collect the previously current entry as well, see bug 1350567. + // TODO: Reenable partial history collection for performance + // this.collectFrom(oldIndex); + this.collect(); + } + + OnHistoryGotoIndex(index, gotoURI) { + // We ought to collect the previously current entry as well, see bug 1350567. + // TODO: Reenable partial history collection for performance + // this.collectFrom(LAST_INDEX); + this.collect(); + } + + OnHistoryPurge(numEntries) { + this.collect(); + } + + OnHistoryReload(reloadURI, reloadFlags) { + this.collect(); + return true; + } + + OnHistoryReplaceEntry(index) { + this.collect(); + } +} +SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", +]); + +/** + * Listens for scroll position changes. Whenever the user scrolls the top-most + * frame we update the scroll position and will restore it when requested. + * + * Causes a SessionStore:update message to be sent that contains the current + * scroll positions as a tree of strings. If no frame of the whole frame tree + * is scrolled this will return null so that we don't tack a property onto + * the tabData object in the parent process. + * + * Example: + * {scroll: "100,100", zoom: {resolution: "1.5", displaySize: + * {height: "1600", width: "1000"}}, children: + * [null, null, {scroll: "200,200"}]} + */ +class ScrollPositionListener extends Handler { + constructor(store) { + super(store); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "mozvisualscroll", + this, + /* capture */ false, + /* system group */ true + ); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "mozvisualresize", + this, + /* capture */ false, + /* system group */ true + ); + + this.stateChangeNotifier.addObserver(this); + } + + handleEvent() { + this.messageQueue.push("scroll", () => this.collect()); + } + + onPageLoadCompleted() { + this.messageQueue.push("scroll", () => this.collect()); + } + + onPageLoadStarted() { + this.messageQueue.push("scroll", () => null); + } + + collect() { + // TODO: Keep an eye on bug 1525259; we may not have to manually store zoom + // Save the current document resolution. + let zoom = 1; + const scrolldata = + SessionStoreUtils.collectScrollPosition(this.mm.content) || {}; + const domWindowUtils = this.mm.content.windowUtils; + zoom = domWindowUtils.getResolution(); + scrolldata.zoom = {}; + scrolldata.zoom.resolution = zoom; + + // Save some data that'll help in adjusting the zoom level + // when restoring in a different screen orientation. + const displaySize = {}; + const width = {}, + height = {}; + domWindowUtils.getDocumentViewerSize(width, height); + + displaySize.width = width.value; + displaySize.height = height.value; + + scrolldata.zoom.displaySize = displaySize; + + return scrolldata; + } +} + +/** + * Listens for changes to input elements. Whenever the value of an input + * element changes we will re-collect data for the current frame tree and send + * a message to the parent process. + * + * Causes a SessionStore:update message to be sent that contains the form data + * for all reachable frames. + * + * Example: + * { + * formdata: {url: "http://mozilla.org/", id: {input_id: "input value"}}, + * children: [ + * null, + * {url: "http://sub.mozilla.org/", id: {input_id: "input value 2"}} + * ] + * } + */ +class FormDataListener extends Handler { + constructor(store) { + super(store); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "input", + this, + true + ); + this.stateChangeNotifier.addObserver(this); + } + + handleEvent() { + this.messageQueue.push("formdata", () => this.collect()); + } + + onPageLoadStarted() { + this.messageQueue.push("formdata", () => null); + } + + collect() { + return SessionStoreUtils.collectFormData(this.mm.content); + } +} + +/** + * A message queue that takes collected data and will take care of sending it + * to the chrome process. It allows flushing using synchronous messages and + * takes care of any race conditions that might occur because of that. Changes + * will be batched if they're pushed in quick succession to avoid a message + * flood. + */ +class MessageQueue extends Handler { + constructor(store) { + super(store); + + /** + * A map (string -> lazy fn) holding lazy closures of all queued data + * collection routines. These functions will return data collected from the + * docShell. + */ + this._data = new Map(); + + /** + * The delay (in ms) used to delay sending changes after data has been + * invalidated. + */ + this.BATCH_DELAY_MS = 1000; + + /** + * The minimum idle period (in ms) we need for sending data to chrome process. + */ + this.NEEDED_IDLE_PERIOD_MS = 5; + + /** + * Timeout for waiting an idle period to send data. We will set this from + * the pref "browser.sessionstore.interval". + */ + this._timeoutWaitIdlePeriodMs = null; + + /** + * The current timeout ID, null if there is no queue data. We use timeouts + * to damp a flood of data changes and send lots of changes as one batch. + */ + this._timeout = null; + + /** + * Whether or not sending batched messages on a timer is disabled. This should + * only be used for debugging or testing. If you need to access this value, + * you should probably use the timeoutDisabled getter. + */ + this._timeoutDisabled = false; + + /** + * True if there is already a send pending idle dispatch, set to prevent + * scheduling more than one. If false there may or may not be one scheduled. + */ + this._idleScheduled = false; + + this.timeoutDisabled = Services.prefs.getBoolPref( + TIMEOUT_DISABLED_PREF, + false + ); + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref( + PREF_INTERVAL, + DEFAULT_INTERVAL_MS + ); + + Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.addObserver(PREF_INTERVAL, this); + } + + /** + * True if batched messages are not being fired on a timer. This should only + * ever be true when debugging or during tests. + */ + get timeoutDisabled() { + return this._timeoutDisabled; + } + + /** + * Disables sending batched messages on a timer. Also cancels any pending + * timers. + */ + set timeoutDisabled(val) { + this._timeoutDisabled = val; + + if (val && this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + uninit() { + this.cleanupTimers(); + } + + /** + * Cleanup pending idle callback and timer. + */ + cleanupTimers() { + this._idleScheduled = false; + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + switch (data) { + case TIMEOUT_DISABLED_PREF: + this.timeoutDisabled = Services.prefs.getBoolPref( + TIMEOUT_DISABLED_PREF, + false + ); + break; + case PREF_INTERVAL: + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref( + PREF_INTERVAL, + DEFAULT_INTERVAL_MS + ); + break; + default: + debug`Received unknown message: ${data}`; + break; + } + } + } + + /** + * Pushes a given |value| onto the queue. The given |key| represents the type + * of data that is stored and can override data that has been queued before + * but has not been sent to the parent process, yet. + * + * @param key (string) + * A unique identifier specific to the type of data this is passed. + * @param fn (function) + * A function that returns the value that will be sent to the parent + * process. + */ + push(key, fn) { + this._data.set(key, fn); + + if (!this._timeout && !this._timeoutDisabled) { + // Wait a little before sending the message to batch multiple changes. + this._timeout = setTimeoutWithTarget( + () => this.sendWhenIdle(), + this.BATCH_DELAY_MS, + this.mm.tabEventTarget + ); + } + } + + /** + * Sends queued data when the remaining idle time is enough or waiting too + * long; otherwise, request an idle time again. If the |deadline| is not + * given, this function is going to schedule the first request. + * + * @param deadline (object) + * An IdleDeadline object passed by idleDispatch(). + */ + sendWhenIdle(deadline) { + if (!this.mm.content) { + // The frameloader is being torn down. Nothing more to do. + return; + } + + if (deadline) { + if ( + deadline.didTimeout || + deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS + ) { + this.send(); + return; + } + } else if (this._idleScheduled) { + // Bail out if there's a pending run. + return; + } + ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { + timeout: this._timeoutWaitIdlePeriodMs, + }); + this._idleScheduled = true; + } + + /** + * Sends queued data to the chrome process. + * + * @param options (object) + * {isFinal: true} to signal this is the final message sent on unload + */ + send(options = {}) { + // Looks like we have been called off a timeout after the tab has been + // closed. The docShell is gone now and we can just return here as there + // is nothing to do. + if (!this.mm.docShell) { + return; + } + + this.cleanupTimers(); + + const data = {}; + for (const [key, func] of this._data) { + const value = func(); + + if (value || (key != "storagechange" && key != "historychange")) { + data[key] = value; + } + } + + this._data.clear(); + + try { + // Send all data to the parent process. + this.eventDispatcher.sendRequest({ + type: "GeckoView:StateUpdated", + data, + isFinal: options.isFinal || false, + epoch: this.store.epoch, + }); + } catch (ex) { + if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { + warn`Failed to save session state`; + } + } + } +} + +class SessionStateAggregator extends GeckoViewChildModule { + constructor(aModuleName, aMessageManager) { + super(aModuleName, aMessageManager); + + this.mm = aMessageManager; + this.messageQueue = new MessageQueue(this); + this.stateChangeNotifier = new StateChangeNotifier(this); + + this.handlers = [ + new SessionHistoryListener(this), + this.stateChangeNotifier, + this.messageQueue, + ]; + + if (!Services.prefs.getBoolPref(PREF_SESSION_COLLECTION, false)) { + this.handlers.push( + new FormDataListener(this), + new ScrollPositionListener(this) + ); + } + + this.messageManager.addMessageListener("GeckoView:FlushSessionState", this); + } + + receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "GeckoView:FlushSessionState": + this.flush(); + break; + } + } + + flush() { + // Flush the message queue, send the latest updates. + this.messageQueue.send(); + } + + onUnload() { + // Upon frameLoader destruction, send a final update message to + // the parent and flush all data currently held in the child. + this.messageQueue.send({ isFinal: true }); + + for (const handler of this.handlers) { + if (handler.uninit) { + handler.uninit(); + } + } + + // We don't need to take care of any StateChangeNotifier observers as they + // will die with the content script. + } +} + +// TODO: Bug 1648158 Move SessionAggregator to the parent process +class DummySessionStateAggregator extends GeckoViewChildModule { + constructor(aModuleName, aMessageManager) { + super(aModuleName, aMessageManager); + this.messageManager.addMessageListener("GeckoView:FlushSessionState", this); + } + + receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "GeckoView:FlushSessionState": + // Do nothing + break; + } + } +} + +const { debug, warn } = SessionStateAggregator.initLogging( + "SessionStateAggregator" +); + +const module = Services.appinfo.sessionHistoryInParent + ? // If history is handled in the parent we don't need a session aggregator + // TODO: Bug 1648158 remove this and do everything in the parent + DummySessionStateAggregator.create(this) + : SessionStateAggregator.create(this); diff --git a/mobile/android/chrome/geckoview/config.js b/mobile/android/chrome/geckoview/config.js new file mode 100644 index 0000000000..1cf92416ac --- /dev/null +++ b/mobile/android/chrome/geckoview/config.js @@ -0,0 +1,719 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +var Cm = Components.manager; + +const VKB_ENTER_KEY = 13; // User press of VKB enter key +const INITIAL_PAGE_DELAY = 500; // Initial pause on program start for scroll alignment +const PREFS_BUFFER_MAX = 30; // Max prefs buffer size for getPrefsBuffer() +const PAGE_SCROLL_TRIGGER = 200; // Triggers additional getPrefsBuffer() on user scroll-to-bottom +const FILTER_CHANGE_TRIGGER = 200; // Delay between responses to filterInput changes +const INNERHTML_VALUE_DELAY = 100; // Delay before providing prefs innerHTML value + +var gClipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper +); + +/* ============================== NewPrefDialog ============================== + * + * New Preference Dialog Object and methods + * + * Implements User Interfaces for creation of a single(new) Preference setting + * + */ +var NewPrefDialog = { + _prefsShield: null, + + _newPrefsDialog: null, + _newPrefItem: null, + _prefNameInputElt: null, + _prefTypeSelectElt: null, + + _booleanValue: null, + _booleanToggle: null, + _stringValue: null, + _intValue: null, + + _positiveButton: null, + + get type() { + return this._prefTypeSelectElt.value; + }, + + set type(aType) { + this._prefTypeSelectElt.value = aType; + switch (this._prefTypeSelectElt.value) { + case "boolean": + this._prefTypeSelectElt.selectedIndex = 0; + break; + case "string": + this._prefTypeSelectElt.selectedIndex = 1; + break; + case "int": + this._prefTypeSelectElt.selectedIndex = 2; + break; + } + + this._newPrefItem.setAttribute("typestyle", aType); + }, + + // Init the NewPrefDialog + init: function AC_init() { + this._prefsShield = document.getElementById("prefs-shield"); + + this._newPrefsDialog = document.getElementById("new-pref-container"); + this._newPrefItem = document.getElementById("new-pref-item"); + this._prefNameInputElt = document.getElementById("new-pref-name"); + this._prefTypeSelectElt = document.getElementById("new-pref-type"); + + this._booleanValue = document.getElementById("new-pref-value-boolean"); + this._stringValue = document.getElementById("new-pref-value-string"); + this._intValue = document.getElementById("new-pref-value-int"); + + this._positiveButton = document.getElementById("positive-button"); + }, + + // Called to update positive button to display text ("Create"/"Change), and enabled/disabled status + // As new pref name is initially displayed, re-focused, or modifed during user input + _updatePositiveButton: function AC_updatePositiveButton(aPrefName) { + document.l10n.setAttributes( + this._positiveButton, + "config-new-pref-create-button" + ); + this._positiveButton.setAttribute("disabled", true); + if (aPrefName == "") { + return; + } + + // If item already in list, it's being changed, else added + const item = AboutConfig._list.filter(i => { + return i.name == aPrefName; + }); + if (item.length) { + document.l10n.setAttributes( + this._positiveButton, + "config-new-pref-change-button" + ); + } else { + this._positiveButton.removeAttribute("disabled"); + } + }, + + // When we want to cancel/hide an existing, or show a new pref dialog + toggleShowHide: function AC_toggleShowHide() { + if (this._newPrefsDialog.classList.contains("show")) { + this.hide(); + } else { + this._show(); + } + }, + + // When we want to show the new pref dialog / shield the prefs list + _show: function AC_show() { + this._newPrefsDialog.classList.add("show"); + this._prefsShield.setAttribute("shown", true); + + // Initial default field values + this._prefNameInputElt.value = ""; + this._updatePositiveButton(this._prefNameInputElt.value); + + this.type = "boolean"; + this._booleanValue.value = "false"; + this._stringValue.value = ""; + this._intValue.value = ""; + + this._prefNameInputElt.focus(); + + window.addEventListener("keypress", this.handleKeypress); + }, + + // When we want to cancel/hide the new pref dialog / un-shield the prefs list + hide: function AC_hide() { + this._newPrefsDialog.classList.remove("show"); + this._prefsShield.removeAttribute("shown"); + + window.removeEventListener("keypress", this.handleKeypress); + }, + + // Watch user key input so we can provide Enter key action, commit input values + handleKeypress: function AC_handleKeypress(aEvent) { + // Close our VKB on new pref enter key press + if (aEvent.keyCode == VKB_ENTER_KEY) { + aEvent.target.blur(); + } + }, + + // New prefs create dialog only allows creating a non-existing preference, doesn't allow for + // Changing an existing one on-the-fly, tap existing/displayed line item pref for that + create: function AC_create(aEvent) { + if (this._positiveButton.getAttribute("disabled") == "true") { + return; + } + + switch (this.type) { + case "boolean": + Services.prefs.setBoolPref( + this._prefNameInputElt.value, + !!(this._booleanValue.value == "true") + ); + break; + case "string": + Services.prefs.setCharPref( + this._prefNameInputElt.value, + this._stringValue.value + ); + break; + case "int": + Services.prefs.setIntPref( + this._prefNameInputElt.value, + this._intValue.value + ); + break; + } + + // Ensure pref adds flushed to disk immediately + Services.prefs.savePrefFile(null); + + this.hide(); + }, + + // Display proper positive button text/state on new prefs name input focus + focusName: function AC_focusName(aEvent) { + this._updatePositiveButton(aEvent.target.value); + }, + + // Display proper positive button text/state as user changes new prefs name + updateName: function AC_updateName(aEvent) { + this._updatePositiveButton(aEvent.target.value); + }, + + // In new prefs dialog, bool prefs are <input type="text">, as they aren't yet tied to an + // Actual Services.prefs.*etBoolPref() + toggleBoolValue: function AC_toggleBoolValue() { + this._booleanValue.value = + this._booleanValue.value == "true" ? "false" : "true"; + }, +}; + +/* ============================== AboutConfig ============================== + * + * Main AboutConfig object and methods + * + * Implements User Interfaces for maintenance of a list of Preference settings + * + */ +var AboutConfig = { + contextMenuLINode: null, + filterInput: null, + _filterPrevInput: null, + _filterChangeTimer: null, + _prefsContainer: null, + _loadingContainer: null, + _list: null, + + // Init the main AboutConfig dialog + init: function AC_init() { + this.filterInput = document.getElementById("filter-input"); + this._prefsContainer = document.getElementById("prefs-container"); + this._loadingContainer = document.getElementById("loading-container"); + + const list = Services.prefs.getChildList(""); + this._list = list.sort().map(function AC_getMapPref(aPref) { + return new Pref(aPref); + }, this); + + // Support filtering about:config via a ?filter=<string> param + const match = /[?&]filter=([^&]+)/i.exec(window.location.href); + if (match) { + this.filterInput.value = decodeURIComponent(match[1]); + } + + // Display the current prefs list (retains searchFilter value) + this.bufferFilterInput(); + + // Setup the prefs observers + Services.prefs.addObserver("", this); + }, + + // Uninit the main AboutConfig dialog + uninit: function AC_uninit() { + // Remove the prefs observer + Services.prefs.removeObserver("", this); + }, + + // Clear the filterInput value, to display the entire list + clearFilterInput: function AC_clearFilterInput() { + this.filterInput.value = ""; + this.bufferFilterInput(); + }, + + // Buffer down rapid changes in filterInput value from keyboard + bufferFilterInput: function AC_bufferFilterInput() { + if (this._filterChangeTimer) { + clearTimeout(this._filterChangeTimer); + } + + this._filterChangeTimer = setTimeout(() => { + this._filterChangeTimer = null; + // Display updated prefs list when filterInput value settles + this._displayNewList(); + }, FILTER_CHANGE_TRIGGER); + }, + + // Update displayed list when filterInput value changes + _displayNewList: function AC_displayNewList() { + // This survives the search filter value past a page refresh + this.filterInput.setAttribute("value", this.filterInput.value); + + // Don't start new filter search if same as last + if (this.filterInput.value == this._filterPrevInput) { + return; + } + this._filterPrevInput = this.filterInput.value; + + // Clear list item selection / context menu, prefs list, get first buffer, set scrolling on + this.selected = ""; + this._clearPrefsContainer(); + this._addMorePrefsToContainer(); + window.onscroll = this.onScroll.bind(this); + + // Pause for screen to settle, then ensure at top + setTimeout(() => { + window.scrollTo(0, 0); + }, INITIAL_PAGE_DELAY); + }, + + // Clear the displayed preferences list + _clearPrefsContainer: function AC_clearPrefsContainer() { + // Quick clear the prefsContainer list + const empty = this._prefsContainer.cloneNode(false); + this._prefsContainer.parentNode.replaceChild(empty, this._prefsContainer); + this._prefsContainer = empty; + + // Quick clear the prefs li.HTML list + this._list.forEach(function (item) { + delete item.li; + }); + }, + + // Get a small manageable block of prefs items, and add them to the displayed list + _addMorePrefsToContainer: function AC_addMorePrefsToContainer() { + // Create filter regex + const filterExp = this.filterInput.value + ? new RegExp(this.filterInput.value, "i") + : null; + + // Get a new block for the display list + const prefsBuffer = []; + for ( + let i = 0; + i < this._list.length && prefsBuffer.length < PREFS_BUFFER_MAX; + i++ + ) { + if (!this._list[i].li && this._list[i].test(filterExp)) { + prefsBuffer.push(this._list[i]); + } + } + + // Add the new block to the displayed list + for (let i = 0; i < prefsBuffer.length; i++) { + this._prefsContainer.appendChild(prefsBuffer[i].getOrCreateNewLINode()); + } + + // Determine if anything left to add later by scrolling + let anotherPrefsBufferRemains = false; + for (let i = 0; i < this._list.length; i++) { + if (!this._list[i].li && this._list[i].test(filterExp)) { + anotherPrefsBufferRemains = true; + break; + } + } + + if (anotherPrefsBufferRemains) { + // If still more could be displayed, show the throbber + this._loadingContainer.style.display = "block"; + } else { + // If no more could be displayed, hide the throbber, and stop noticing scroll events + this._loadingContainer.style.display = "none"; + window.onscroll = null; + } + }, + + // If scrolling at the bottom, maybe add some more entries + onScroll: function AC_onScroll(aEvent) { + if ( + this._prefsContainer.scrollHeight - + (window.pageYOffset + window.innerHeight) < + PAGE_SCROLL_TRIGGER + ) { + if (!this._filterChangeTimer) { + this._addMorePrefsToContainer(); + } + } + }, + + // Return currently selected list item node + get selected() { + return document.querySelector(".pref-item.selected"); + }, + + // Set list item node as selected + set selected(aSelection) { + const currentSelection = this.selected; + if (aSelection == currentSelection) { + return; + } + + // Clear any previous selection + if (currentSelection) { + currentSelection.classList.remove("selected"); + currentSelection.removeEventListener("keypress", this.handleKeypress); + } + + // Set any current selection + if (aSelection) { + aSelection.classList.add("selected"); + aSelection.addEventListener("keypress", this.handleKeypress); + } + }, + + // Watch user key input so we can provide Enter key action, commit input values + handleKeypress: function AC_handleKeypress(aEvent) { + if (aEvent.keyCode == VKB_ENTER_KEY) { + aEvent.target.blur(); + } + }, + + // Return the target list item node of an action event + getLINodeForEvent: function AC_getLINodeForEvent(aEvent) { + let node = aEvent.target; + while (node && node.nodeName != "li") { + node = node.parentNode; + } + + return node; + }, + + // Return a pref of a list item node + _getPrefForNode: function AC_getPrefForNode(aNode) { + const pref = aNode.getAttribute("name"); + + return new Pref(pref); + }, + + // When list item name or value are tapped + selectOrToggleBoolPref: function AC_selectOrToggleBoolPref(aEvent) { + const node = this.getLINodeForEvent(aEvent); + + // If not already selected, just do so + if (this.selected != node) { + this.selected = node; + return; + } + + // If already selected, and value is boolean, toggle it + const pref = this._getPrefForNode(node); + if (pref.type != Services.prefs.PREF_BOOL) { + return; + } + + this.toggleBoolPref(aEvent); + }, + + // When finalizing list input values due to blur + setIntOrStringPref: function AC_setIntOrStringPref(aEvent) { + const node = this.getLINodeForEvent(aEvent); + + // Skip if locked + const pref = this._getPrefForNode(node); + if (pref.locked) { + return; + } + + // Boolean inputs blur to remove focus from "button" + if (pref.type == Services.prefs.PREF_BOOL) { + return; + } + + // String and Int inputs change / commit on blur + pref.value = aEvent.target.value; + }, + + // When we reset a pref to it's default value (note resetting a user created pref will delete it) + resetDefaultPref: function AC_resetDefaultPref(aEvent) { + const node = this.getLINodeForEvent(aEvent); + + // If not already selected, do so + if (this.selected != node) { + this.selected = node; + } + + // Reset will handle any locked condition + const pref = this._getPrefForNode(node); + pref.reset(); + + // Ensure pref reset flushed to disk immediately + Services.prefs.savePrefFile(null); + }, + + // When we want to toggle a bool pref + toggleBoolPref: function AC_toggleBoolPref(aEvent) { + const node = this.getLINodeForEvent(aEvent); + + // Skip if locked, or not boolean + const pref = this._getPrefForNode(node); + if (pref.locked) { + return; + } + + // Toggle, and blur to remove field focus + pref.value = !pref.value; + aEvent.target.blur(); + }, + + // When Int inputs have their Up or Down arrows toggled + incrOrDecrIntPref: function AC_incrOrDecrIntPref(aEvent, aInt) { + const node = this.getLINodeForEvent(aEvent); + + // Skip if locked + const pref = this._getPrefForNode(node); + if (pref.locked) { + return; + } + + pref.value += aInt; + }, + + // Observe preference changes + observe: function AC_observe(aSubject, aTopic, aPrefName) { + const pref = new Pref(aPrefName); + + // Ignore uninteresting changes, and avoid "private" preferences + if (aTopic != "nsPref:changed") { + return; + } + + // If pref type invalid, refresh display as user reset/removed an item from the list + if (pref.type == Services.prefs.PREF_INVALID) { + document.location.reload(); + return; + } + + // If pref onscreen, update in place. + const item = document.querySelector( + '.pref-item[name="' + CSS.escape(pref.name) + '"]' + ); + if (item) { + item.setAttribute("value", pref.value); + const input = item.querySelector("input"); + input.setAttribute("value", pref.value); + input.value = pref.value; + + pref.default + ? item.querySelector(".reset").setAttribute("disabled", "true") + : item.querySelector(".reset").removeAttribute("disabled"); + return; + } + + // If pref not already in list, refresh display as it's being added + const anyWhere = this._list.filter(i => { + return i.name == pref.name; + }); + if (!anyWhere.length) { + document.location.reload(); + } + }, + + // Quick context menu helpers for about:config + clipboardCopy: function AC_clipboardCopy(aField) { + const pref = this._getPrefForNode(this.contextMenuLINode); + if (aField == "name") { + gClipboardHelper.copyString(pref.name); + } else { + gClipboardHelper.copyString(pref.value); + } + }, +}; + +/* ============================== Pref ============================== + * + * Individual Preference object / methods + * + * Defines a Pref object, a document list item tied to Preferences Services + * And the methods by which they interact. + * + */ +function Pref(aName) { + this.name = aName; +} + +Pref.prototype = { + get type() { + return Services.prefs.getPrefType(this.name); + }, + + get value() { + switch (this.type) { + case Services.prefs.PREF_BOOL: + return Services.prefs.getBoolPref(this.name); + case Services.prefs.PREF_INT: + return Services.prefs.getIntPref(this.name); + case Services.prefs.PREF_STRING: + default: + return Services.prefs.getCharPref(this.name); + } + }, + set value(aPrefValue) { + switch (this.type) { + case Services.prefs.PREF_BOOL: + Services.prefs.setBoolPref(this.name, aPrefValue); + break; + case Services.prefs.PREF_INT: + Services.prefs.setIntPref(this.name, aPrefValue); + break; + case Services.prefs.PREF_STRING: + default: + Services.prefs.setCharPref(this.name, aPrefValue); + } + + // Ensure pref change flushed to disk immediately + Services.prefs.savePrefFile(null); + }, + + get default() { + return !Services.prefs.prefHasUserValue(this.name); + }, + + get locked() { + return Services.prefs.prefIsLocked(this.name); + }, + + reset: function AC_reset() { + Services.prefs.clearUserPref(this.name); + }, + + test: function AC_test(aValue) { + return aValue ? aValue.test(this.name) : true; + }, + + // Get existing or create new LI node for the pref + getOrCreateNewLINode: function AC_getOrCreateNewLINode() { + if (!this.li) { + this.li = document.createElement("li"); + + this.li.className = "pref-item"; + this.li.setAttribute("name", this.name); + + // Click callback to ensure list item selected even on no-action tap events + this.li.addEventListener("click", function (aEvent) { + AboutConfig.selected = AboutConfig.getLINodeForEvent(aEvent); + }); + + // Contextmenu callback to identify selected list item + this.li.addEventListener("contextmenu", function (aEvent) { + AboutConfig.contextMenuLINode = AboutConfig.getLINodeForEvent(aEvent); + }); + + this.li.setAttribute("contextmenu", "prefs-context-menu"); + + const prefName = document.createElement("div"); + prefName.className = "pref-name"; + prefName.addEventListener("click", function (event) { + AboutConfig.selectOrToggleBoolPref(event); + }); + prefName.textContent = this.name; + + this.li.appendChild(prefName); + + const prefItemLine = document.createElement("div"); + prefItemLine.className = "pref-item-line"; + + const prefValue = document.createElement("input"); + prefValue.className = "pref-value"; + prefValue.addEventListener("blur", function (event) { + AboutConfig.setIntOrStringPref(event); + }); + prefValue.addEventListener("click", function (event) { + AboutConfig.selectOrToggleBoolPref(event); + }); + prefValue.value = ""; + prefItemLine.appendChild(prefValue); + + const resetButton = document.createElement("div"); + resetButton.className = "pref-button reset"; + resetButton.addEventListener("click", function (event) { + AboutConfig.resetDefaultPref(event); + }); + resetButton.setAttribute("data-l10n-id", "config-pref-reset-button"); + prefItemLine.appendChild(resetButton); + + const toggleButton = document.createElement("div"); + toggleButton.className = "pref-button toggle"; + toggleButton.addEventListener("click", function (event) { + AboutConfig.toggleBoolPref(event); + }); + toggleButton.setAttribute("data-l10n-id", "config-pref-toggle-button"); + prefItemLine.appendChild(toggleButton); + + const upButton = document.createElement("div"); + upButton.className = "pref-button up"; + upButton.addEventListener("click", function (event) { + AboutConfig.incrOrDecrIntPref(event, 1); + }); + prefItemLine.appendChild(upButton); + + const downButton = document.createElement("div"); + downButton.className = "pref-button down"; + downButton.addEventListener("click", function (event) { + AboutConfig.incrOrDecrIntPref(event, -1); + }); + prefItemLine.appendChild(downButton); + + this.li.appendChild(prefItemLine); + + // Delay providing the list item values, until the LI is returned and added to the document + setTimeout(this._valueSetup.bind(this), INNERHTML_VALUE_DELAY); + } + + return this.li; + }, + + // Initialize list item object values + _valueSetup: function AC_valueSetup() { + this.li.setAttribute("type", this.type); + this.li.setAttribute("value", this.value); + + const valDiv = this.li.querySelector(".pref-value"); + valDiv.value = this.value; + + switch (this.type) { + case Services.prefs.PREF_BOOL: + valDiv.setAttribute("type", "button"); + this.li.querySelector(".up").setAttribute("disabled", true); + this.li.querySelector(".down").setAttribute("disabled", true); + break; + case Services.prefs.PREF_STRING: + valDiv.setAttribute("type", "text"); + this.li.querySelector(".up").setAttribute("disabled", true); + this.li.querySelector(".down").setAttribute("disabled", true); + this.li.querySelector(".toggle").setAttribute("disabled", true); + break; + case Services.prefs.PREF_INT: + valDiv.setAttribute("type", "number"); + this.li.querySelector(".toggle").setAttribute("disabled", true); + break; + } + + this.li.setAttribute("default", this.default); + if (this.default) { + this.li.querySelector(".reset").setAttribute("disabled", true); + } + + if (this.locked) { + valDiv.setAttribute("disabled", this.locked); + this.li.querySelector(".pref-name").setAttribute("locked", true); + } + }, +}; diff --git a/mobile/android/chrome/geckoview/config.xhtml b/mobile/android/chrome/geckoview/config.xhtml new file mode 100644 index 0000000000..ab760beea4 --- /dev/null +++ b/mobile/android/chrome/geckoview/config.xhtml @@ -0,0 +1,148 @@ +<?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 html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> + +<html xmlns="http://www.w3.org/1999/xhtml"> + <head> + <meta name="viewport" content="width=device-width; user-scalable=0" /> + <title>about:config</title> + <meta charset="UTF-8" /> + + <link rel="localization" href="mobile/android/aboutConfig.ftl" /> + <link + rel="stylesheet" + href="chrome://geckoview/skin/config.css" + type="text/css" + /> + <script + type="text/javascript" + src="chrome://geckoview/content/config.js" + ></script> + </head> + + <body + onload="NewPrefDialog.init(); AboutConfig.init();" + onunload="AboutConfig.uninit();" + > + <div class="toolbar"> + <div class="toolbar-container"> + <div + id="new-pref-toggle-button" + onclick="NewPrefDialog.toggleShowHide();" + /> + + <div class="toolbar-item" id="filter-container"> + <div id="filter-search-button" /> + <input + id="filter-input" + type="search" + data-l10n-id="config-toolbar-search" + value="" + oninput="AboutConfig.bufferFilterInput();" + /> + <div + id="filter-input-clear-button" + onclick="AboutConfig.clearFilterInput();" + /> + </div> + </div> + </div> + + <div id="content" ontouchstart="AboutConfig.filterInput.blur();"> + <div id="new-pref-container"> + <li class="pref-item" id="new-pref-item"> + <div class="pref-item-line"> + <input + class="pref-name" + id="new-pref-name" + type="text" + data-l10n-id="config-new-pref-name" + onfocus="NewPrefDialog.focusName(event);" + oninput="NewPrefDialog.updateName(event);" + /> + <select + id="new-pref-type" + onchange="NewPrefDialog.type = event.target.value;" + > + <option + value="boolean" + data-l10n-id="config-new-pref-value-boolean" + ></option> + <option + value="string" + data-l10n-id="config-new-pref-value-string" + ></option> + <option + value="int" + data-l10n-id="config-new-pref-value-integer" + ></option> + </select> + </div> + + <div class="pref-item-line" id="new-pref-line-boolean"> + <input + class="pref-value" + id="new-pref-value-boolean" + disabled="disabled" + /> + <div + class="pref-button toggle" + onclick="NewPrefDialog.toggleBoolValue();" + data-l10n-id="config-pref-toggle-button" + ></div> + </div> + + <div class="pref-item-line" id="new-pref-line-input"> + <input + class="pref-value" + id="new-pref-value-string" + data-l10n-id="config-new-pref-string" + /> + <input + class="pref-value" + id="new-pref-value-int" + data-l10n-id="config-new-pref-number" + type="number" + /> + </div> + + <div class="pref-item-line"> + <div + class="pref-button cancel" + id="negative-button" + onclick="NewPrefDialog.hide();" + data-l10n-id="config-new-pref-cancel-button" + ></div> + <div + class="pref-button create" + id="positive-button" + onclick="NewPrefDialog.create(event);" + data-l10n-id="config-new-pref-create-button" + ></div> + </div> + </li> + </div> + + <div id="prefs-shield"></div> + + <ul id="prefs-container" /> + + <div id="loading-container"></div> + </div> + + <menu type="context" id="prefs-context-menu"> + <menuitem + data-l10n-id="config-context-menu-copy-pref-name" + onclick="AboutConfig.clipboardCopy('name');" + ></menuitem> + <menuitem + data-l10n-id="config-context-menu-copy-pref-value" + onclick="AboutConfig.clipboardCopy('value');" + ></menuitem> + </menu> + </body> +</html> diff --git a/mobile/android/chrome/geckoview/geckoview.js b/mobile/android/chrome/geckoview/geckoview.js new file mode 100644 index 0000000000..6dc5ed4610 --- /dev/null +++ b/mobile/android/chrome/geckoview/geckoview.js @@ -0,0 +1,945 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +var { DelayedInit } = ChromeUtils.importESModule( + "resource://gre/modules/DelayedInit.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + Blocklist: "resource://gre/modules/Blocklist.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + GeckoViewActorManager: "resource://gre/modules/GeckoViewActorManager.sys.mjs", + GeckoViewSettings: "resource://gre/modules/GeckoViewSettings.sys.mjs", + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.sys.mjs", + InitializationTracker: "resource://gre/modules/GeckoViewTelemetry.sys.mjs", + RemoteSecuritySettings: + "resource://gre/modules/psm/RemoteSecuritySettings.sys.mjs", + SafeBrowsing: "resource://gre/modules/SafeBrowsing.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "WindowEventDispatcher", () => + EventDispatcher.for(window) +); + +XPCOMUtils.defineLazyScriptGetter( + this, + "PrintUtils", + "chrome://global/content/printUtils.js" +); + +// This file assumes `warn` and `debug` are imported into scope +// by the child scripts. +/* global debug, warn */ + +/** + * ModuleManager creates and manages GeckoView modules. Each GeckoView module + * normally consists of a JSM module file with an optional content module file. + * The module file contains a class that extends GeckoViewModule, and the + * content module file contains a class that extends GeckoViewChildModule. A + * module usually pairs with a particular GeckoSessionHandler or delegate on the + * Java side, and automatically receives module lifetime events such as + * initialization, change in enabled state, and change in settings. + */ +var ModuleManager = { + get _initData() { + return window.arguments[0].QueryInterface(Ci.nsIAndroidView).initData; + }, + + init(aBrowser, aModules) { + const initData = this._initData; + this._browser = aBrowser; + this._settings = initData.settings; + this._frozenSettings = Object.freeze(Object.assign({}, this._settings)); + + const self = this; + this._modules = new Map( + (function* () { + for (const module of aModules) { + yield [ + module.name, + new ModuleInfo({ + enabled: !!initData.modules[module.name], + manager: self, + ...module, + }), + ]; + } + })() + ); + + window.document.documentElement.appendChild(aBrowser); + + // By default all layers are discarded when a browser is set to inactive. + // GeckoView by default sets browsers to inactive every time they're not + // visible. To avoid flickering when changing tabs, we preserve layers for + // all loaded tabs. + aBrowser.preserveLayers(true); + // GeckoView browsers start off as active (for now at least). + // See bug 1815015 for an attempt at making them start off inactive. + aBrowser.docShellIsActive = true; + + WindowEventDispatcher.registerListener(this, [ + "GeckoView:UpdateModuleState", + "GeckoView:UpdateInitData", + "GeckoView:UpdateSettings", + ]); + + this.messageManager.addMessageListener( + "GeckoView:ContentModuleLoaded", + this + ); + + this._moduleByActorName = new Map(); + this.forEach(module => { + module.onInit(); + module.loadInitFrameScript(); + for (const actorName of module.actorNames) { + this._moduleByActorName[actorName] = module; + } + }); + + window.addEventListener("unload", () => { + this.forEach(module => { + module.enabled = false; + module.onDestroy(); + }); + + this._modules.clear(); + }); + }, + + onPrintWindow(aParams) { + if (!aParams.openWindowInfo.isForWindowDotPrint) { + return PrintUtils.handleStaticCloneCreatedForPrint( + aParams.openWindowInfo + ); + } + const printActor = this.window.moduleManager.getActor( + "GeckoViewPrintDelegate" + ); + // Prevents continually making new static browsers + if (printActor.browserStaticClone != null) { + throw new Error("A prior window.print is still in progress."); + } + const staticBrowser = PrintUtils.createParentBrowserForStaticClone( + aParams.openWindowInfo.parent, + aParams.openWindowInfo + ); + printActor.browserStaticClone = staticBrowser; + printActor.printRequest(); + return staticBrowser; + }, + + get window() { + return window; + }, + + get browser() { + return this._browser; + }, + + get messageManager() { + return this._browser.messageManager; + }, + + get eventDispatcher() { + return WindowEventDispatcher; + }, + + get settings() { + return this._frozenSettings; + }, + + forEach(aCallback) { + this._modules.forEach(aCallback, this); + }, + + getActor(aActorName) { + return this.browser.browsingContext.currentWindowGlobal?.getActor( + aActorName + ); + }, + + // Ensures that session history has been flushed before changing remoteness + async prepareToChangeRemoteness() { + // Session state like history is maintained at the process level so we need + // to collect it and restore it in the other process when switching. + // TODO: This should go away when we migrate the history to the main + // process Bug 1507287. + const { history } = await this.getActor("GeckoViewContent").collectState(); + + // Ignore scroll and form data since we're navigating away from this page + // anyway + this.sessionState = { history }; + }, + + willChangeBrowserRemoteness() { + debug`WillChangeBrowserRemoteness`; + + // Now we're switching the remoteness. + this.disabledModules = []; + this.forEach(module => { + if (module.enabled && module.disableOnProcessSwitch) { + module.enabled = false; + this.disabledModules.push(module); + } + }); + + this.forEach(module => { + module.onDestroyBrowser(); + }); + }, + + didChangeBrowserRemoteness() { + debug`DidChangeBrowserRemoteness`; + + this.forEach(module => { + if (module.impl) { + module.impl.onInitBrowser(); + } + }); + + this.messageManager.addMessageListener( + "GeckoView:ContentModuleLoaded", + this + ); + + this.forEach(module => { + // We're attaching a new browser so we have to reload the frame scripts + module.loadInitFrameScript(); + }); + + this.disabledModules.forEach(module => { + module.enabled = true; + }); + this.disabledModules = null; + }, + + afterBrowserRemotenessChange(aSwitchId) { + const { sessionState } = this; + this.sessionState = null; + + sessionState.switchId = aSwitchId; + + this.getActor("GeckoViewContent").restoreState(sessionState); + this.browser.focus(); + + // Load was handled + return true; + }, + + _updateSettings(aSettings) { + Object.assign(this._settings, aSettings); + this._frozenSettings = Object.freeze(Object.assign({}, this._settings)); + + const windowType = aSettings.isPopup + ? "navigator:popup" + : "navigator:geckoview"; + window.document.documentElement.setAttribute("windowtype", windowType); + + this.forEach(module => { + if (module.impl) { + module.impl.onSettingsUpdate(); + } + }); + }, + + onMessageFromActor(aActorName, aMessage) { + this._moduleByActorName[aActorName].receiveMessage(aMessage); + }, + + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + switch (aEvent) { + case "GeckoView:UpdateModuleState": { + const module = this._modules.get(aData.module); + if (module) { + module.enabled = aData.enabled; + } + break; + } + + case "GeckoView:UpdateInitData": { + // Replace all settings during a transfer. + const initData = this._initData; + this._updateSettings(initData.settings); + + // Update module enabled states. + for (const name in initData.modules) { + const module = this._modules.get(name); + if (module) { + module.enabled = initData.modules[name]; + } + } + + // Notify child of the transfer. + this._browser.messageManager.sendAsyncMessage(aEvent); + break; + } + + case "GeckoView:UpdateSettings": { + this._updateSettings(aData); + break; + } + } + }, + + receiveMessage(aMsg) { + debug`receiveMessage ${aMsg.name} ${aMsg.data}`; + switch (aMsg.name) { + case "GeckoView:ContentModuleLoaded": { + const module = this._modules.get(aMsg.data.module); + if (module) { + module.onContentModuleLoaded(); + } + break; + } + } + }, +}; + +/** + * ModuleInfo is the structure used by ModuleManager to represent individual + * modules. It is responsible for loading the module JSM file if necessary, + * and it acts as the intermediary between ModuleManager and the module + * object that extends GeckoViewModule. + */ +class ModuleInfo { + /** + * Create a ModuleInfo instance. See _loadPhase for phase object description. + * + * @param manager the ModuleManager instance. + * @param name Name of the module. + * @param enabled Enabled state of the module at startup. + * @param onInit Phase object for the init phase, when the window is created. + * @param onEnable Phase object for the enable phase, when the module is first + * enabled by setting a delegate in Java. + */ + constructor({ manager, name, enabled, onInit, onEnable }) { + this._manager = manager; + this._name = name; + + // We don't support having more than one main process script, so let's + // check that we're not accidentally defining two. We could support this if + // needed by making _impl an array for each phase impl. + if (onInit?.resource !== undefined && onEnable?.resource !== undefined) { + throw new Error( + "Only one main process script is allowed for each module." + ); + } + + this._impl = null; + this._contentModuleLoaded = false; + this._enabled = false; + // Only enable once we performed initialization. + this._enabledOnInit = enabled; + + // For init, load resource _before_ initializing browser to support the + // onInitBrowser() override. However, load content module after initializing + // browser, because we don't have a message manager before then. + this._loadResource(onInit); + this._loadActors(onInit); + if (this._enabledOnInit) { + this._loadActors(onEnable); + } + + this._onInitPhase = onInit; + this._onEnablePhase = onEnable; + + const actorNames = []; + if (this._onInitPhase?.actors) { + actorNames.push(Object.keys(this._onInitPhase.actors)); + } + if (this._onEnablePhase?.actors) { + actorNames.push(Object.keys(this._onEnablePhase.actors)); + } + this._actorNames = Object.freeze(actorNames); + } + + get actorNames() { + return this._actorNames; + } + + onInit() { + if (this._impl) { + this._impl.onInit(); + this._impl.onSettingsUpdate(); + } + + this.enabled = this._enabledOnInit; + } + + /** + * Loads the onInit frame script + */ + loadInitFrameScript() { + this._loadFrameScript(this._onInitPhase); + } + + onDestroy() { + if (this._impl) { + this._impl.onDestroy(); + } + } + + /** + * Called before the browser is removed + */ + onDestroyBrowser() { + if (this._impl) { + this._impl.onDestroyBrowser(); + } + this._contentModuleLoaded = false; + } + + _loadActors(aPhase) { + if (!aPhase || !aPhase.actors) { + return; + } + + GeckoViewActorManager.addJSWindowActors(aPhase.actors); + } + + /** + * Load resource according to a phase object that contains possible keys, + * + * "resource": specify the JSM resource to load for this module. + * "frameScript": specify a content JS frame script to load for this module. + */ + _loadResource(aPhase) { + if (!aPhase || !aPhase.resource || this._impl) { + return; + } + + const exports = ChromeUtils.importESModule(aPhase.resource); + this._impl = new exports[this._name](this); + } + + /** + * Load frameScript according to a phase object that contains possible keys, + * + * "frameScript": specify a content JS frame script to load for this module. + */ + _loadFrameScript(aPhase) { + if (!aPhase || !aPhase.frameScript || this._contentModuleLoaded) { + return; + } + + if (this._impl) { + this._impl.onLoadContentModule(); + } + this._manager.messageManager.loadFrameScript(aPhase.frameScript, true); + this._contentModuleLoaded = true; + } + + get manager() { + return this._manager; + } + + get disableOnProcessSwitch() { + // Only disable while process switching if it has a frameScript + return ( + !!this._onInitPhase?.frameScript || !!this._onEnablePhase?.frameScript + ); + } + + get name() { + return this._name; + } + + get impl() { + return this._impl; + } + + get enabled() { + return this._enabled; + } + + set enabled(aEnabled) { + if (aEnabled === this._enabled) { + return; + } + + if (!aEnabled && this._impl) { + this._impl.onDisable(); + } + + this._enabled = aEnabled; + + if (aEnabled) { + this._loadResource(this._onEnablePhase); + this._loadFrameScript(this._onEnablePhase); + this._loadActors(this._onEnablePhase); + if (this._impl) { + this._impl.onEnable(); + this._impl.onSettingsUpdate(); + } + } + + this._updateContentModuleState(); + } + + receiveMessage(aMessage) { + if (!this._impl) { + throw new Error(`No impl for message: ${aMessage.name}.`); + } + + try { + this._impl.receiveMessage(aMessage); + } catch (error) { + warn`this._impl.receiveMessage failed ${aMessage.name}`; + throw error; + } + } + + onContentModuleLoaded() { + this._updateContentModuleState(); + + if (this._impl) { + this._impl.onContentModuleLoaded(); + } + } + + _updateContentModuleState() { + this._manager.messageManager.sendAsyncMessage( + "GeckoView:UpdateModuleState", + { + module: this._name, + enabled: this.enabled, + } + ); + } +} + +function createBrowser() { + const browser = (window.browser = document.createXULElement("browser")); + // Identify this `<browser>` element uniquely to Marionette, devtools, etc. + // Use the JSM global to create the permanentKey, so that if the + // permanentKey is held by something after this window closes, it + // doesn't keep the window alive. See also Bug 1501789. + browser.permanentKey = new (Cu.getGlobalForObject(Services).Object)(); + + browser.setAttribute("nodefaultsrc", "true"); + browser.setAttribute("type", "content"); + browser.setAttribute("primary", "true"); + browser.setAttribute("flex", "1"); + browser.setAttribute("maychangeremoteness", "true"); + browser.setAttribute("remote", "true"); + browser.setAttribute("remoteType", E10SUtils.DEFAULT_REMOTE_TYPE); + browser.setAttribute("messagemanagergroup", "browsers"); + browser.setAttribute("manualactiveness", "true"); + + // This is only needed for mochitests, so that they honor the + // prefers-color-scheme.content-override pref. GeckoView doesn't set this + // pref to anything other than the default value otherwise. + browser.setAttribute( + "style", + "color-scheme: env(-moz-content-preferred-color-scheme)" + ); + + return browser; +} + +function InitLater(fn, object, name) { + return DelayedInit.schedule(fn, object, name, 15000 /* 15s max wait */); +} + +function startup() { + GeckoViewUtils.initLogging("XUL", window); + + const browser = createBrowser(); + ModuleManager.init(browser, [ + { + name: "GeckoViewContent", + onInit: { + resource: "resource://gre/modules/GeckoViewContent.sys.mjs", + actors: { + GeckoViewContent: { + parent: { + esModuleURI: "resource:///actors/GeckoViewContentParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/GeckoViewContentChild.sys.mjs", + events: { + mozcaretstatechanged: { capture: true, mozSystemGroup: true }, + pageshow: { mozSystemGroup: true }, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + }, + }, + onEnable: { + actors: { + ContentDelegate: { + parent: { + esModuleURI: "resource:///actors/ContentDelegateParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ContentDelegateChild.sys.mjs", + events: { + DOMContentLoaded: {}, + DOMMetaViewportFitChanged: {}, + "MozDOMFullscreen:Entered": {}, + "MozDOMFullscreen:Exit": {}, + "MozDOMFullscreen:Exited": {}, + "MozDOMFullscreen:Request": {}, + MozFirstContentfulPaint: {}, + MozPaintStatusReset: {}, + contextmenu: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewNavigation", + onInit: { + resource: "resource://gre/modules/GeckoViewNavigation.sys.mjs", + }, + }, + { + name: "GeckoViewProcessHangMonitor", + onInit: { + resource: "resource://gre/modules/GeckoViewProcessHangMonitor.sys.mjs", + }, + }, + { + name: "GeckoViewProgress", + onEnable: { + resource: "resource://gre/modules/GeckoViewProgress.sys.mjs", + actors: { + ProgressDelegate: { + parent: { + esModuleURI: "resource:///actors/ProgressDelegateParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ProgressDelegateChild.sys.mjs", + events: { + MozAfterPaint: { capture: false, mozSystemGroup: true }, + DOMContentLoaded: { capture: false, mozSystemGroup: true }, + pageshow: { capture: false, mozSystemGroup: true }, + }, + }, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewScroll", + onEnable: { + actors: { + ScrollDelegate: { + parent: { + esModuleURI: "resource:///actors/ScrollDelegateParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/ScrollDelegateChild.sys.mjs", + events: { + mozvisualscroll: { mozSystemGroup: true }, + }, + }, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewSelectionAction", + onEnable: { + resource: "resource://gre/modules/GeckoViewSelectionAction.sys.mjs", + actors: { + SelectionActionDelegate: { + parent: { + esModuleURI: + "resource:///actors/SelectionActionDelegateParent.sys.mjs", + }, + child: { + esModuleURI: + "resource:///actors/SelectionActionDelegateChild.sys.mjs", + events: { + mozcaretstatechanged: { mozSystemGroup: true }, + pagehide: { capture: true, mozSystemGroup: true }, + deactivate: { mozSystemGroup: true }, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewSettings", + onInit: { + resource: "resource://gre/modules/GeckoViewSettings.sys.mjs", + actors: { + GeckoViewSettings: { + child: { + esModuleURI: "resource:///actors/GeckoViewSettingsChild.sys.mjs", + }, + }, + }, + }, + }, + { + name: "GeckoViewTab", + onInit: { + resource: "resource://gre/modules/GeckoViewTab.sys.mjs", + }, + }, + { + name: "GeckoViewContentBlocking", + onInit: { + resource: "resource://gre/modules/GeckoViewContentBlocking.sys.mjs", + }, + }, + { + name: "SessionStateAggregator", + onInit: { + frameScript: "chrome://geckoview/content/SessionStateAggregator.js", + }, + }, + { + name: "GeckoViewAutofill", + onInit: { + actors: { + GeckoViewAutoFill: { + parent: { + esModuleURI: "resource:///actors/GeckoViewAutoFillParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/GeckoViewAutoFillChild.sys.mjs", + events: { + DOMFormHasPassword: { + mozSystemGroup: true, + capture: false, + }, + DOMInputPasswordAdded: { + mozSystemGroup: true, + capture: false, + }, + pagehide: { + mozSystemGroup: true, + capture: false, + }, + pageshow: { + mozSystemGroup: true, + capture: false, + }, + focusin: { + mozSystemGroup: true, + capture: false, + }, + focusout: { + mozSystemGroup: true, + capture: false, + }, + "PasswordManager:ShowDoorhanger": {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewMediaControl", + onEnable: { + resource: "resource://gre/modules/GeckoViewMediaControl.sys.mjs", + actors: { + MediaControlDelegate: { + parent: { + esModuleURI: + "resource:///actors/MediaControlDelegateParent.sys.mjs", + }, + child: { + esModuleURI: + "resource:///actors/MediaControlDelegateChild.sys.mjs", + events: { + "MozDOMFullscreen:Entered": {}, + "MozDOMFullscreen:Exited": {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewAutocomplete", + onInit: { + actors: { + FormAutofill: { + parent: { + esModuleURI: "resource://autofill/FormAutofillParent.sys.mjs", + }, + child: { + esModuleURI: "resource://autofill/FormAutofillChild.sys.mjs", + events: { + focusin: {}, + DOMFormBeforeSubmit: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + }, + }, + }, + { + name: "GeckoViewPrompter", + onInit: { + actors: { + GeckoViewPrompter: { + parent: { + esModuleURI: "resource:///actors/GeckoViewPrompterParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/GeckoViewPrompterChild.sys.mjs", + }, + allFrames: true, + includeChrome: true, + }, + }, + }, + }, + { + name: "GeckoViewPrintDelegate", + onInit: { + actors: { + GeckoViewPrintDelegate: { + parent: { + esModuleURI: + "resource:///actors/GeckoViewPrintDelegateParent.sys.mjs", + }, + child: { + esModuleURI: + "resource:///actors/GeckoViewPrintDelegateChild.sys.mjs", + }, + allFrames: true, + }, + }, + }, + }, + { + name: "GeckoViewExperimentDelegate", + onInit: { + actors: { + GeckoViewExperimentDelegate: { + parent: { + esModuleURI: + "resource:///actors/GeckoViewExperimentDelegateParent.sys.mjs", + }, + allFrames: true, + }, + }, + }, + }, + { + name: "GeckoViewTranslations", + onInit: { + resource: "resource://gre/modules/GeckoViewTranslations.sys.mjs", + }, + }, + ]); + + if (!Services.appinfo.sessionHistoryInParent) { + browser.prepareToChangeRemoteness = () => + ModuleManager.prepareToChangeRemoteness(); + browser.afterChangeRemoteness = switchId => + ModuleManager.afterBrowserRemotenessChange(switchId); + } + + browser.addEventListener("WillChangeBrowserRemoteness", event => + ModuleManager.willChangeBrowserRemoteness() + ); + + browser.addEventListener("DidChangeBrowserRemoteness", event => + ModuleManager.didChangeBrowserRemoteness() + ); + + // Allows actors to access ModuleManager. + window.moduleManager = ModuleManager; + + window.prompts = () => { + return window.ModuleManager.getActor("GeckoViewPrompter").getPrompts(); + }; + + Services.tm.dispatchToMainThread(() => { + // This should always be the first thing we do here - any additional delayed + // initialisation tasks should be added between "browser-delayed-startup-finished" + // and "browser-idle-startup-tasks-finished". + + // Bug 1496684: Various bits of platform stuff depend on this notification + // to learn when a browser window has finished its initial (chrome) + // initialisation, especially with regards to the very first window that is + // created. Therefore, GeckoView "windows" need to send this, too. + InitLater(() => + Services.obs.notifyObservers(window, "browser-delayed-startup-finished") + ); + + // Let the extension code know it can start loading things that were delayed + // while GeckoView started up. + InitLater(() => { + Services.obs.notifyObservers(window, "extensions-late-startup"); + }); + + InitLater(() => { + // TODO bug 1730026: this runs too often. It should run once. + RemoteSecuritySettings.init(); + }); + + InitLater(() => { + // Initialize safe browsing module. This is required for content + // blocking features and manages blocklist downloads and updates. + SafeBrowsing.init(); + }); + + InitLater(() => { + // It's enough to run this once to set up FOG. + // (See also bug 1730026.) + Services.fog.registerCustomPings(); + }); + + InitLater(() => { + // Initialize the blocklist module. + // TODO bug 1730026: this runs too often. It should run once. + Blocklist.loadBlocklistAsync(); + }); + + // This should always go last, since the idle tasks (except for the ones with + // timeouts) should execute in order. Note that this observer notification is + // not guaranteed to fire, since the window could close before we get here. + + // This notification in particular signals the ScriptPreloader that we have + // finished startup, so it can now stop recording script usage and start + // updating the startup cache for faster script loading. + InitLater(() => + Services.obs.notifyObservers( + window, + "browser-idle-startup-tasks-finished" + ) + ); + }); + + // Move focus to the content window at the end of startup, + // so things like text selection can work properly. + browser.focus(); + + InitializationTracker.onInitialized(performance.now()); +} diff --git a/mobile/android/chrome/geckoview/geckoview.xhtml b/mobile/android/chrome/geckoview/geckoview.xhtml new file mode 100644 index 0000000000..6f328dcc18 --- /dev/null +++ b/mobile/android/chrome/geckoview/geckoview.xhtml @@ -0,0 +1,19 @@ +<?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/. --> + +<window + id="main-window" + windowtype="navigator:geckoview" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" +> + <script + type="application/javascript" + src="chrome://geckoview/content/geckoview.js" + /> + <script> + /* import-globals-from geckoview.js */ + window.addEventListener("DOMContentLoaded", startup, { once: true }); + </script> +</window> diff --git a/mobile/android/chrome/geckoview/jar.mn b/mobile/android/chrome/geckoview/jar.mn new file mode 100644 index 0000000000..c2905f6fc4 --- /dev/null +++ b/mobile/android/chrome/geckoview/jar.mn @@ -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/. + +geckoview.jar: +% content geckoview %content/ + + content/config.xhtml + content/config.js + content/geckoview.xhtml + content/geckoview.js + content/SessionStateAggregator.js + +% content branding %content/branding/ diff --git a/mobile/android/chrome/geckoview/moz.build b/mobile/android/chrome/geckoview/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/chrome/geckoview/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/chrome/moz.build b/mobile/android/chrome/moz.build new file mode 100644 index 0000000000..bb28efc488 --- /dev/null +++ b/mobile/android/chrome/moz.build @@ -0,0 +1,17 @@ +# -*- 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/. + +# NOTE: I think there are a few other possible components in this directory +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +DIRS += ["geckoview"] + +DEFINES["AB_CD"] = CONFIG["MOZ_UI_LOCALE"] +DEFINES["PACKAGE"] = "browser" +DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"] +DEFINES["MOZ_APP_VERSION_DISPLAY"] = CONFIG["MOZ_APP_VERSION_DISPLAY"] +DEFINES["ANDROID_PACKAGE_NAME"] = CONFIG["ANDROID_PACKAGE_NAME"] diff --git a/mobile/android/components/extensions/.eslintrc.js b/mobile/android/components/extensions/.eslintrc.js new file mode 100644 index 0000000000..7726338490 --- /dev/null +++ b/mobile/android/components/extensions/.eslintrc.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +module.exports = { + extends: "../../../../toolkit/components/extensions/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/ExtensionBrowsingData.sys.mjs b/mobile/android/components/extensions/ExtensionBrowsingData.sys.mjs new file mode 100644 index 0000000000..fb4155f897 --- /dev/null +++ b/mobile/android/components/extensions/ExtensionBrowsingData.sys.mjs @@ -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/. */ + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { ExtensionError } = ExtensionUtils; + +export class BrowsingDataDelegate { + constructor(extension) { + this.extension = extension; + } + + async sendRequestForResult(type, data) { + try { + const result = await lazy.EventDispatcher.instance.sendRequestForResult({ + type, + extensionId: this.extension.id, + ...data, + }); + return result; + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + } + + async settings() { + return this.sendRequestForResult("GeckoView:BrowsingData:GetSettings"); + } + + async sendClear(dataType, options) { + const { since } = options; + return this.sendRequestForResult("GeckoView:BrowsingData:Clear", { + dataType, + since, + }); + } + + // This method returns undefined for all data types that are _not_ handled by + // this delegate. + handleRemoval(dataType, options) { + switch (dataType) { + case "downloads": + case "formData": + case "history": + case "passwords": + return this.sendClear(dataType, options); + + default: + return undefined; + } + } +} diff --git a/mobile/android/components/extensions/ext-android.js b/mobile/android/components/extensions/ext-android.js new file mode 100644 index 0000000000..b53c390f36 --- /dev/null +++ b/mobile/android/components/extensions/ext-android.js @@ -0,0 +1,641 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +/** + * NOTE: If you change the globals in this file, you must check if the globals + * list in mobile/android/.eslintrc.js also needs updating. + */ + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +var { EventDispatcher } = ChromeUtils.importESModule( + "resource://gre/modules/Messaging.sys.mjs" +); + +var { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); +var { ExtensionUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionUtils.sys.mjs" +); + +var { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +var { defineLazyGetter } = ExtensionCommon; + +const BrowserStatusFilter = Components.Constructor( + "@mozilla.org/appshell/component/browser-status-filter;1", + "nsIWebProgress", + "addProgressListener" +); + +const WINDOW_TYPE = "navigator:geckoview"; + +// We need let to break cyclic dependency +/* eslint-disable-next-line prefer-const */ +let windowTracker; + +/** + * A nsIWebProgressListener for a specific XUL browser, which delegates the + * events that it receives to a tab progress listener, and prepends the browser + * to their arguments list. + * + * @param {XULElement} browser + * A XUL browser element. + * @param {object} listener + * A tab progress listener object. + * @param {integer} flags + * The web progress notification flags with which to filter events. + */ +class BrowserProgressListener { + constructor(browser, listener, flags) { + this.listener = listener; + this.browser = browser; + this.filter = new BrowserStatusFilter(this, flags); + this.browser.addProgressListener(this.filter, flags); + } + + /** + * Destroy the listener, and perform any necessary cleanup. + */ + destroy() { + this.browser.removeProgressListener(this.filter); + this.filter.removeProgressListener(this); + } + + /** + * Calls the appropriate listener in the wrapped tab progress listener, with + * the wrapped XUL browser object as its first argument, and the additional + * arguments in `args`. + * + * @param {string} method + * The name of the nsIWebProgressListener method which is being + * delegated. + * @param {*} args + * The arguments to pass to the delegated listener. + * @private + */ + delegate(method, ...args) { + if (this.listener[method]) { + this.listener[method](this.browser, ...args); + } + } + + onLocationChange(webProgress, request, locationURI, flags) { + const window = this.browser.ownerGlobal; + // GeckoView windows can become popups at any moment, so we need to check + // here + if (!windowTracker.isBrowserWindow(window)) { + return; + } + + this.delegate("onLocationChange", webProgress, request, locationURI, flags); + } + onStateChange(webProgress, request, stateFlags, status) { + this.delegate("onStateChange", webProgress, request, stateFlags, status); + } +} + +const PROGRESS_LISTENER_FLAGS = + Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION; + +class ProgressListenerWrapper { + constructor(window, listener) { + this.listener = new BrowserProgressListener( + window.browser, + listener, + PROGRESS_LISTENER_FLAGS + ); + } + + destroy() { + this.listener.destroy(); + } +} + +class WindowTracker extends WindowTrackerBase { + constructor(...args) { + super(...args); + + this.progressListeners = new DefaultWeakMap(() => new WeakMap()); + } + + getCurrentWindow(context) { + // In GeckoView the popup is on a separate window so getCurrentWindow for + // the popup should return whatever is the topWindow. + // TODO: Bug 1651506 use context?.viewType === "popup" instead + if (context?.currentWindow?.moduleManager.settings.isPopup) { + return this.topWindow; + } + return super.getCurrentWindow(context); + } + + get topWindow() { + return mobileWindowTracker.topWindow; + } + + get topNonPBWindow() { + return mobileWindowTracker.topNonPBWindow; + } + + isBrowserWindow(window) { + const { documentElement } = window.document; + return documentElement.getAttribute("windowtype") === WINDOW_TYPE; + } + + addProgressListener(window, listener) { + const listeners = this.progressListeners.get(window); + if (!listeners.has(listener)) { + const wrapper = new ProgressListenerWrapper(window, listener); + listeners.set(listener, wrapper); + } + } + + removeProgressListener(window, listener) { + const listeners = this.progressListeners.get(window); + const wrapper = listeners.get(listener); + if (wrapper) { + wrapper.destroy(); + listeners.delete(listener); + } + } +} + +/** + * Helper to create an event manager which listens for an event in the Android + * global EventDispatcher, and calls the given listener function whenever the + * event is received. That listener function receives a `fire` object, + * which it can use to dispatch events to the extension, and an object + * detailing the EventDispatcher event that was received. + * + * @param {BaseContext} context + * The extension context which the event manager belongs to. + * @param {string} name + * The API name of the event manager, e.g.,"runtime.onMessage". + * @param {string} event + * The name of the EventDispatcher event to listen for. + * @param {Function} listener + * The listener function to call when an EventDispatcher event is + * recieved. + * + * @returns {object} An injectable api for the new event. + */ +global.makeGlobalEvent = function makeGlobalEvent( + context, + name, + event, + listener +) { + return new EventManager({ + context, + name, + register: fire => { + const listener2 = { + onEvent(event, data, callback) { + listener(fire, data); + }, + }; + + EventDispatcher.instance.registerListener(listener2, [event]); + return () => { + EventDispatcher.instance.unregisterListener(listener2, [event]); + }; + }, + }).api(); +}; + +class TabTracker extends TabTrackerBase { + init() { + if (this.initialized) { + return; + } + this.initialized = true; + + windowTracker.addOpenListener(window => { + const nativeTab = window.tab; + this.emit("tab-created", { nativeTab }); + }); + + windowTracker.addCloseListener(window => { + const { tab: nativeTab, browser } = window; + const { windowId, tabId } = this.getBrowserData(browser); + this.emit("tab-removed", { + nativeTab, + tabId, + windowId, + // In GeckoView, it is not meaningful to speak of "window closed", because a tab is a window. + // Until we have a meaningful way to group tabs (and close multiple tabs at once), + // let's use isWindowClosing: false + isWindowClosing: false, + }); + }); + } + + getId(nativeTab) { + return nativeTab.id; + } + + getTab(id, default_ = undefined) { + const windowId = GeckoViewTabBridge.tabIdToWindowId(id); + const window = windowTracker.getWindow(windowId, null, false); + + if (window) { + const { tab } = window; + if (tab) { + return tab; + } + } + + if (default_ !== undefined) { + return default_; + } + throw new ExtensionError(`Invalid tab ID: ${id}`); + } + + getBrowserData(browser) { + const window = browser.ownerGlobal; + const tab = window?.tab; + if (!tab) { + return { + tabId: -1, + windowId: -1, + }; + } + + const windowId = windowTracker.getId(window); + + if (!windowTracker.isBrowserWindow(window)) { + return { + windowId, + tabId: -1, + }; + } + + return { + windowId, + tabId: this.getId(tab), + }; + } + + get activeTab() { + const window = windowTracker.topWindow; + if (window) { + return window.tab; + } + return null; + } +} + +windowTracker = new WindowTracker(); +const tabTracker = new TabTracker(); + +Object.assign(global, { tabTracker, windowTracker }); + +class Tab extends TabBase { + get _favIconUrl() { + return undefined; + } + + get attention() { + return false; + } + + get audible() { + return this.nativeTab.playingAudio; + } + + get browser() { + return this.nativeTab.browser; + } + + get discarded() { + return this.browser.getAttribute("pending") === "true"; + } + + get cookieStoreId() { + return getCookieStoreIdForTab(this, this.nativeTab); + } + + get height() { + return this.browser.clientHeight; + } + + get incognito() { + return PrivateBrowsingUtils.isBrowserPrivate(this.browser); + } + + get index() { + return 0; + } + + get mutedInfo() { + return { muted: false }; + } + + get lastAccessed() { + return this.nativeTab.lastTouchedAt; + } + + get pinned() { + return false; + } + + get active() { + return this.nativeTab.getActive(); + } + + get highlighted() { + return this.active; + } + + get status() { + if (this.browser.webProgress.isLoadingDocument) { + return "loading"; + } + return "complete"; + } + + get successorTabId() { + return -1; + } + + get width() { + return this.browser.clientWidth; + } + + get window() { + return this.browser.ownerGlobal; + } + + get windowId() { + return windowTracker.getId(this.window); + } + + // TODO: Just return false for these until properly implemented on Android. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1402924 + get isArticle() { + return false; + } + + get isInReaderMode() { + return false; + } + + get hidden() { + return false; + } + + get autoDiscardable() { + // This property reflects whether the browser is allowed to auto-discard. + // Since extensions cannot do so on Android, we return true here. + return true; + } + + get sharingState() { + return { + screen: undefined, + microphone: false, + camera: false, + }; + } +} + +// Manages tab-specific context data and dispatches tab select and close events. +class TabContext extends EventEmitter { + constructor(getDefaultPrototype) { + super(); + + windowTracker.addListener("progress", this); + + this.getDefaultPrototype = getDefaultPrototype; + this.tabData = new Map(); + } + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (!webProgress.isTopLevel) { + // Only pageAction and browserAction are consuming the "location-change" event + // to update their per-tab status, and they should only do so in response of + // location changes related to the top level frame (See Bug 1493470 for a rationale). + return; + } + const { tab } = browser.ownerGlobal; + // fromBrowse will be false in case of e.g. a hash change or history.pushState + const fromBrowse = !( + flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT + ); + this.emit( + "location-change", + { + id: tab.id, + linkedBrowser: browser, + // TODO: we don't support selected so we just alway say we are + selected: true, + }, + fromBrowse + ); + } + + get(tabId) { + if (!this.tabData.has(tabId)) { + const data = Object.create(this.getDefaultPrototype(tabId)); + this.tabData.set(tabId, data); + } + + return this.tabData.get(tabId); + } + + clear(tabId) { + this.tabData.delete(tabId); + } + + shutdown() { + windowTracker.removeListener("progress", this); + } +} + +class Window extends WindowBase { + get focused() { + return this.window.document.hasFocus(); + } + + isCurrentFor(context) { + // In GeckoView the popup is on a separate window so the current window for + // the popup is whatever is the topWindow. + // TODO: Bug 1651506 use context?.viewType === "popup" instead + if (context?.currentWindow?.moduleManager.settings.isPopup) { + return mobileWindowTracker.topWindow == this.window; + } + return super.isCurrentFor(context); + } + + get top() { + return this.window.screenY; + } + + get left() { + return this.window.screenX; + } + + get width() { + return this.window.outerWidth; + } + + get height() { + return this.window.outerHeight; + } + + get incognito() { + return PrivateBrowsingUtils.isWindowPrivate(this.window); + } + + get alwaysOnTop() { + return false; + } + + get isLastFocused() { + return this.window === windowTracker.topWindow; + } + + get state() { + return "fullscreen"; + } + + *getTabs() { + yield this.activeTab; + } + + *getHighlightedTabs() { + yield this.activeTab; + } + + get activeTab() { + const { tabManager } = this.extension; + return tabManager.getWrapper(this.window.tab); + } + + getTabAtIndex(index) { + if (index == 0) { + return this.activeTab; + } + } +} + +Object.assign(global, { Tab, TabContext, Window }); + +class TabManager extends TabManagerBase { + get(tabId, default_ = undefined) { + const nativeTab = tabTracker.getTab(tabId, default_); + + if (nativeTab) { + return this.getWrapper(nativeTab); + } + return default_; + } + + addActiveTabPermission(nativeTab = tabTracker.activeTab) { + return super.addActiveTabPermission(nativeTab); + } + + revokeActiveTabPermission(nativeTab = tabTracker.activeTab) { + return super.revokeActiveTabPermission(nativeTab); + } + + canAccessTab(nativeTab) { + return ( + this.extension.privateBrowsingAllowed || + !PrivateBrowsingUtils.isBrowserPrivate(nativeTab.browser) + ); + } + + wrapTab(nativeTab) { + return new Tab(this.extension, nativeTab, nativeTab.id); + } +} + +class WindowManager extends WindowManagerBase { + get(windowId, context) { + const window = windowTracker.getWindow(windowId, context); + + return this.getWrapper(window); + } + + *getAll(context) { + for (const window of windowTracker.browserWindows()) { + if (!this.canAccessWindow(window, context)) { + continue; + } + const wrapped = this.getWrapper(window); + if (wrapped) { + yield wrapped; + } + } + } + + wrapWindow(window) { + return new Window(this.extension, window, windowTracker.getId(window)); + } +} + +// eslint-disable-next-line mozilla/balanced-listeners +extensions.on("startup", (type, extension) => { + defineLazyGetter(extension, "tabManager", () => new TabManager(extension)); + defineLazyGetter( + extension, + "windowManager", + () => new WindowManager(extension) + ); +}); + +/* eslint-disable mozilla/balanced-listeners */ +extensions.on("page-shutdown", (type, context) => { + if (context.viewType == "tab") { + const window = context.xulBrowser.ownerGlobal; + if (!windowTracker.isBrowserWindow(window)) { + // Content in non-browser window, e.g. ContentPage in xpcshell uses + // chrome://extensions/content/dummy.xhtml as the window. + return; + } + GeckoViewTabBridge.closeTab({ + window, + extensionId: context.extension.id, + }); + } +}); +/* eslint-enable mozilla/balanced-listeners */ + +global.openOptionsPage = async extension => { + const { options_ui } = extension.manifest; + const extensionId = extension.id; + + if (options_ui.open_in_tab) { + // Delegate new tab creation and open the options page in the new tab. + const tab = await GeckoViewTabBridge.createNewTab({ + extensionId, + createProperties: { + url: options_ui.page, + active: true, + }, + }); + + const { browser } = tab; + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + + browser.fixupAndLoadURIString(options_ui.page, { + flags, + triggeringPrincipal: extension.principal, + }); + + const newWindow = browser.ownerGlobal; + mobileWindowTracker.setTabActive(newWindow, true); + return; + } + + // Delegate option page handling to the app. + return GeckoViewTabBridge.openOptionsPage(extensionId); +}; diff --git a/mobile/android/components/extensions/ext-android.json b/mobile/android/components/extensions/ext-android.json new file mode 100644 index 0000000000..987dcc14b3 --- /dev/null +++ b/mobile/android/components/extensions/ext-android.json @@ -0,0 +1,31 @@ +{ + "browserAction": { + "url": "chrome://geckoview/content/ext-browserAction.js", + "schema": "chrome://extensions/content/schemas/browser_action.json", + "scopes": ["addon_parent"], + "manifest": ["browser_action", "action"], + "paths": [["browserAction"], ["action"]] + }, + "browsingData": { + "url": "chrome://extensions/content/parent/ext-browsingData.js", + "schema": "chrome://extensions/content/schemas/browsing_data.json", + "scopes": ["addon_parent"], + "paths": [["browsingData"]] + }, + "pageAction": { + "url": "chrome://geckoview/content/ext-pageAction.js", + "schema": "chrome://extensions/content/schemas/page_action.json", + "scopes": ["addon_parent"], + "manifest": ["page_action"], + "paths": [["pageAction"]] + }, + "tabs": { + "url": "chrome://geckoview/content/ext-tabs.js", + "schema": "chrome://geckoview/content/schemas/tabs.json", + "scopes": ["addon_parent"], + "paths": [["tabs"]] + }, + "geckoViewAddons": { + "schema": "chrome://geckoview/content/schemas/gecko_view_addons.json" + } +} diff --git a/mobile/android/components/extensions/ext-browserAction.js b/mobile/android/components/extensions/ext-browserAction.js new file mode 100644 index 0000000000..3a91e913f9 --- /dev/null +++ b/mobile/android/components/extensions/ext-browserAction.js @@ -0,0 +1,197 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const { BrowserActionBase } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionActions.sys.mjs" +); + +const BROWSER_ACTION_PROPERTIES = [ + "title", + "icon", + "popup", + "badgeText", + "badgeBackgroundColor", + "badgeTextColor", + "enabled", + "patternMatching", +]; + +class BrowserAction extends BrowserActionBase { + constructor(extension, clickDelegate) { + const tabContext = new TabContext(tabId => this.getContextData(null)); + super(tabContext, extension); + this.clickDelegate = clickDelegate; + this.helper = new ExtensionActionHelper({ + extension, + tabTracker, + windowTracker, + tabContext, + properties: BROWSER_ACTION_PROPERTIES, + }); + } + + updateOnChange(tab) { + const tabId = tab ? tab.id : null; + const action = tab + ? this.getContextData(tab) + : this.helper.extractProperties(this.globals); + this.helper.sendRequest(tabId, { + action, + type: "GeckoView:BrowserAction:Update", + }); + } + + openPopup(tab, openPopupWithoutUserInteraction = false) { + const popupUri = openPopupWithoutUserInteraction + ? this.getPopupUrl(tab) + : this.triggerClickOrPopup(tab); + const actionObject = this.getContextData(tab); + const action = this.helper.extractProperties(actionObject); + this.helper.sendRequest(tab.id, { + action, + type: "GeckoView:BrowserAction:OpenPopup", + popupUri, + }); + } + + triggerClickOrPopup(tab = tabTracker.activeTab) { + return super.triggerClickOrPopup(tab); + } + + getTab(tabId) { + return this.helper.getTab(tabId); + } + + getWindow(windowId) { + return this.helper.getWindow(windowId); + } + + dispatchClick() { + this.clickDelegate.onClick(); + } +} + +this.browserAction = class extends ExtensionAPIPersistent { + static for(extension) { + return GeckoViewWebExtension.browserActions.get(extension); + } + + async onManifestEntry(entryName) { + const { extension } = this; + this.action = new BrowserAction(extension, this); + await this.action.loadIconData(); + + GeckoViewWebExtension.browserActions.set(extension, this.action); + + // Notify the embedder of this action + this.action.updateOnChange(null); + } + + onShutdown() { + const { extension } = this; + this.action.onShutdown(); + GeckoViewWebExtension.browserActions.delete(extension); + } + + onClick() { + this.emit("click", tabTracker.activeTab); + } + + PERSISTENT_EVENTS = { + onClicked({ fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener(_event, tab) { + if (fire.wakeup) { + await fire.wakeup(); + } + // TODO: we should double-check if the tab is already being closed by the time + // the background script got started and we converted the primed listener. + fire.sync(tabManager.convert(tab)); + } + this.on("click", listener); + return { + unregister: () => { + this.off("click", listener); + }, + convert(newFire) { + fire = newFire; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + const { action } = this; + const namespace = + extension.manifestVersion < 3 ? "browserAction" : "action"; + + return { + [namespace]: { + ...action.api(context), + + onClicked: new EventManager({ + context, + // module name is "browserAction" because it the name used in the + // ext-android.json, independently from the manifest version. + module: "browserAction", + event: "onClicked", + inputHandling: true, + extensionApi: this, + }).api(), + + getUserSettings: () => { + return { + // isOnToolbar is not supported on Android. + // We intentionally omit the property, in case + // extensions would like to feature-detect support + // for this feature. + }; + }, + openPopup: options => { + const isHandlingUserInput = + context.callContextData?.isHandlingUserInput; + + if ( + !Services.prefs.getBoolPref( + "extensions.openPopupWithoutUserGesture.enabled" + ) && + !isHandlingUserInput + ) { + throw new ExtensionError("openPopup requires a user gesture"); + } + + const currentWindow = windowTracker.getCurrentWindow(context); + + const window = + typeof options?.windowId === "number" + ? windowTracker.getWindow(options.windowId, context) + : currentWindow; + + if (window !== currentWindow) { + throw new ExtensionError( + "Only the current window is supported on Android." + ); + } + + if (this.action.getPopupUrl(window.tab, true)) { + action.openPopup(window.tab, !isHandlingUserInput); + } + }, + }, + }; + } +}; + +global.browserActionFor = this.browserAction.for; diff --git a/mobile/android/components/extensions/ext-c-android.js b/mobile/android/components/extensions/ext-c-android.js new file mode 100644 index 0000000000..3f2392a9c2 --- /dev/null +++ b/mobile/android/components/extensions/ext-c-android.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +extensions.registerModules({ + tabs: { + url: "chrome://geckoview/content/ext-c-tabs.js", + scopes: ["addon_child"], + paths: [["tabs"]], + }, +}); diff --git a/mobile/android/components/extensions/ext-c-tabs.js b/mobile/android/components/extensions/ext-c-tabs.js new file mode 100644 index 0000000000..cad1e29051 --- /dev/null +++ b/mobile/android/components/extensions/ext-c-tabs.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +this.tabs = class extends ExtensionAPI { + getAPI(context) { + return { + tabs: { + connect(tabId, options) { + const { frameId = null, name = "" } = options || {}; + return context.messenger.connect({ name, tabId, frameId }); + }, + + sendMessage(tabId, message, options, callback) { + const arg = { tabId, frameId: options?.frameId, message, callback }; + return context.messenger.sendRuntimeMessage(arg); + }, + }, + }; + } +}; diff --git a/mobile/android/components/extensions/ext-downloads.js b/mobile/android/components/extensions/ext-downloads.js new file mode 100644 index 0000000000..6422440d54 --- /dev/null +++ b/mobile/android/components/extensions/ext-downloads.js @@ -0,0 +1,309 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +ChromeUtils.defineESModuleGetters(this, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", + DownloadTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +Cu.importGlobalProperties(["PathUtils"]); + +var { ignoreEvent } = ExtensionCommon; + +const REQUEST_DOWNLOAD_MESSAGE = "GeckoView:WebExtension:Download"; + +const FORBIDDEN_HEADERS = [ + "ACCEPT-CHARSET", + "ACCEPT-ENCODING", + "ACCESS-CONTROL-REQUEST-HEADERS", + "ACCESS-CONTROL-REQUEST-METHOD", + "CONNECTION", + "CONTENT-LENGTH", + "COOKIE", + "COOKIE2", + "DATE", + "DNT", + "EXPECT", + "HOST", + "KEEP-ALIVE", + "ORIGIN", + "TE", + "TRAILER", + "TRANSFER-ENCODING", + "UPGRADE", + "VIA", +]; + +const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; + +const State = { + IN_PROGRESS: "in_progress", + INTERRUPTED: "interrupted", + COMPLETE: "complete", +}; + +const STATE_MAP = new Map([ + [0, State.IN_PROGRESS], + [1, State.INTERRUPTED], + [2, State.COMPLETE], +]); + +const INTERRUPT_REASON_MAP = new Map([ + [0, undefined], + [1, "FILE_FAILED"], + [2, "FILE_ACCESS_DENIED"], + [3, "FILE_NO_SPACE"], + [4, "FILE_NAME_TOO_LONG"], + [5, "FILE_TOO_LARGE"], + [6, "FILE_VIRUS_INFECTED"], + [7, "FILE_TRANSIENT_ERROR"], + [8, "FILE_BLOCKED"], + [9, "FILE_SECURITY_CHECK_FAILED"], + [10, "FILE_TOO_SHORT"], + [11, "NETWORK_FAILED"], + [12, "NETWORK_TIMEOUT"], + [13, "NETWORK_DISCONNECTED"], + [14, "NETWORK_SERVER_DOWN"], + [15, "NETWORK_INVALID_REQUEST"], + [16, "SERVER_FAILED"], + [17, "SERVER_NO_RANGE"], + [18, "SERVER_BAD_CONTENT"], + [19, "SERVER_UNAUTHORIZED"], + [20, "SERVER_CERT_PROBLEM"], + [21, "SERVER_FORBIDDEN"], + [22, "USER_CANCELED"], + [23, "USER_SHUTDOWN"], + [24, "CRASH"], +]); + +// TODO Bug 1247794: make id and extension info persistent +class DownloadItem { + /** + * Initializes an object that represents a download + * + * @param {object} downloadInfo - an object from Java when creating a download + * @param {object} options - an object passed in to download() function + * @param {Extension} extension - instance of an extension object + */ + constructor(downloadInfo, options, extension) { + this.id = downloadInfo.id; + this.url = options.url; + this.referrer = downloadInfo.referrer || ""; + this.filename = downloadInfo.filename || ""; + this.incognito = options.incognito; + this.danger = "safe"; // todo; not implemented in desktop either + this.mime = downloadInfo.mime || ""; + this.startTime = downloadInfo.startTime; + this.state = STATE_MAP.get(downloadInfo.state); + this.paused = downloadInfo.paused; + this.canResume = downloadInfo.canResume; + this.bytesReceived = downloadInfo.bytesReceived; + this.totalBytes = downloadInfo.totalBytes; + this.fileSize = downloadInfo.fileSize; + this.exists = downloadInfo.exists; + this.byExtensionId = extension?.id; + this.byExtensionName = extension?.name; + } + + /** + * This function updates the download item it was called on. + * + * @param {object} data that arrived from the app (Java) + * @returns {object | null} an object of <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged#downloaddelta>downloadDelta type</a> + */ + update(data) { + const { downloadItemId } = data; + const delta = {}; + + data.state = STATE_MAP.get(data.state); + data.error = INTERRUPT_REASON_MAP.get(data.error); + delete data.downloadItemId; + + let changed = false; + for (const prop in data) { + const current = data[prop] ?? null; + const previous = this[prop] ?? null; + if (current !== previous) { + delta[prop] = { current, previous }; + this[prop] = current; + changed = true; + } + } + + // Don't send empty onChange events + if (!changed) { + return null; + } + + delta.id = downloadItemId; + + return delta; + } +} + +this.downloads = class extends ExtensionAPIPersistent { + PERSISTENT_EVENTS = { + onChanged({ fire }, params) { + const listener = (eventName, event) => { + const { delta, downloadItem } = event; + const { extension } = this; + if (extension.privateBrowsingAllowed || !downloadItem.incognito) { + fire.async(delta); + } + }; + DownloadTracker.on("download-changed", listener); + + return { + unregister() { + DownloadTracker.off("download-changed", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + return { + downloads: { + download(options) { + // the validation checks should be kept in sync with the toolkit implementation + const { filename } = options; + if (filename != null) { + if (!filename.length) { + return Promise.reject({ message: "filename must not be empty" }); + } + + if (PathUtils.isAbsolute(filename)) { + return Promise.reject({ + message: "filename must not be an absolute path", + }); + } + + const pathComponents = PathUtils.splitRelative(filename, { + allowEmpty: true, + allowCurrentDir: true, + allowParentDir: true, + }); + + if (pathComponents.some(component => component == "..")) { + return Promise.reject({ + message: "filename must not contain back-references (..)", + }); + } + + if ( + pathComponents.some(component => { + const sanitized = DownloadPaths.sanitize(component, { + compressWhitespaces: false, + }); + return component != sanitized; + }) + ) { + return Promise.reject({ + message: "filename must not contain illegal characters", + }); + } + } + + if (options.incognito && !context.privateBrowsingAllowed) { + return Promise.reject({ + message: "Private browsing access not allowed", + }); + } + + if (options.cookieStoreId != null) { + // https://bugzilla.mozilla.org/show_bug.cgi?id=1721460 + throw new ExtensionError("Not implemented"); + } + + if (options.headers) { + for (const { name } of options.headers) { + if ( + FORBIDDEN_HEADERS.includes(name.toUpperCase()) || + name.match(FORBIDDEN_PREFIXES) + ) { + return Promise.reject({ + message: "Forbidden request header name", + }); + } + } + } + + return EventDispatcher.instance + .sendRequestForResult({ + type: REQUEST_DOWNLOAD_MESSAGE, + options, + extensionId: extension.id, + }) + .then(value => { + const downloadItem = new DownloadItem(value, options, extension); + DownloadTracker.addDownloadItem(downloadItem); + return downloadItem.id; + }); + }, + + removeFile(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + search(query) { + throw new ExtensionError("Not implemented"); + }, + + pause(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + resume(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + cancel(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + showDefaultFolder() { + throw new ExtensionError("Not implemented"); + }, + + erase(query) { + throw new ExtensionError("Not implemented"); + }, + + open(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + show(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + getFileIcon(downloadId, options) { + throw new ExtensionError("Not implemented"); + }, + + onChanged: new EventManager({ + context, + module: "downloads", + event: "onChanged", + extensionApi: this, + }).api(), + + onCreated: ignoreEvent(context, "downloads.onCreated"), + + onErased: ignoreEvent(context, "downloads.onErased"), + + onDeterminingFilename: ignoreEvent( + context, + "downloads.onDeterminingFilename" + ), + }, + }; + } +}; diff --git a/mobile/android/components/extensions/ext-pageAction.js b/mobile/android/components/extensions/ext-pageAction.js new file mode 100644 index 0000000000..04973379f1 --- /dev/null +++ b/mobile/android/components/extensions/ext-pageAction.js @@ -0,0 +1,154 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const { PageActionBase } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionActions.sys.mjs" +); + +const PAGE_ACTION_PROPERTIES = [ + "title", + "icon", + "popup", + "badgeText", + "enabled", + "patternMatching", +]; + +class PageAction extends PageActionBase { + constructor(extension, clickDelegate) { + const tabContext = new TabContext(tabId => this.getContextData(null)); + super(tabContext, extension); + this.clickDelegate = clickDelegate; + this.helper = new ExtensionActionHelper({ + extension, + tabTracker, + windowTracker, + tabContext, + properties: PAGE_ACTION_PROPERTIES, + }); + } + + updateOnChange(tab) { + const tabId = tab ? tab.id : null; + // The embedder only gets the override, not the full object + const action = tab + ? this.getContextData(tab) + : this.helper.extractProperties(this.globals); + this.helper.sendRequest(tabId, { + action, + type: "GeckoView:PageAction:Update", + }); + } + + openPopup() { + const tab = tabTracker.activeTab; + const popupUri = this.triggerClickOrPopup(tab); + const actionObject = this.getContextData(tab); + const action = this.helper.extractProperties(actionObject); + this.helper.sendRequest(tab.id, { + action, + type: "GeckoView:PageAction:OpenPopup", + popupUri, + }); + } + + triggerClickOrPopup(tab = tabTracker.activeTab) { + return super.triggerClickOrPopup(tab); + } + + getTab(tabId) { + return this.helper.getTab(tabId); + } + + dispatchClick() { + this.clickDelegate.onClick(); + } +} + +this.pageAction = class extends ExtensionAPIPersistent { + static for(extension) { + return GeckoViewWebExtension.pageActions.get(extension); + } + + async onManifestEntry(entryName) { + const { extension } = this; + const action = new PageAction(extension, this); + await action.loadIconData(); + this.action = action; + + GeckoViewWebExtension.pageActions.set(extension, action); + + // Notify the embedder of this action + action.updateOnChange(null); + } + + onClick() { + this.emit("click", tabTracker.activeTab); + } + + onShutdown() { + const { extension, action } = this; + action.onShutdown(); + GeckoViewWebExtension.pageActions.delete(extension); + } + + PERSISTENT_EVENTS = { + onClicked({ fire }) { + const { extension } = this; + const { tabManager } = extension; + + const listener = async (_event, tab) => { + if (fire.wakeup) { + await fire.wakeup(); + } + // TODO: we should double-check if the tab is already being closed by the time + // the background script got started and we converted the primed listener. + fire.async(tabManager.convert(tab)); + }; + + this.on("click", listener); + return { + unregister: () => { + this.off("click", listener); + }, + convert(newFire, _extContext) { + fire = newFire; + }, + }; + }, + }; + + getAPI(context) { + const { action } = this; + + return { + pageAction: { + ...action.api(context), + + onClicked: new EventManager({ + context, + module: "pageAction", + event: "onClicked", + inputHandling: true, + extensionApi: this, + }).api(), + + openPopup() { + action.openPopup(); + }, + }, + }; + } +}; + +global.pageActionFor = this.pageAction.for; diff --git a/mobile/android/components/extensions/ext-tabs.js b/mobile/android/components/extensions/ext-tabs.js new file mode 100644 index 0000000000..88d3d9e83a --- /dev/null +++ b/mobile/android/components/extensions/ext-tabs.js @@ -0,0 +1,588 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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"; + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const getBrowserWindow = window => { + return window.browsingContext.topChromeWindow; +}; + +const tabListener = { + tabReadyInitialized: false, + tabReadyPromises: new WeakMap(), + initializingTabs: new WeakSet(), + + initTabReady() { + if (!this.tabReadyInitialized) { + windowTracker.addListener("progress", this); + + this.tabReadyInitialized = true; + } + }, + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + const { tab } = browser.ownerGlobal; + + // Ignore initial about:blank + if (!request && this.initializingTabs.has(tab)) { + return; + } + + // Now we are certain that the first page in the tab was loaded. + this.initializingTabs.delete(tab); + + // browser.innerWindowID is now set, resolve the promises if any. + const deferred = this.tabReadyPromises.get(tab); + if (deferred) { + deferred.resolve(tab); + this.tabReadyPromises.delete(tab); + } + } + }, + + /** + * Returns a promise that resolves when the tab is ready. + * Tabs created via the `tabs.create` method are "ready" once the location + * changes to the requested URL. Other tabs are assumed to be ready once their + * inner window ID is known. + * + * @param {NativeTab} nativeTab The native tab object. + * @returns {Promise} Resolves with the given tab once ready. + */ + awaitTabReady(nativeTab) { + let deferred = this.tabReadyPromises.get(nativeTab); + if (!deferred) { + deferred = Promise.withResolvers(); + if ( + !this.initializingTabs.has(nativeTab) && + (nativeTab.browser.innerWindowID || + nativeTab.browser.currentURI.spec === "about:blank") + ) { + deferred.resolve(nativeTab); + } else { + this.initTabReady(); + this.tabReadyPromises.set(nativeTab, deferred); + } + } + return deferred.promise; + }, +}; + +this.tabs = class extends ExtensionAPIPersistent { + tabEventRegistrar({ event, listener }) { + const { extension } = this; + const { tabManager } = extension; + return ({ fire }) => { + const listener2 = (eventName, eventData, ...args) => { + if (!tabManager.canAccessTab(eventData.nativeTab)) { + return; + } + + listener(fire, eventData, ...args); + }; + + tabTracker.on(event, listener2); + return { + unregister() { + tabTracker.off(event, listener2); + }, + convert(_fire) { + fire = _fire; + }, + }; + }; + } + + PERSISTENT_EVENTS = { + onActivated({ fire, context }, params) { + const listener = (eventName, event) => { + const { windowId, tabId, isPrivate } = event; + if (isPrivate && !context.privateBrowsingAllowed) { + return; + } + // In GeckoView each window has only one tab, so previousTabId is omitted. + fire.async({ windowId, tabId }); + }; + + mobileWindowTracker.on("tab-activated", listener); + return { + unregister() { + mobileWindowTracker.off("tab-activated", listener); + }, + convert(_fire, _context) { + fire = _fire; + context = _context; + }, + }; + }, + onCreated: this.tabEventRegistrar({ + event: "tab-created", + listener: (fire, event) => { + const { tabManager } = this.extension; + fire.async(tabManager.convert(event.nativeTab)); + }, + }), + onRemoved: this.tabEventRegistrar({ + event: "tab-removed", + listener: (fire, event) => { + fire.async(event.tabId, { + windowId: event.windowId, + isWindowClosing: event.isWindowClosing, + }); + }, + }), + onUpdated({ fire }, params) { + const { tabManager } = this.extension; + const restricted = ["url", "favIconUrl", "title"]; + + function sanitize(tab, changeInfo) { + const result = {}; + let nonempty = false; + for (const prop in changeInfo) { + // In practice, changeInfo contains at most one property from + // restricted. Therefore it is not necessary to cache the value + // of tab.hasTabPermission outside the loop. + if (!restricted.includes(prop) || tab.hasTabPermission) { + nonempty = true; + result[prop] = changeInfo[prop]; + } + } + return [nonempty, result]; + } + + const fireForTab = (tab, changed) => { + const [needed, changeInfo] = sanitize(tab, changed); + if (needed) { + fire.async(tab.id, changeInfo, tab.convert()); + } + }; + + const listener = event => { + const needed = []; + let nativeTab; + switch (event.type) { + case "pagetitlechanged": { + const window = getBrowserWindow(event.target.ownerGlobal); + nativeTab = window.tab; + + needed.push("title"); + break; + } + + case "DOMAudioPlaybackStarted": + case "DOMAudioPlaybackStopped": { + const window = event.target.ownerGlobal; + nativeTab = window.tab; + needed.push("audible"); + break; + } + } + + if (!nativeTab) { + return; + } + + const tab = tabManager.getWrapper(nativeTab); + const changeInfo = {}; + for (const prop of needed) { + changeInfo[prop] = tab[prop]; + } + + fireForTab(tab, changeInfo); + }; + + const statusListener = ({ browser, status, url }) => { + const { tab } = browser.ownerGlobal; + if (tab) { + const changed = { status }; + if (url) { + changed.url = url; + } + + fireForTab(tabManager.wrapTab(tab), changed); + } + }; + + windowTracker.addListener("status", statusListener); + windowTracker.addListener("pagetitlechanged", listener); + + return { + unregister() { + windowTracker.removeListener("status", statusListener); + windowTracker.removeListener("pagetitlechanged", listener); + }, + convert(_fire) { + fire = _fire; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + const { tabManager } = extension; + const extensionApi = this; + const module = "tabs"; + + function getTabOrActive(tabId) { + if (tabId !== null) { + return tabTracker.getTab(tabId); + } + return tabTracker.activeTab; + } + + async function promiseTabWhenReady(tabId) { + let tab; + if (tabId !== null) { + tab = tabManager.get(tabId); + } else { + tab = tabManager.getWrapper(tabTracker.activeTab); + } + if (!tab) { + throw new ExtensionError( + tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}` + ); + } + + await tabListener.awaitTabReady(tab.nativeTab); + + return tab; + } + + function loadURIInTab(nativeTab, url) { + const { browser } = nativeTab; + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let { principal } = context; + const isAboutUrl = url.startsWith("about:"); + if ( + isAboutUrl || + (url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true })) + ) { + // Falling back to content here as about: requires it, however is safe. + principal = + Services.scriptSecurityManager.getLoadContextContentPrincipal( + Services.io.newURI(url), + browser.loadContext + ); + } + if (isAboutUrl) { + // Make sure things like about:blank and other about: URIs never + // inherit, and instead always get a NullPrincipal. + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL; + } + + browser.fixupAndLoadURIString(url, { + flags, + triggeringPrincipal: principal, + }); + } + + return { + tabs: { + onActivated: new EventManager({ + context, + module, + event: "onActivated", + extensionApi, + }).api(), + + onCreated: new EventManager({ + context, + module, + event: "onCreated", + extensionApi, + }).api(), + + /** + * Since multiple tabs currently can't be highlighted, onHighlighted + * essentially acts an alias for tabs.onActivated but returns + * the tabId in an array to match the API. + * + * @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted + */ + onHighlighted: makeGlobalEvent( + context, + "tabs.onHighlighted", + "Tab:Selected", + (fire, data) => { + const tab = tabManager.get(data.id); + + fire.async({ tabIds: [tab.id], windowId: tab.windowId }); + } + ), + + // Some events below are not be persisted because they are not implemented. + // They do not have an "extensionApi" property with an entry in + // PERSISTENT_EVENTS, but instead an empty "register" method. + onAttached: new EventManager({ + context, + name: "tabs.onAttached", + register: fire => { + return () => {}; + }, + }).api(), + + onDetached: new EventManager({ + context, + name: "tabs.onDetached", + register: fire => { + return () => {}; + }, + }).api(), + + onRemoved: new EventManager({ + context, + module, + event: "onRemoved", + extensionApi, + }).api(), + + onReplaced: new EventManager({ + context, + name: "tabs.onReplaced", + register: fire => { + return () => {}; + }, + }).api(), + + onMoved: new EventManager({ + context, + name: "tabs.onMoved", + register: fire => { + return () => {}; + }, + }).api(), + + onUpdated: new EventManager({ + context, + module, + event: "onUpdated", + extensionApi, + }).api(), + + async create({ + active, + cookieStoreId, + discarded, + index, + openInReaderMode, + pinned, + title, + url, + } = {}) { + if (active === null) { + active = true; + } + + tabListener.initTabReady(); + + if (url !== null) { + url = context.uri.resolve(url); + + if ( + !url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + if (cookieStoreId) { + cookieStoreId = getUserContextIdForCookieStoreId( + extension, + cookieStoreId, + false // TODO bug 1372178: support creation of private browsing tabs + ); + } + cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined; + + const nativeTab = await GeckoViewTabBridge.createNewTab({ + extensionId: context.extension.id, + createProperties: { + active, + cookieStoreId, + discarded, + index, + openInReaderMode, + pinned, + url, + }, + }); + + // Make sure things like about:blank URIs never inherit, + // and instead always get a NullPrincipal. + if (url !== null) { + tabListener.initializingTabs.add(nativeTab); + } else { + url = "about:blank"; + } + + loadURIInTab(nativeTab, url); + + if (active) { + const newWindow = nativeTab.browser.ownerGlobal; + mobileWindowTracker.setTabActive(newWindow, true); + } + + return tabManager.convert(nativeTab); + }, + + async remove(tabs) { + if (!Array.isArray(tabs)) { + tabs = [tabs]; + } + + await Promise.all( + tabs.map(async tabId => { + const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId); + const window = windowTracker.getWindow(windowId, context, false); + if (!window) { + throw new ExtensionError(`Invalid tab ID ${tabId}`); + } + await GeckoViewTabBridge.closeTab({ + window, + extensionId: context.extension.id, + }); + }) + ); + }, + + async update( + tabId, + { active, autoDiscardable, highlighted, muted, pinned, url } = {} + ) { + const nativeTab = getTabOrActive(tabId); + const window = nativeTab.browser.ownerGlobal; + + if (url !== null) { + url = context.uri.resolve(url); + + if ( + !url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + await GeckoViewTabBridge.updateTab({ + window, + extensionId: context.extension.id, + updateProperties: { + active, + autoDiscardable, + highlighted, + muted, + pinned, + url, + }, + }); + + if (url !== null) { + loadURIInTab(nativeTab, url); + } + + // FIXME: openerTabId, successorTabId + if (active) { + mobileWindowTracker.setTabActive(window, true); + } + + return tabManager.convert(nativeTab); + }, + + async reload(tabId, reloadProperties) { + const nativeTab = getTabOrActive(tabId); + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (reloadProperties && reloadProperties.bypassCache) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + nativeTab.browser.reloadWithFlags(flags); + }, + + async get(tabId) { + return tabManager.get(tabId).convert(); + }, + + async getCurrent() { + if (context.tabId) { + return tabManager.get(context.tabId).convert(); + } + }, + + async query(queryInfo) { + return Array.from(tabManager.query(queryInfo, context), tab => + tab.convert() + ); + }, + + async captureTab(tabId, options) { + const nativeTab = getTabOrActive(tabId); + await tabListener.awaitTabReady(nativeTab); + + const { browser } = nativeTab; + const tab = tabManager.wrapTab(nativeTab); + return tab.capture(context, browser.fullZoom, options); + }, + + async captureVisibleTab(windowId, options) { + const window = + windowId == null + ? windowTracker.topWindow + : windowTracker.getWindow(windowId, context); + + const tab = tabManager.wrapTab(window.tab); + await tabListener.awaitTabReady(tab.nativeTab); + const zoom = window.browsingContext.fullZoom; + + return tab.capture(context, zoom, options); + }, + + async detectLanguage(tabId) { + const tab = await promiseTabWhenReady(tabId); + const results = await tab.queryContent("DetectLanguage", {}); + return results[0]; + }, + + async executeScript(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.executeScript(context, details); + }, + + async insertCSS(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.insertCSS(context, details); + }, + + async removeCSS(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.removeCSS(context, details); + }, + + goForward(tabId) { + const { browser } = getTabOrActive(tabId); + browser.goForward(); + }, + + goBack(tabId) { + const { browser } = getTabOrActive(tabId); + browser.goBack(); + }, + }, + }; + } +}; diff --git a/mobile/android/components/extensions/extensions-mobile.manifest b/mobile/android/components/extensions/extensions-mobile.manifest new file mode 100644 index 0000000000..34850bed8b --- /dev/null +++ b/mobile/android/components/extensions/extensions-mobile.manifest @@ -0,0 +1,5 @@ +# modules +category webextension-modules android chrome://geckoview/content/ext-android.json + +category webextension-scripts c-android chrome://geckoview/content/ext-android.js +category webextension-scripts-addon android chrome://geckoview/content/ext-c-android.js diff --git a/mobile/android/components/extensions/jar.mn b/mobile/android/components/extensions/jar.mn new file mode 100644 index 0000000000..29d4cd01cb --- /dev/null +++ b/mobile/android/components/extensions/jar.mn @@ -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/. + +geckoview.jar: + content/ext-android.js + content/ext-android.json + content/ext-browserAction.js + content/ext-c-android.js + content/ext-c-tabs.js + content/ext-pageAction.js + content/ext-tabs.js + content/ext-downloads.js +% override chrome://extensions/content/parent/ext-downloads.js chrome://geckoview/content/ext-downloads.js diff --git a/mobile/android/components/extensions/moz.build b/mobile/android/components/extensions/moz.build new file mode 100644 index 0000000000..131ae52859 --- /dev/null +++ b/mobile/android/components/extensions/moz.build @@ -0,0 +1,21 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "ExtensionBrowsingData.sys.mjs", +] + +EXTRA_COMPONENTS += [ + "extensions-mobile.manifest", +] + +DIRS += ["schemas"] + +MOCHITEST_MANIFESTS += ["test/mochitest/mochitest.toml"] +MOCHITEST_CHROME_MANIFESTS += ["test/mochitest/chrome.toml"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] diff --git a/mobile/android/components/extensions/schemas/LICENSE-CHROMIUM b/mobile/android/components/extensions/schemas/LICENSE-CHROMIUM new file mode 100644 index 0000000000..9314092fdc --- /dev/null +++ b/mobile/android/components/extensions/schemas/LICENSE-CHROMIUM @@ -0,0 +1,27 @@ +// Copyright (c) 2006-2008 The Chromium Authors. All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/mobile/android/components/extensions/schemas/README.md b/mobile/android/components/extensions/schemas/README.md new file mode 100644 index 0000000000..790fcd648e --- /dev/null +++ b/mobile/android/components/extensions/schemas/README.md @@ -0,0 +1,13 @@ +This source code is available under the [Mozilla Public License 2.0](/LICENSE). + +Additionally, parts of the schema files originated from Chromium source code: + +> Copyright (c) 2012 The Chromium Authors. All rights reserved. +> Use of this source code is governed by a BSD-style license that can be +> found in the [LICENSE-CHROMIUM](LICENSE-CHROMIUM) file. + +You are not granted rights or licenses to the trademarks of the +Mozilla Foundation or any party, including without limitation the +Firefox name or logo. + +For more information, see: https://www.mozilla.org/foundation/licensing.html diff --git a/mobile/android/components/extensions/schemas/gecko_view_addons.json b/mobile/android/components/extensions/schemas/gecko_view_addons.json new file mode 100644 index 0000000000..b60d346d19 --- /dev/null +++ b/mobile/android/components/extensions/schemas/gecko_view_addons.json @@ -0,0 +1,16 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [ + { + "type": "string", + "enum": ["geckoViewAddons", "nativeMessagingFromContent"] + } + ] + } + ] + } +] diff --git a/mobile/android/components/extensions/schemas/jar.mn b/mobile/android/components/extensions/schemas/jar.mn new file mode 100644 index 0000000000..9f15031cd1 --- /dev/null +++ b/mobile/android/components/extensions/schemas/jar.mn @@ -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/. + +geckoview.jar: + content/schemas/gecko_view_addons.json + content/schemas/tabs.json diff --git a/mobile/android/components/extensions/schemas/moz.build b/mobile/android/components/extensions/schemas/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/components/extensions/schemas/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/components/extensions/schemas/tabs.json b/mobile/android/components/extensions/schemas/tabs.json new file mode 100644 index 0000000000..2c6a8f5651 --- /dev/null +++ b/mobile/android/components/extensions/schemas/tabs.json @@ -0,0 +1,1395 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [ + { + "type": "string", + "enum": ["activeTab"] + } + ] + }, + { + "$extend": "OptionalPermission", + "choices": [ + { + "type": "string", + "enum": ["tabs"] + } + ] + } + ] + }, + { + "namespace": "tabs", + "description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.", + "types": [ + { + "id": "MutedInfoReason", + "type": "string", + "description": "An event that caused a muted state change.", + "enum": [ + { + "name": "user", + "description": "A user input action has set/overridden the muted state." + }, + { + "name": "capture", + "description": "Tab capture started, forcing a muted state change." + }, + { + "name": "extension", + "description": "An extension, identified by the extensionId field, set the muted state." + } + ] + }, + { + "id": "MutedInfo", + "type": "object", + "description": "Tab muted state and the reason for the last state change.", + "properties": { + "muted": { + "type": "boolean", + "description": "Whether the tab is prevented from playing sound (but hasn't necessarily recently produced sound). Equivalent to whether the muted audio indicator is showing." + }, + "reason": { + "$ref": "MutedInfoReason", + "optional": true, + "description": "The reason the tab was muted or unmuted. Not set if the tab's mute state has never been changed." + }, + "extensionId": { + "type": "string", + "optional": true, + "description": "The ID of the extension that changed the muted state. Not set if an extension was not the reason the muted state last changed." + } + } + }, + { + "id": "SharingState", + "type": "object", + "description": "Tab sharing state for screen, microphone and camera. Currently unsupported on Android.", + "properties": { + "screen": { + "type": "string", + "optional": true, + "description": "If the tab is sharing the screen the value will be one of \"Screen\", \"Window\", or \"Application\", or undefined if not screen sharing." + }, + "camera": { + "type": "boolean", + "description": "True if the tab is using the camera." + }, + "microphone": { + "type": "boolean", + "description": "True if the tab is using the microphone." + } + } + }, + { + "id": "Tab", + "type": "object", + "properties": { + "id": { + "type": "integer", + "minimum": -1, + "optional": true, + "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a Tab may not be assigned an ID, for example when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to $(ref:tabs.TAB_ID_NONE) for apps and devtools windows." + }, + "index": { + "type": "integer", + "minimum": -1, + "description": "The zero-based index of the tab within its window." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "The ID of the window the tab is contained within." + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists." + }, + "highlighted": { + "type": "boolean", + "description": "Whether the tab is highlighted. Works as an alias of active." + }, + "active": { + "type": "boolean", + "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)" + }, + "pinned": { + "type": "boolean", + "description": "Whether the tab is pinned." + }, + "lastAccessed": { + "type": "integer", + "optional": true, + "description": "The last time the tab was accessed as the number of milliseconds since epoch." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing." + }, + "autoDiscardable": { + "type": "boolean", + "optional": true, + "description": "Whether the tab can be discarded automatically by the browser when resources are low." + }, + "mutedInfo": { + "$ref": "MutedInfo", + "optional": true, + "description": "Current tab muted state and the reason for the last state change." + }, + "url": { + "type": "string", + "optional": true, + "permissions": ["tabs"], + "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission." + }, + "title": { + "type": "string", + "optional": true, + "permissions": ["tabs"], + "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission." + }, + "favIconUrl": { + "type": "string", + "optional": true, + "permissions": ["tabs"], + "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading." + }, + "status": { + "type": "string", + "optional": true, + "description": "Either <em>loading</em> or <em>complete</em>." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tab is not loaded with content." + }, + "incognito": { + "type": "boolean", + "description": "Whether the tab is in an incognito window." + }, + "width": { + "type": "integer", + "optional": true, + "description": "The width of the tab in pixels." + }, + "height": { + "type": "integer", + "optional": true, + "description": "The height of the tab in pixels." + }, + "hidden": { + "type": "boolean", + "optional": true, + "description": "True if the tab is hidden." + }, + "sessionId": { + "type": "string", + "optional": true, + "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The CookieStoreId used for the tab." + }, + "isArticle": { + "type": "boolean", + "optional": true, + "description": "Whether the document in the tab can be rendered in reader mode." + }, + "isInReaderMode": { + "type": "boolean", + "optional": true, + "description": "Whether the document in the tab is being rendered in reader mode." + }, + "sharingState": { + "$ref": "SharingState", + "optional": true, + "description": "Current tab sharing state for screen, microphone and camera." + }, + "attention": { + "type": "boolean", + "optional": true, + "description": "Whether the tab is drawing attention." + }, + "successorTabId": { + "type": "integer", + "optional": true, + "minimum": -1, + "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise." + } + } + }, + { + "id": "ZoomSettingsMode", + "type": "string", + "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.", + "enum": [ + { + "name": "automatic", + "description": "Zoom changes are handled automatically by the browser." + }, + { + "name": "manual", + "description": "Overrides the automatic handling of zoom changes. The <code>onZoomChange</code> event will still be dispatched, and it is the responsibility of the extension to listen for this event and manually scale the page. This mode does not support <code>per-origin</code> zooming, and will thus ignore the <code>scope</code> zoom setting and assume <code>per-tab</code>." + }, + { + "name": "disabled", + "description": "Disables all zooming in the tab. The tab will revert to the default zoom level, and all attempted zoom changes will be ignored." + } + ] + }, + { + "id": "ZoomSettingsScope", + "type": "string", + "description": "Defines whether zoom changes will persist for the page's origin, or only take effect in this tab; defaults to <code>per-origin</code> when in <code>automatic</code> mode, and <code>per-tab</code> otherwise.", + "enum": [ + { + "name": "per-origin", + "description": "Zoom changes will persist in the zoomed page's origin, i.e. all other tabs navigated to that same origin will be zoomed as well. Moreover, <code>per-origin</code> zoom changes are saved with the origin, meaning that when navigating to other pages in the same origin, they will all be zoomed to the same zoom factor. The <code>per-origin</code> scope is only available in the <code>automatic</code> mode." + }, + { + "name": "per-tab", + "description": "Zoom changes only take effect in this tab, and zoom changes in other tabs will not affect the zooming of this tab. Also, <code>per-tab</code> zoom changes are reset on navigation; navigating a tab will always load pages with their <code>per-origin</code> zoom factors." + } + ] + }, + { + "id": "ZoomSettings", + "type": "object", + "description": "Defines how zoom changes in a tab are handled and at what scope.", + "properties": { + "mode": { + "$ref": "ZoomSettingsMode", + "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.", + "optional": true + }, + "scope": { + "$ref": "ZoomSettingsScope", + "description": "Defines whether zoom changes will persist for the page's origin, or only take effect in this tab; defaults to <code>per-origin</code> when in <code>automatic</code> mode, and <code>per-tab</code> otherwise.", + "optional": true + }, + "defaultZoomFactor": { + "type": "number", + "optional": true, + "description": "Used to return the default zoom level for the current tab in calls to tabs.getZoomSettings." + } + } + }, + { + "id": "TabStatus", + "type": "string", + "enum": ["loading", "complete"], + "description": "Whether the tabs have completed loading." + }, + { + "id": "WindowType", + "type": "string", + "enum": ["normal", "popup", "panel", "app", "devtools"], + "description": "The type of window." + } + ], + "properties": { + "TAB_ID_NONE": { + "value": -1, + "description": "An ID which represents the absence of a browser tab." + } + }, + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves details about the specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "function", + "name": "callback", + "parameters": [{ "name": "tab", "$ref": "Tab" }] + } + ] + }, + { + "name": "getCurrent", + "type": "function", + "description": "Gets the tab that this script call is being made from. May be undefined if called from a non-tab context (for example: a background page or popup view).", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true + } + ] + } + ] + }, + { + "name": "connect", + "type": "function", + "description": "Connects to the content script(s) in the specified tab. The $(ref:runtime.onConnect) event is fired in each content script running in the specified tab for the current extension. For more details, see $(topic:messaging)[Content Script Messaging].", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "object", + "name": "connectInfo", + "properties": { + "name": { + "type": "string", + "optional": true, + "description": "Will be passed into onConnect for content scripts that are listening for the connection event." + }, + "frameId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "Open a port to a specific $(topic:frame_ids)[frame] identified by <code>frameId</code> instead of all frames in the tab." + } + }, + "optional": true + } + ], + "returns": { + "$ref": "runtime.Port", + "description": "A port that can be used to communicate with the content scripts running in the specified tab. The port's $(ref:runtime.Port) event is fired if the tab closes or does not exist. " + } + }, + { + "name": "sendMessage", + "type": "function", + "description": "Sends a single message to the content script(s) in the specified tab, with an optional callback to run when a response is sent back. The $(ref:runtime.onMessage) event is fired in each content script running in the specified tab for the current extension.", + "async": "responseCallback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "any", + "name": "message" + }, + { + "type": "object", + "name": "options", + "properties": { + "frameId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "Send a message to a specific $(topic:frame_ids)[frame] identified by <code>frameId</code> instead of all frames in the tab." + } + }, + "optional": true + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "create", + "type": "function", + "description": "Creates a new tab.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "createProperties", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "The window to create the new tab in. Defaults to the $(topic:current-window)[current window]." + }, + "index": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The position the tab should take in the window. The provided value will be clamped to between zero and the number of tabs in the window." + }, + "url": { + "type": "string", + "optional": true, + "description": "The URL to navigate the tab to initially. Fully-qualified URLs must include a scheme (i.e. 'http://www.google.com', not 'www.google.com'). Relative URLs will be relative to the current page within the extension. Defaults to the New Tab Page." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should become the active tab in the window. Does not affect whether the window is focused (see $(ref:windows.update)). Defaults to <var>true</var>." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be pinned. Defaults to <var>false</var>" + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as the newly created tab." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The CookieStoreId for the tab that opened this tab." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the created tab. Will contain the ID of the new tab." + } + ] + } + ] + }, + { + "name": "duplicate", + "unsupported": true, + "type": "function", + "description": "Duplicates a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The ID of the tab which is to be duplicated." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "optional": true, + "description": "Details about the duplicated tab. The $(ref:tabs.Tab) object doesn't contain <code>url</code>, <code>title</code> and <code>favIconUrl</code> if the <code>\"tabs\"</code> permission has not been requested.", + "$ref": "Tab" + } + ] + } + ] + }, + { + "name": "query", + "type": "function", + "description": "Gets all tabs that have the specified properties, or all tabs if no properties are specified.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "queryInfo", + "properties": { + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are active in their windows." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are pinned." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are audible." + }, + "muted": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are muted." + }, + "highlighted": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are highlighted. Works as an alias of active." + }, + "currentWindow": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are in the $(topic:current-window)[current window]." + }, + "lastFocusedWindow": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are in the last focused window." + }, + "status": { + "$ref": "TabStatus", + "optional": true, + "description": "Whether the tabs have completed loading." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tabs are not loaded with content." + }, + "title": { + "type": "string", + "optional": true, + "description": "Match page titles against a pattern." + }, + "url": { + "choices": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ], + "optional": true, + "description": "Match tabs against one or more $(topic:match_patterns)[URL patterns]. Note that fragment identifiers are not matched." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": -2, + "description": "The ID of the parent window, or $(ref:windows.WINDOW_ID_CURRENT) for the $(topic:current-window)[current window]." + }, + "windowType": { + "$ref": "WindowType", + "optional": true, + "description": "The type of window the tabs are in." + }, + "index": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "The position of the tabs within their windows." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "The CookieStoreId used for the tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "array", + "items": { + "$ref": "Tab" + } + } + ] + } + ] + }, + { + "name": "highlight", + "type": "function", + "description": "Highlights the given tabs.", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "type": "object", + "name": "highlightInfo", + "properties": { + "windowId": { + "type": "integer", + "optional": true, + "description": "The window that contains the tabs.", + "minimum": -2 + }, + "tabs": { + "description": "One or more tab indices to highlight.", + "choices": [ + { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + }, + { "type": "integer" } + ] + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "window", + "$ref": "windows.Window", + "description": "Contains details about the window whose tabs were highlighted." + } + ] + } + ] + }, + { + "name": "update", + "type": "function", + "description": "Modifies the properties of a tab. Properties that are not specified in <var>updateProperties</var> are not modified.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the selected tab of the $(topic:current-window)[current window]." + }, + { + "type": "object", + "name": "updateProperties", + "properties": { + "url": { + "type": "string", + "optional": true, + "description": "A URL to navigate the tab to." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be active. Does not affect whether the window is focused (see $(ref:windows.update))." + }, + "highlighted": { + "unsupported": true, + "type": "boolean", + "optional": true, + "description": "Adds or removes the tab from the current selection." + }, + "pinned": { + "type": "boolean", + "unsupported": true, + "optional": true, + "description": "Whether the tab should be pinned." + }, + "muted": { + "type": "boolean", + "unsupported": true, + "optional": true, + "description": "Whether the tab should be muted." + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the updated tab. The $(ref:tabs.Tab) object doesn't contain <code>url</code>, <code>title</code> and <code>favIconUrl</code> if the <code>\"tabs\"</code> permission has not been requested." + } + ] + } + ] + }, + { + "name": "move", + "unsupported": true, + "type": "function", + "description": "Moves one or more tabs to a new position within its window, or to a new window. Note that tabs can only be moved to and from normal (window.type === \"normal\") windows.", + "async": "callback", + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to move.", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + }, + { + "type": "object", + "name": "moveProperties", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "Defaults to the window the tab is currently in." + }, + "index": { + "type": "integer", + "minimum": -1, + "description": "The position to move the window to. -1 will place the tab at the end of the window." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tabs", + "description": "Details about the moved tabs.", + "choices": [ + { "$ref": "Tab" }, + { "type": "array", "items": { "$ref": "Tab" } } + ] + } + ] + } + ] + }, + { + "name": "reload", + "type": "function", + "description": "Reload a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to reload; defaults to the selected tab of the current window." + }, + { + "type": "object", + "name": "reloadProperties", + "optional": true, + "properties": { + "bypassCache": { + "type": "boolean", + "optional": true, + "description": "Whether using any local cache. Default is false." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Closes one or more tabs.", + "async": "callback", + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to close.", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "detectLanguage", + "type": "function", + "description": "Detects the primary language of the content in a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the active tab of the $(topic:current-window)[current window]." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "string", + "name": "language", + "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. The 2nd to 4th columns will be checked and the first non-NULL value will be returned except for Simplified Chinese for which zh-CN will be returned. For an unknown language, <code>und</code> will be returned." + } + ] + } + ] + }, + { + "name": "captureTab", + "type": "function", + "description": "Captures an area of a specified tab. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", + "permissions": ["<all_urls>"], + "async": true, + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The tab to capture. Defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.ImageDetails", + "name": "options", + "optional": true + } + ] + }, + { + "name": "captureVisibleTab", + "type": "function", + "description": "Captures an area of the currently active tab in the specified window. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", + "permissions": ["<all_urls>"], + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": -2, + "optional": true, + "description": "The target window. Defaults to the $(topic:current-window)[current window]." + }, + { + "$ref": "extensionTypes.ImageDetails", + "name": "options", + "optional": true + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "string", + "name": "dataUrl", + "description": "A data URL which encodes an image of the visible area of the captured tab. May be assigned to the 'src' property of an HTML Image element for display." + } + ] + } + ] + }, + { + "name": "executeScript", + "type": "function", + "max_manifest_version": 2, + "description": "Injects JavaScript code into a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab in which to run the script; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the script to run." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after all the JavaScript has been executed.", + "parameters": [ + { + "name": "result", + "optional": true, + "type": "array", + "items": { "type": "any" }, + "description": "The result of the script in every injected frame." + } + ] + } + ] + }, + { + "name": "insertCSS", + "type": "function", + "max_manifest_version": 2, + "description": "Injects CSS into a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab in which to insert the CSS; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the CSS text to insert." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when all the CSS has been inserted.", + "parameters": [] + } + ] + }, + { + "name": "removeCSS", + "type": "function", + "max_manifest_version": 2, + "description": "Removes injected CSS from a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab from which to remove the injected CSS; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the CSS text to remove." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when all the CSS has been removed.", + "parameters": [] + } + ] + }, + { + "name": "setZoom", + "unsupported": true, + "type": "function", + "description": "Zooms a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to zoom; defaults to the active tab of the current window." + }, + { + "type": "number", + "name": "zoomFactor", + "description": "The new zoom factor. Use a value of 0 here to set the tab to its current default zoom factor. Values greater than zero specify a (possibly non-default) zoom factor for the tab." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after the zoom factor has been changed.", + "parameters": [] + } + ] + }, + { + "name": "getZoom", + "unsupported": true, + "type": "function", + "description": "Gets the current zoom factor of a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to get the current zoom factor from; defaults to the active tab of the current window." + }, + { + "type": "function", + "name": "callback", + "description": "Called with the tab's current zoom factor after it has been fetched.", + "parameters": [ + { + "type": "number", + "name": "zoomFactor", + "description": "The tab's current zoom factor." + } + ] + } + ] + }, + { + "name": "setZoomSettings", + "unsupported": true, + "type": "function", + "description": "Sets the zoom settings for a specified tab, which define how zoom changes are handled. These settings are reset to defaults upon navigating the tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "optional": true, + "minimum": 0, + "description": "The ID of the tab to change the zoom settings for; defaults to the active tab of the current window." + }, + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "Defines how zoom changes are handled and at what scope." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after the zoom settings have been changed.", + "parameters": [] + } + ] + }, + { + "name": "getZoomSettings", + "unsupported": true, + "type": "function", + "description": "Gets the current zoom settings of a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "optional": true, + "minimum": 0, + "description": "The ID of the tab to get the current zoom settings from; defaults to the active tab of the current window." + }, + { + "type": "function", + "name": "callback", + "description": "Called with the tab's current zoom settings.", + "parameters": [ + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "The tab's current zoom settings." + } + ] + } + ] + }, + { + "name": "goForward", + "type": "function", + "description": "Navigate to next page in tab's history, if available.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to navigate forward." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "goBack", + "type": "function", + "description": "Navigate to previous page in tab's history, if available.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to navigate backward." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onCreated", + "type": "function", + "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.", + "parameters": [ + { + "$ref": "Tab", + "name": "tab", + "description": "Details of the tab that was created." + } + ] + }, + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a tab is updated.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "changeInfo", + "description": "Lists the changes to the state of the tab that was updated.", + "properties": { + "status": { + "type": "string", + "optional": true, + "description": "The status of the tab. Can be either <em>loading</em> or <em>complete</em>." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tab is not loaded with content." + }, + "url": { + "type": "string", + "optional": true, + "description": "The tab's URL if it has changed." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "The tab's new pinned state." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "The tab's new audible state." + }, + "mutedInfo": { + "$ref": "MutedInfo", + "optional": true, + "description": "The tab's new muted state and the reason for the change." + }, + "favIconUrl": { + "type": "string", + "optional": true, + "description": "The tab's new favicon URL." + } + } + }, + { + "$ref": "Tab", + "name": "tab", + "description": "Gives the state of the tab that was updated." + } + ] + }, + { + "name": "onMoved", + "type": "function", + "description": "Fired when a tab is moved within a window. Only one move event is fired, representing the tab the user directly moved. Move events are not fired for the other tabs that must move in response. This event is not fired when a tab is moved between windows. For that, see $(ref:tabs.onDetached).", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "moveInfo", + "properties": { + "windowId": { "type": "integer", "minimum": 0 }, + "fromIndex": { "type": "integer", "minimum": 0 }, + "toIndex": { "type": "integer", "minimum": 0 } + } + } + ] + }, + { + "name": "onActivated", + "type": "function", + "description": "Fires when the active tab in a window changes. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.", + "parameters": [ + { + "type": "object", + "name": "activeInfo", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab that has become active." + }, + "previousTabId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that was previously active, if that tab is still open." + }, + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the window the active tab changed inside of." + } + } + } + ] + }, + { + "name": "onHighlighted", + "type": "function", + "description": "Fired when the highlighted or selected tabs in a window changes.", + "parameters": [ + { + "type": "object", + "name": "highlightInfo", + "properties": { + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The window whose tabs changed." + }, + "tabIds": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "description": "All highlighted tabs in the window." + } + } + } + ] + }, + { + "name": "onDetached", + "type": "function", + "description": "Fired when a tab is detached from a window, for example because it is being moved between windows.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "detachInfo", + "properties": { + "oldWindowId": { "type": "integer", "minimum": 0 }, + "oldPosition": { "type": "integer", "minimum": 0 } + } + } + ] + }, + { + "name": "onAttached", + "type": "function", + "description": "Fired when a tab is attached to a window, for example because it was moved between windows.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "attachInfo", + "properties": { + "newWindowId": { "type": "integer", "minimum": 0 }, + "newPosition": { "type": "integer", "minimum": 0 } + } + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when a tab is closed.", + "parameters": [ + { "type": "integer", "name": "tabId", "minimum": 0 }, + { + "type": "object", + "name": "removeInfo", + "properties": { + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The window whose tab is closed." + }, + "isWindowClosing": { + "type": "boolean", + "description": "True when the tab is being closed because its window is being closed." + } + } + } + ] + }, + { + "name": "onReplaced", + "type": "function", + "description": "Fired when a tab is replaced with another tab due to prerendering or instant.", + "parameters": [ + { "type": "integer", "name": "addedTabId", "minimum": 0 }, + { "type": "integer", "name": "removedTabId", "minimum": 0 } + ] + }, + { + "name": "onZoomChange", + "unsupported": true, + "type": "function", + "description": "Fired when a tab is zoomed.", + "parameters": [ + { + "type": "object", + "name": "ZoomChangeInfo", + "properties": { + "tabId": { "type": "integer", "minimum": 0 }, + "oldZoomFactor": { "type": "number" }, + "newZoomFactor": { "type": "number" }, + "zoomSettings": { "$ref": "ZoomSettings" } + } + } + ] + } + ] + } +] diff --git a/mobile/android/components/extensions/test/mochitest/.eslintrc.js b/mobile/android/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..7d6fe2eb1a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + extends: + "../../../../../../toolkit/components/extensions/test/mochitest/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/test/mochitest/chrome.toml b/mobile/android/components/extensions/test/mochitest/chrome.toml new file mode 100644 index 0000000000..5a93353448 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/chrome.toml @@ -0,0 +1,8 @@ +[DEFAULT] +support-files = [ + "head.js", + "../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js", +] +tags = "webextensions" + +["test_ext_options_ui.html"] diff --git a/mobile/android/components/extensions/test/mochitest/context.html b/mobile/android/components/extensions/test/mochitest/context.html new file mode 100644 index 0000000000..1e25c6e851 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context.html @@ -0,0 +1,24 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + just some text 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + <img src="ctxmenu-image.png" id="img1"> + + <p> + <a href="some-link" id="link1">Some link</a> + </p> + + <p> + <a href="image-around-some-link"> + <img src="ctxmenu-image.png" id="img-wrapped-in-link"> + </a> + </p> + + <p> + <input type="text" id="edit-me"><br> + <input type="password" id="password"> + </p> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html new file mode 100644 index 0000000000..1e2afec6fa --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html @@ -0,0 +1,22 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h3>test iframe</h3> + <script> + "use strict"; + + window.onload = function() { + window.onhashchange = function() { + window.parent.postMessage("updated-iframe-url", "*"); + }; + // NOTE: without the this setTimeout the location change is not fired + // even without the "fire only for top level windows" fix + setTimeout(function() { + window.location.hash = "updated-iframe-url"; + }, 0); + }; + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html new file mode 100644 index 0000000000..3fa93979fa --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html @@ -0,0 +1,21 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h3>test page</h3> + <iframe src="about:blank"></iframe> + <script> + "use strict"; + + window.onmessage = function(evt) { + if (evt.data === "updated-iframe-url") { + window.postMessage("frame-updated", "*"); + } + }; + window.onload = function() { + document.querySelector("iframe").setAttribute("src", "context_tabs_onUpdated_iframe.html"); + }; + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs b/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs new file mode 100644 index 0000000000..eed8a6ef49 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs @@ -0,0 +1,13 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } +} diff --git a/mobile/android/components/extensions/test/mochitest/file_dummy.html b/mobile/android/components/extensions/test/mochitest/file_dummy.html new file mode 100644 index 0000000000..49ad37128d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_dummy.html @@ -0,0 +1,10 @@ +<html> +<head> +<meta charset="utf-8"> +<title>Dummy test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Dummy test page</p> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_iframe_document.html b/mobile/android/components/extensions/test/mochitest/file_iframe_document.html new file mode 100644 index 0000000000..3bb2bd5dcf --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_iframe_document.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title></title> +</head> +<body> + <iframe src="/"></iframe> + <iframe src="about:blank"></iframe> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs b/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs new file mode 100644 index 0000000000..3816cf045b --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +// This script slows the load of an HTML document so that we can reliably test +// all phases of the load cycle supported by the extension API. + +/* eslint-disable no-unused-vars */ + +const URL = "file_slowed_document.sjs"; + +const DELAY = 2 * 1000; // Delay one second before completing the request. + +const nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(`<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + `); + + // Note: We need to store a reference to the timer to prevent it from being + // canceled when it's GCed. + timer = new nsTimer( + () => { + if (request.queryString.includes("with-iframe")) { + response.write(`<iframe src="${URL}?r=${Math.random()}"></iframe>`); + } + response.write(`</body></html>`); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/mobile/android/components/extensions/test/mochitest/head.js b/mobile/android/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..c240edd765 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/head.js @@ -0,0 +1,73 @@ +"use strict"; + +/* exported assertPersistentListeners, AppConstants, TEST_ICON_ARRAYBUFFER */ + +var { AppConstants } = SpecialPowers.ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +var TEST_ICON_DATA = + "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC4klEQVRYhdWXLWzbQBSADQtDAwsHC1tUhUxqfL67lk2tdn+OJg0ODU0rLByqgqINBY6tmlbn7LMTJ5FaFVVBk1G0oUGjG2jT2Y7jxmmcbU/6iJ+f36fz+e5sGP9riCGm9hB37RG+scd4Yo/wsDXCZyIE2xuXsce4bY+wXkAsQtzYmExrfFgvkJkRbkzo1ehoxx5iXcgI/9iYUGt8WH9MqDXEcmNChmEYrRCf2SHWeYgQx3x0tLNRIeKQLTtEFyJEep4NTuhk8BC+yMrwEE3+iozo42d8gK7FAOkMsRiiN8QhW2ttSK5QTfRRV4QoymVeJMvPvDp7gCZigD613MN6yRFA3SWarow9QB9LCfG+NeF9qCtjAKOSQjCqVKhfVsiHEQ+grgx/lRGqUihAc1uL8EFD+KCRO+GrF4J61phcoRoPoEzkYhZYpykh5sMb7kOdIeY+jHKur4QI4Feh4AFX1nVeLxrAvQchGsBz5ls6wa2QdwcvIcE2863bTH79KOvsz/uUYJsp+J0pSzNlDckVqqVGUAF+n6uS7txcOl6wot4JVy70ufDLy4pWLUQVPE81pRI0mGe9oxLMHSeohHvMs/STUNaUK6vDPCvOyxMFDx4achehRDJmHnydnkPww5OFfLxrGIZBFDyYl4LpMzlTQFIP6AQx86w2UeYBccFpJrcKv5L9eGDtUAU6RIELqsB74uynjy/UBRF1gS5BTFxwQT1wTiXoUg9MH7m/3NZRRoi5IJytUbMgzv4Wc832+oQkiKgEehmyMkkpKsFkQV11QsRJL5rJYBLItQgRaUZEmnoZXsomz3vGiWw+I9KMF9SVFOqZEemZekli1jN3U/UOqhHHvC6oWWGElhfSpGdOk6+O9prdwvtLj5BjRsQxdRnot+Zeifpy/2/0stktKTRNLmbk0mwXyl8253fyojj+8rxOHNAhjjm5n0/5OOCGOKBzkrMO0Z75lvSAzKlrF32Z/3z8BqLAn+yMV7VhAAAAAElFTkSuQmCC"; + +var TEST_ICON_ARRAYBUFFER = Uint8Array.from(atob(TEST_ICON_DATA), byte => + byte.charCodeAt(0) +).buffer; + +async function assertPersistentListeners( + extWrapper, + apiNs, + apiEvents, + expected +) { + const stringErr = await SpecialPowers.spawnChrome( + [extWrapper.id, apiNs, apiEvents, expected], + async (id, apiNs, apiEvents, expected) => { + try { + const { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" + ); + const ext = { id }; + for (const event of apiEvents) { + ExtensionTestCommon.testAssertions.assertPersistentListeners( + ext, + apiNs, + event, + { + primed: expected.primed, + persisted: expected.persisted, + primedListenersCount: expected.primedListenersCount, + } + ); + } + } catch (err) { + return String(err); + } + } + ); + ok( + stringErr == undefined, + stringErr ? stringErr : `Found expected primed and persistent listeners` + ); +} + +{ + const chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js") + ); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + const results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok( + false, + `Test left extra windows or tabs: ${JSON.stringify(results)}\n` + ); + } + }); +} diff --git a/mobile/android/components/extensions/test/mochitest/mochitest.toml b/mobile/android/components/extensions/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..f3e5922cc4 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/mochitest.toml @@ -0,0 +1,62 @@ +[DEFAULT] +support-files = [ + "../../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js", + "../../../../../../toolkit/components/extensions/test/mochitest/file_sample.html", + "../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js", + "context.html", + "context_tabs_onUpdated_iframe.html", + "context_tabs_onUpdated_page.html", + "file_bypass_cache.sjs", + "file_dummy.html", + "file_iframe_document.html", + "file_slowed_document.sjs", + "head.js", +] +tags = "webextensions" +prefs = ["javascript.options.asyncstack_capture_debuggee_only=false"] + +["test_ext_all_apis.html"] + +["test_ext_downloads_event_page.html"] + +["test_ext_tab_runtimeConnect.html"] + +["test_ext_tabs_autoDiscardable.html"] + +["test_ext_tabs_create.html"] + +["test_ext_tabs_events.html"] +skip-if = ["fission"] # Bug 1827754 + +["test_ext_tabs_executeScript.html"] + +["test_ext_tabs_executeScript_bad.html"] + +["test_ext_tabs_executeScript_no_create.html"] + +["test_ext_tabs_executeScript_runAt.html"] + +["test_ext_tabs_get.html"] + +["test_ext_tabs_getCurrent.html"] + +["test_ext_tabs_goBack_goForward.html"] + +["test_ext_tabs_insertCSS.html"] + +["test_ext_tabs_lastAccessed.html"] +skip-if = ["true"] # tab.lastAccessed not implemented + +["test_ext_tabs_onUpdated.html"] + +["test_ext_tabs_query.html"] + +["test_ext_tabs_reload.html"] + +["test_ext_tabs_reload_bypass_cache.html"] + +["test_ext_tabs_sendMessage.html"] + +["test_ext_tabs_update_url.html"] + +["test_ext_webNavigation_onCommitted.html"] diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html new file mode 100644 index 0000000000..3ad239b093 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html @@ -0,0 +1,51 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; +/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */ +let expectedContentApisTargetSpecific = []; + +let expectedBackgroundApisTargetSpecific = [ + "tabs.MutedInfoReason", + "tabs.TAB_ID_NONE", + "tabs.TabStatus", + "tabs.WindowType", + "tabs.ZoomSettingsMode", + "tabs.ZoomSettingsScope", + "tabs.connect", + "tabs.create", + "tabs.detectLanguage", + "tabs.executeScript", + "tabs.get", + "tabs.getCurrent", + "tabs.goBack", + "tabs.goForward", + "tabs.insertCSS", + "tabs.onActivated", + "tabs.onAttached", + "tabs.onCreated", + "tabs.onDetached", + "tabs.onHighlighted", + "tabs.onMoved", + "tabs.onRemoved", + "tabs.onReplaced", + "tabs.onUpdated", + "tabs.query", + "tabs.reload", + "tabs.remove", + "tabs.removeCSS", + "tabs.sendMessage", + "tabs.update", +]; +</script> +<script src="test_ext_all_apis.js"></script> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_downloads_event_page.html b/mobile/android/components/extensions/test/mochitest/test_ext_downloads_event_page.html new file mode 100644 index 0000000000..78691114df --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_downloads_event_page.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Downloads Events Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_downloads_event_page() { + const apiEvents = ["onChanged"]; + const apiNs = "downloads"; + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@downloads" } }, + permissions: ["downloads"], + background: { persistent: false }, + }, + background() { + browser.downloads.onChanged.addListener(() => { + browser.test.sendMessage("onChanged"); + browser.test.notifyPass("downloads-events"); + }); + browser.test.sendMessage("ready"); + }, + }); + + // on startup, onChanged event listener should not be primed + await extension.startup(); + info("Wait for event page to be started"); + await extension.awaitMessage("ready"); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + // when the extension is killed, onChanged event listener should be primed + info("Terminate event page"); + await extension.terminateBackground(); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: true }); + + // fire download-changed event and onChanged event listener should not be primed + info("Wait for download-changed to be emitted"); + await SpecialPowers.spawnChrome([], async () => { + const { DownloadTracker } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewWebExtension.sys.mjs" + ); + + const delta = { + filename: "test.gif", + id: 4, + mime: "image/gif", + totalBytes: 5, + }; + + // Mocks DownloadItem from mobile/android/components/extensions/ext-downloads.js + const downloadItem = { + byExtensionId: "download-onChanged@tests.mozilla.org", + byExtensionName: "Download", + bytesReceived: 0, + canResume: false, + danger: "safe", + exists: false, + fileSize: -1, + filename: "test.gif", + id: 4, + incognito: false, + mime: "image/gif", + paused: false, + referrer: "", + startTime: 1680818149350, + state: "in_progress", + totalBytes: 5, + url: "http://localhost:4245/assets/www/images/test.gif", + }; + + // WebExtension.DownloadDelegate has not been overridden in + // TestRunnerActivity (used by mochitests), so the downloads API + // does not actually work. In this test, we are only interested in + // whether or not dispatching an event would wake up the event page, + // so we artificially trigger a fake onChanged event to test that. + DownloadTracker.emit("download-changed", { delta, downloadItem }); + }); + + info("Triggered download change, expecting downloads.onChanged event"); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onChanged"); + await extension.awaitFinish("downloads-events"); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html new file mode 100644 index 0000000000..138bb054a9 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html @@ -0,0 +1,498 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>PageAction Test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function waitAboutAddonsRendered(addonId) { + await ContentTaskUtils.waitForCondition(() => { + return content.document.querySelector(`div.addon-item[addonID="${addonId}"]`); + }, `wait Addon Item for ${addonId} to be rendered`); +} + +async function navigateToAddonDetails(addonId) { + const item = content.document.querySelector(`div.addon-item[addonID="${addonId}"]`); + const rect = item.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const domWinUtils = content.window.windowUtils; + + domWinUtils.sendMouseEventToWindow("mousedown", x, y, 0, 1, 0); + domWinUtils.sendMouseEventToWindow("mouseup", x, y, 0, 1, 0); +} + +async function waitAddonOptionsPage([addonId, expectedText]) { + await ContentTaskUtils.waitForCondition(() => { + const optionsIframe = content.document.querySelector(`#addon-options`); + return optionsIframe && optionsIframe.contentDocument.readyState === "complete" && + optionsIframe.contentDocument.body.innerText.includes(expectedText); + }, `wait Addon Options ${expectedText} for ${addonId} to be loaded`); + + const optionsIframe = content.document.querySelector(`#addon-options`); + + return { + iframeHeight: optionsIframe.style.height, + documentHeight: optionsIframe.contentDocument.documentElement.scrollHeight, + bodyHeight: optionsIframe.contentDocument.body.scrollHeight, + }; +} + +async function clickOnLinkInOptionsPage(selector) { + const optionsIframe = content.document.querySelector(`#addon-options`); + optionsIframe.contentDocument.querySelector(selector).click(); +} + +async function clickAddonOptionButton() { + content.document.querySelector(`button#open-addon-options`).click(); +} + +async function navigateBack() { + content.window.history.back(); +} + +function waitDOMContentLoaded(checkUrlCb) { + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + return new Promise(resolve => { + const listener = (event) => { + if (checkUrlCb(event.target.defaultView.location.href)) { + BrowserApp.deck.removeEventListener("DOMContentLoaded", listener); + resolve(); + } + }; + + BrowserApp.deck.addEventListener("DOMContentLoaded", listener); + }); +} + +function waitAboutAddonsLoaded() { + return waitDOMContentLoaded(url => url === "about:addons"); +} + +function clickAddonDisable() { + content.document.querySelector("#disable-btn").click(); +} + +function clickAddonEnable() { + content.document.querySelector("#enable-btn").click(); +} + +add_task(async function test_options_ui_iframe_height() { + const addonID = "test-options-ui@mozilla.org"; + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + // An option page with the document element bigger than the body. + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + html { height: 500px; border: 1px solid black; } + body { height: 200px; } + </style> + </head> + <body> + <h1>Options page 1</h1> + <a href="options2.html">go to page 2</a> + </body> + </html> + `, + // A second option page with the body element bigger than the document. + "options2.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + html { height: 200px; border: 1px solid black; } + body { height: 350px; } + </style> + </head> + <body> + <h1>Options page 2</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [addonID], waitAboutAddonsRendered); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [addonID], navigateToAddonDetails); + + const optionsSizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 1"]], waitAddonOptionsPage + ); + + ok(parseInt(optionsSizes.iframeHeight, 10) >= 500, + "The addon options iframe is at least 500px"); + + is(optionsSizes.iframeHeight, optionsSizes.documentHeight + "px", + "The addon options iframe has the expected height"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, ["a"], clickOnLinkInOptionsPage); + + const options2Sizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 2"]], waitAddonOptionsPage + ); + + // The second option page has a body bigger than the document element + // and we expect the iframe to be bigger than that. + ok(parseInt(options2Sizes.iframeHeight, 10) > 200, + `The iframe is bigger then 200px (${options2Sizes.iframeHeight})`); + + // The second option page has a body smaller than the document element of the first + // page and we expect the iframe to be smaller than for the previous options page. + ok(parseInt(options2Sizes.iframeHeight, 10) < 500, + `The iframe is smaller then 500px (${options2Sizes.iframeHeight})`); + + is(options2Sizes.iframeHeight, options2Sizes.documentHeight + "px", + "The second addon options page has the expected height"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [], navigateBack); + + const backToOptionsSizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 1"]], waitAddonOptionsPage + ); + + // After going back to the first options page, + // we expect the iframe to have the same size of the previous load. + is(backToOptionsSizes.iframeHeight, optionsSizes.iframeHeight, + `When navigating back, the old iframe size is restored (${backToOptionsSizes.iframeHeight})`); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_open_aboutaddons_details() { + const addonID = "test-options-ui-open-addon-details@mozilla.org"; + + function background() { + browser.test.onMessage.addListener(msg => { + if (msg !== "runtime.openOptionsPage") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.runtime.openOptionsPage(); + }); + } + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open addon details Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + info("Wait runtime.openOptionsPage to open the about:addond details in the existent tab"); + extension.sendMessage("runtime.openOptionsPage"); + await extension.awaitMessage("options-page-loaded"); + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is still the currently selected tab once the options has been loaded"); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_open_in_tab() { + const addonID = "test-options-ui@mozilla.org"; + + function background() { + browser.test.onMessage.addListener(msg => { + if (msg !== "runtime.openOptionsPage") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.runtime.openOptionsPage(); + }); + } + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open_in_tab Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + open_in_tab: true, + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.selectOrAddTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + const aboutAddonsTab = BrowserApp.selectedTab; + + is(aboutAddonsTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], waitAboutAddonsRendered); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], navigateToAddonDetails); + + const onceAddonOptionsLoaded = waitDOMContentLoaded(url => url.endsWith("options.html")); + + info("Click the Options button in the addon details"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonOptionButton); + + info("Waiting that the addon options are loaded in a new tab"); + await onceAddonOptionsLoaded; + + const addonOptionsTab = BrowserApp.selectedTab; + + ok(aboutAddonsTab.id !== addonOptionsTab.id, + "The Addon Options page has been loaded in a new tab"); + + let optionsURL = await extension.awaitMessage("options-page-loaded"); + + is(addonOptionsTab.currentURI.spec, optionsURL, + "Got the expected extension url opened in the addon options tab"); + + const waitTabClosed = (nativeTab) => { + return new Promise(resolve => { + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + const expectedBrowser = nativeTab.browser; + + const tabCloseListener = (event) => { + const browser = event.target; + if (browser !== expectedBrowser) { + return; + } + + BrowserApp.deck.removeEventListener("TabClose", tabCloseListener); + resolve(); + }; + + BrowserApp.deck.addEventListener("TabClose", tabCloseListener); + }); + }; + + const onceOptionsTabClosed = waitTabClosed(addonOptionsTab); + const onceAboutAddonsClosed = waitTabClosed(aboutAddonsTab); + + info("Close the opened about:addons and options tab"); + BrowserApp.closeTab(addonOptionsTab); + BrowserApp.closeTab(aboutAddonsTab); + + info("Wait the tabs to be closed"); + await Promise.all([onceOptionsTabClosed, onceAboutAddonsClosed]); + + const oldSelectedTab = BrowserApp.selectedTab; + info("Call runtime.openOptionsPage"); + extension.sendMessage("runtime.openOptionsPage"); + + info("Wait runtime.openOptionsPage to open the options in a new tab"); + optionsURL = await extension.awaitMessage("options-page-loaded"); + is(BrowserApp.selectedTab.currentURI.spec, optionsURL, + "runtime.openOptionsPage has opened the expected extension page"); + ok(BrowserApp.selectedTab !== oldSelectedTab, + "runtime.openOptionsPage has opened a new tab"); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_on_disable_and_enable() { + // Temporarily disabled for races. + /* eslint-disable no-unreachable */ + return; + + const addonID = "test-options-ui-disable-enable@mozilla.org"; + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open addon details Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + const aboutAddonsTab = BrowserApp.selectedTab; + + is(aboutAddonsTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + info("Wait the addon details to have been loaded"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], waitAboutAddonsRendered); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], navigateToAddonDetails); + + info("Wait the addon options page to have been loaded"); + await extension.awaitMessage("options-page-loaded"); + + info("Click the addon disable button"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonDisable); + + // NOTE: Currently after disabling the addon the extension.awaitMessage seems + // to fail be able to receive events coming from the browser.test.sendMessage API + // (nevertheless `await extension.unload()` seems to be able to remove the extension), + // falling back to wait for the options page to be loaded here. + const onceAddonOptionsLoaded = waitDOMContentLoaded(url => url.endsWith("options.html")); + + info("Click the addon enable button"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonEnable); + + info("Wait the addon options page to have been loaded after clicking the addon enable button"); + await onceAddonOptionsLoaded; + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html b/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html new file mode 100644 index 0000000000..48904c2990 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs runtimeConnect Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const win = window.open("http://mochi.test:8888/"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background: function() { + const messages_received = []; + + let tabId; + + browser.runtime.onConnect.addListener((port) => { + browser.test.assertTrue(!!port, "tab to background port received"); + browser.test.assertEq("tab-connection-name", port.name, "port name should be defined and equal to connectInfo.name"); + browser.test.assertTrue(!!port.sender.tab, "port.sender.tab should be defined"); + browser.test.assertEq(tabId, port.sender.tab.id, "port.sender.tab.id should be equal to the expected tabId"); + + port.onMessage.addListener((msg) => { + messages_received.push(msg); + + if (messages_received.length == 1) { + browser.test.assertEq("tab to background port message", msg, "'tab to background' port message received"); + port.postMessage("background to tab port message"); + } + + if (messages_received.length == 2) { + browser.test.assertTrue(!!msg.tabReceived, "'background to tab' reply port message received"); + browser.test.assertEq("background to tab port message", msg.tabReceived, "reply port content contains the message received"); + + browser.test.notifyPass("tabRuntimeConnect.pass"); + } + }); + }); + + browser.tabs.create({url: "tab.html"}, + (tab) => { tabId = tab.id; }); + }, + + files: { + "tab.js": function() { + const port = browser.runtime.connect({name: "tab-connection-name"}); + port.postMessage("tab to background port message"); + port.onMessage.addListener((msg) => { + port.postMessage({tabReceived: msg}); + }); + }, + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <title>test tab extension page</title> + <meta charset="utf-8"> + <script src="tab.js" async><\/script> + </head> + <body> + <h1>test tab extension page</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabRuntimeConnect.pass"); + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html new file mode 100644 index 0000000000..63ea8337a7 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_autoDiscardable.html @@ -0,0 +1,39 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>autoDiscardable test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + const tab = await browser.tabs.create({}); + browser.test.assertTrue(tab.autoDiscardable, "autoDiscardable should be true on Android"); + browser.test.assertThrows(() => browser.tabs.query({ autoDiscardable: true }), + /Unexpected property "autoDiscardable"/, + `tabs.query with autoDiscardable should error out on Android`); + browser.test.assertThrows(() => browser.tabs.update(tab.id, { autoDiscardable: true }), + /Unexpected property "autoDiscardable"/, + `tabs.update with autoDiscardable should error out on Android`); + await browser.tabs.remove(tab.id); // Cleanup + browser.test.notifyPass("tabs.autoDiscardable"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.autoDiscardable"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html new file mode 100644 index 0000000000..027b231fa7 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html @@ -0,0 +1,153 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.userContext.enabled", true], + ["dom.security.https_first", false], + ], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs", "cookies"], + + "background": {"page": "bg/background.html"}, + }, + + files: { + "bg/blank.html": `<html><head><meta charset="utf-8"></head></html>`, + + "bg/background.html": `<html><head> + <meta charset="utf-8"> + <script src="background.js"><\/script> + </head></html>`, + + "bg/background.js": function() { + let activeTab; + + function runTests() { + const DEFAULTS = { + active: true, + url: "about:blank", + }; + + const tests = [ + { + create: {url: "http://example.com/"}, + result: {url: "http://example.com/"}, + }, + { + create: {url: "blank.html"}, + result: {url: browser.runtime.getURL("bg/blank.html")}, + }, + { + create: {}, + }, + { + create: {active: false}, + result: {active: false}, + }, + { + create: {active: true}, + result: {active: true}, + }, + { + create: {cookieStoreId: null}, + result: {cookieStoreId: "firefox-default"}, + }, + { + create: {cookieStoreId: "firefox-container-1"}, + result: {cookieStoreId: "firefox-container-1"}, + }, + ]; + + async function nextTest() { + if (!tests.length) { + browser.test.notifyPass("tabs.create"); + return; + } + + const test = tests.shift(); + const expected = Object.assign({}, DEFAULTS, test.result); + + browser.test.log(`Testing tabs.create(${JSON.stringify(test.create)}), expecting ${JSON.stringify(test.result)}`); + + const updatedPromise = new Promise(resolve => { + const onUpdated = (changedTabId, changed) => { + // Loading an extension page causes two `about:blank` messages + // because of the process switch + if (changed.url && (expected.url == "about:blank" || changed.url != "about:blank")) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve({tabId: changedTabId, url: changed.url}); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + + const createdPromise = new Promise(resolve => { + const onCreated = tab => { + browser.test.assertTrue("id" in tab, `Expected tabs.onCreated callback to receive tab object`); + resolve(); + }; + browser.tabs.onCreated.addListener(onCreated); + }); + + const [tab] = await Promise.all([ + browser.tabs.create(test.create), + createdPromise, + ]); + const tabId = tab.id; + + for (const key of Object.keys(expected)) { + if (key === "url") { + // FIXME: This doesn't get updated until later in the load cycle. + continue; + } + + browser.test.assertEq(expected[key], tab[key], `Expected value for tab.${key}`); + } + + const updated = await updatedPromise; + browser.test.assertEq(tabId, updated.tabId, `Expected value for tab.id`); + browser.test.assertEq(expected.url, updated.url, `Expected value for tab.url`); + + await browser.tabs.remove(tabId); + await browser.tabs.update(activeTab, {active: true}); + + nextTest(); + } + + nextTest(); + } + + browser.tabs.query({active: true, currentWindow: true}, tabs => { + activeTab = tabs[0].id; + + runTests(); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.create"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html new file mode 100644 index 0000000000..cd708d942a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html @@ -0,0 +1,302 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs Events Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testTabEvents() { + async function background() { + const events = []; + let eventPromise; + const checkEvents = () => { + if (eventPromise && events.length >= eventPromise.names.length) { + eventPromise.resolve(); + } + }; + + browser.tabs.onCreated.addListener(tab => { + events.push({type: "onCreated", tab}); + checkEvents(); + }); + + browser.tabs.onAttached.addListener((tabId, info) => { + events.push(Object.assign({type: "onAttached", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onDetached.addListener((tabId, info) => { + events.push(Object.assign({type: "onDetached", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onRemoved.addListener((tabId, info) => { + events.push(Object.assign({type: "onRemoved", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onMoved.addListener((tabId, info) => { + events.push(Object.assign({type: "onMoved", tabId}, info)); + checkEvents(); + }); + + async function expectEvents(names) { + browser.test.log(`Expecting events: ${names.join(", ")}`); + + await new Promise(resolve => { + eventPromise = {names, resolve}; + checkEvents(); + }); + + browser.test.assertEq(names.length, events.length, "Got expected number of events"); + for (const [i, name] of names.entries()) { + browser.test.assertEq(name, i in events && events[i].type, + `Got expected ${name} event`); + } + return events.splice(0); + } + + try { + browser.test.log("Create tab"); + const tab = await browser.tabs.create({url: "about:blank"}); + const oldIndex = tab.index; + + const [created] = await expectEvents(["onCreated"]); + browser.test.assertEq(tab.id, created.tab.id, "Got expected tab ID"); + browser.test.assertEq(oldIndex, created.tab.index, "Got expected tab index"); + + + browser.test.log("Remove tab"); + await browser.tabs.remove(tab.id); + const [removed] = await expectEvents(["onRemoved"]); + + browser.test.assertEq(tab.id, removed.tabId, "Expected removed tab ID"); + browser.test.assertEq(tab.windowId, removed.windowId, "Expected removed tab window ID"); + // Note: We want to test for the actual boolean value false here. + browser.test.assertEq(false, removed.isWindowClosing, "Expected isWindowClosing value"); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabRemovalEvent() { + async function background() { + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + chrome.tabs.onRemoved.addListener((tabId, info) => { + browser.test.log("Make sure the removed tab is not available in the tabs.query callback."); + chrome.tabs.query({}, tabs => { + for (const tab of tabs) { + browser.test.assertTrue(tab.id != tabId, "Tab query should not include removed tabId"); + } + browser.test.notifyPass("tabs-events"); + }); + }); + + try { + const url = "http://example.com/mochitest/mobile/android/components/extensions/test/mochitest/context.html"; + const tab = await browser.tabs.create({url: url}); + await awaitLoad(tab.id); + + await browser.tabs.remove(tab.id); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabActivationEvent() { + // TODO bug 1565536: tabs.onActivated is not supported in GeckoView. + if (true) { + todo(false, "skipping testTabActivationEvent"); + return; + } + async function background() { + function makeExpectable() { + let expectation = null, resolver = null; + const expectable = param => { + if (expectation === null) { + browser.test.fail("unexpected call to expectable"); + } else { + try { + resolver(expectation(param)); + } catch (e) { + resolver(Promise.reject(e)); + } finally { + expectation = null; + } + } + }; + expectable.expect = e => { + expectation = e; + return new Promise(r => { resolver = r; }); + }; + return expectable; + } + try { + const listener = makeExpectable(); + browser.tabs.onActivated.addListener(listener); + + const [tab0] = await browser.tabs.query({active: true}); + const [, tab1] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab0.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.create({url: "about:blank"}), + ]); + const [, tab2] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.create({url: "about:blank"}), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.tabId, "Got expected tabId"); + browser.test.assertEq(tab2.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.update(tab1.id, {active: true}), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab2.id, info.tabId, "Got expected tabId"); + browser.test.assertEq(undefined, info.previousTabId, "previousTabId should not be defined when previous tab was closed"); + }), + browser.tabs.remove(tab1.id), + ]); + + browser.tabs.onActivated.removeListener(listener); + await browser.tabs.remove(tab2.id); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function test_tabs_event_page() { + function background() { + const EVENTS = [ + "onActivated", + "onRemoved", + "onUpdated", + ]; + browser.tabs.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + for (const event of EVENTS) { + browser.tabs[event].addListener(() => { + }); + } + browser.test.onMessage.addListener(async msg => { + if (msg === "createTab") { + await browser.tabs.create({url: "about:blank"}); + } + }); + browser.test.sendMessage("ready"); + } + + const apiEvents = ["onActivated", "onCreated", "onRemoved", "onUpdated"]; + const apiNs = "tabs"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@tabs" } }, + "permissions": ["tabs"], + background: { persistent: false }, + }, + background, + }); + + await extension.startup(); + info("Wait for event page to be started"); + await extension.awaitMessage("ready"); + // Sanity check + info("Wait for tabs.onCreated listener call"); + extension.sendMessage("createTab"); + await extension.awaitMessage("onCreated"); + + // on startup, all event listeners should not be primed + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + // when the extension is killed, all event listeners should be primed + info("Terminate event page"); + await extension.terminateBackground({ disableResetIdleForTest: true }); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: true }); + + // on start up again, all event listeners should not be primed + info("Wake up event page on a new tabs.onCreated event"); + const newWin = window.open(); + info("Wait for event page to be restarted"); + await extension.awaitMessage("ready"); + info("Wait for the primed tabs.onCreated to be received by the event page"); + await extension.awaitMessage("onCreated"); + await assertPersistentListeners(extension, apiNs, apiEvents, { primed: false }); + + await extension.unload(); + newWin.close(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html new file mode 100644 index 0000000000..09e42d73cf --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html @@ -0,0 +1,252 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScript() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_iframe_document.html"; + + const win = window.open(URL); + await new Promise(resolve => win.addEventListener("load", resolve, {once: true})); + + async function background() { + try { + const [tab] = await browser.tabs.query({active: true, currentWindow: true}); + const frames = await browser.webNavigation.getAllFrames({tabId: tab.id}); + + browser.test.log(`FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n`); + await Promise.all([ + browser.tabs.executeScript({ + code: "42", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(42, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + file: "script.js", + code: "42", + }).then(result => { + browser.test.fail("Expected not to be able to execute a script with both file and code"); + }, error => { + browser.test.assertTrue(/a 'code' or a 'file' property, but not both/.test(error.message), + "Got expected error"); + }), + + browser.tabs.executeScript({ + file: "script.js", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(undefined, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + file: "script2.js", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(27, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(2, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + matchAboutBlank: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(3, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + browser.test.assertEq("about:blank", result[2], "Thirds result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected callback result"); + browser.test.assertEq("string", typeof result[0], "Result is a string"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "Result is correct"); + }), + + browser.tabs.executeScript({ + code: "window", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + browser.test.assertEq("<anonymous code>", error.fileName, "Got expected fileName"); + browser.test.assertEq("Script '<anonymous code>' result is non-structured-clonable data", + error.message, "Got expected error"); + }), + + browser.tabs.executeScript({ + code: "Promise.resolve(window)", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + browser.test.assertEq("<anonymous code>", error.fileName, "Got expected fileName"); + browser.test.assertEq("Script '<anonymous code>' result is non-structured-clonable data", + error.message, "Got expected error"); + }), + + browser.tabs.executeScript({ + file: "script3.js", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + const expected = /Script '.*script3.js' result is non-structured-clonable data/; + browser.test.assertTrue(expected.test(error.message), "Got expected error"); + browser.test.assertTrue(error.fileName.endsWith("script3.js"), "Got expected fileName"); + }), + + browser.tabs.executeScript({ + frameId: Number.MAX_SAFE_INTEGER, + code: "42", + }).then(result => { + browser.test.fail("Expected error when specifying invalid frame ID"); + }, error => { + browser.test.assertEq( + `Invalid frame IDs: [${Number.MAX_SAFE_INTEGER}].`, + error.message, + "Got expected error" + ); + }), + + browser.tabs.create({url: "http://example.net/", active: false}).then(async tab => { + await browser.tabs.executeScript(tab.id, { + code: "42", + }).then(result => { + browser.test.fail("Expected error when trying to execute on invalid domain"); + }, error => { + browser.test.assertEq(`Missing host permission for the tab`, + error.message, "Got expected error"); + }); + + await browser.tabs.remove(tab.id); + }), + + browser.tabs.executeScript({ + code: "Promise.resolve(42)", + }).then(result => { + browser.test.assertEq(42, result[0], "Got expected promise resolution value as result"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(2, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + frameId: frames[0].frameId, + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), `Result for frameId[0] is correct: ${result[0]}`); + }), + + browser.tabs.executeScript({ + code: "location.href;", + frameId: frames[1].frameId, + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertEq("http://mochi.test:8888/", result[0], "Result for frameId[1] is correct"); + }), + + browser.tabs.create({url: "http://example.com/"}).then(async tab => { + const result = await browser.tabs.executeScript(tab.id, {code: "location.href"}); + + browser.test.assertEq("http://example.com/", result[0], "Script executed correctly in new tab"); + + await browser.tabs.remove(tab.id); + }), + + // This currently does not work on Android. + /* + browser.tabs.create({url: "about:blank"}).then(async tab => { + const result = await browser.tabs.executeScript(tab.id, {code: "location.href", matchAboutBlank: true}); + browser.test.assertEq("about:blank", result[0], "Script executed correctly in new tab"); + await browser.tabs.remove(tab.id); + }), + */ + + new Promise(resolve => { + browser.runtime.onMessage.addListener(message => { + browser.test.assertEq("script ran", message, "Expected runtime message"); + resolve(); + }); + }), + ]); + + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "http://example.com/", "webNavigation"], + }, + + background, + + files: { + "script.js": function() { + browser.runtime.sendMessage("script ran"); + }, + + "script2.js": "27", + + "script3.js": "window", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html new file mode 100644 index 0000000000..da645ef738 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html @@ -0,0 +1,151 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Bad Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function* testHasNoPermission(params) { + const contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "execute-script"); + + await browser.test.assertRejects(browser.tabs.executeScript({ + file: "script.js", + }), /Missing host permission for the tab/); + + browser.test.notifyPass("executeScript"); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "script.js": function() { + browser.runtime.sendMessage("first script ran"); + }, + }, + }); + + yield extension.startup(); + yield extension.awaitMessage("ready"); + + if (params.setup) { + yield params.setup(extension); + } + + extension.sendMessage("execute-script"); + + yield extension.awaitFinish("executeScript"); + yield extension.unload(); +} + +add_task(async function testBadPermissions() { + const win = window.open("http://mochi.test:8888/"); + + await new Promise(resolve => setTimeout(resolve, 0)); + + info("Test no special permissions"); + await testHasNoPermission({ + manifest: {"permissions": []}, + }); + + info("Test tabs permissions"); + await testHasNoPermission({ + manifest: {"permissions": ["tabs"]}, + }); + + win.close(); +}); + +add_task(async function testBadURL() { + async function background() { + const promises = [ + new Promise(resolve => { + browser.tabs.executeScript({ + file: "http://example.com/script.js", + }, result => { + browser.test.assertEq(undefined, result, "Result value"); + + browser.test.assertTrue(browser.runtime.lastError instanceof Error, + "runtime.lastError is Error"); + + browser.test.assertTrue(browser.runtime.lastError instanceof Error, + "runtime.lastError is Error"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value"); + + resolve(); + }); + }), + + browser.tabs.executeScript({ + file: "http://example.com/script.js", + }).catch(error => { + browser.test.assertTrue(error instanceof Error, "Error is Error"); + + browser.test.assertEq(null, browser.runtime.lastError, + "runtime.lastError value"); + + browser.test.assertEq(null, browser.runtime.lastError, + "runtime.lastError value"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + error && error.message, + "error value"); + }), + ]; + + await Promise.all(promises); + + browser.test.notifyPass("executeScript-lastError"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["<all_urls>"], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-lastError"); + + await extension.unload(); +}); + +// TODO bug 1435100: Test that |executeScript| fails if the tab has navigated +// to a new page, and no longer matches our expected state. This involves +// intentionally trying to trigger a race condition. +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html new file mode 100644 index 0000000000..fa25568619 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript noCreate Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScriptAtOnUpdated() { + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_iframe_document.html"; + // This is a regression test for bug 1325830. + // The bug (executeScript not completing any more) occurred when executeScript + // was called early at the onUpdated event, unless the tabs.create method is + // called. So this test does not use tabs.create to open new tabs. + // Note that if this test is run together with other tests that do call + // tabs.create, then this test case does not properly test the conditions of + // the regression any more. To verify that the regression has been resolved, + // this test must be run in isolation. + + function background() { + // Using variables to prevent listeners from running more than once, instead + // of removing the listener. This is to minimize any IPC, since the bug that + // is being tested is sensitive to timing. + let ignore = false; + let url; + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (url && changeInfo.status === "loading" && tab.url === url && !ignore) { + ignore = true; + browser.tabs.executeScript(tabId, { + code: "document.URL", + }).then(results => { + browser.test.assertEq(url, results[0], "Content script should run"); + browser.test.notifyPass("executeScript-at-onUpdated"); + }, error => { + browser.test.fail(`Unexpected error: ${error} :: ${error.stack}`); + browser.test.notifyFail("executeScript-at-onUpdated"); + }); + // (running this log call after executeScript to minimize IPC between + // onUpdated and executeScript.) + browser.test.log(`Found expected navigation to ${url}`); + } else { + // The bug occurs when executeScript is called before a tab is + // initialized. + browser.tabs.executeScript(tabId, {code: ""}); + } + }); + browser.test.onMessage.addListener(testUrl => { + url = testUrl; + browser.test.sendMessage("open-test-tab"); + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "tabs"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage(URL); + await extension.awaitMessage("open-test-tab"); + + const tab = window.open(URL); + await extension.awaitFinish("executeScript-at-onUpdated"); + + await extension.unload(); + + tab.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html new file mode 100644 index 0000000000..2e82320f8c --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html @@ -0,0 +1,128 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript runAt Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/** + * These tests ensure that the runAt argument to tabs.executeScript delays + * script execution until the document has reached the correct state. + * + * Since tests of this nature are especially race-prone, it relies on a + * server-JS script to delay the completion of our test page's load cycle long + * enough for us to attempt to load our scripts in the earlies phase we support. + * + * And since we can't actually rely on that timing, it retries any attempts that + * fail to load as early as expected, but don't load at any illegal time. + */ + +add_task(async function testExecuteScript() { + const win = window.open("about:blank"); + + async function background(DEBUG) { + let tab; + + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_slowed_document.sjs"; + + const MAX_TRIES = 30; + + const onUpdatedPromise = (tabId, url, status) => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(_, changed, tab) { + if (tabId == tab.id && changed.status == status && tab.url == url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + [tab] = await browser.tabs.query({active: true, currentWindow: true}); + + let success = false; + for (let tries = 0; !success && tries < MAX_TRIES; tries++) { + const url = `${URL}?with-iframe&r=${Math.random()}`; + + const loadingPromise = onUpdatedPromise(tab.id, url, "loading"); + const completePromise = onUpdatedPromise(tab.id, url, "complete"); + + // TODO: Test allFrames and frameId. + + await browser.tabs.update({url}); + await loadingPromise; + + const states = await Promise.all([ + // Send the executeScript requests in the reverse order that we expect + // them to execute in, to avoid them passing only because of timing + // races. + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_idle", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_end", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_start", + }), + ].reverse()); + + browser.test.log(`Got states: ${states}`); + + // Make sure that none of our scripts executed earlier than expected, + // regardless of retries. + browser.test.assertTrue(states[1] == "interactive" || states[1] == "complete", + `document_end state is valid: ${states[1]}`); + browser.test.assertTrue(states[2] == "interactive" || states[2] == "complete", + `document_idle state is valid: ${states[2]}`); + + // If we have the earliest valid states for each script, we're done. + // Otherwise, try again. + success = ((states[0] == "loading" || DEBUG) && + states[1] == "interactive" && + (states[2] == "interactive" || states[2] == "complete")); + + await completePromise; + } + + browser.test.assertTrue(success, "Got the earliest expected states at least once"); + + browser.test.notifyPass("executeScript-runAt"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-runAt"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "tabs"], + }, + background: `(${background})(${AppConstants.DEBUG})`, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-runAt"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html new file mode 100644 index 0000000000..109ab8f65c --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html @@ -0,0 +1,36 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs get Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + const tab1 = await browser.tabs.create({}); + const tab2 = await browser.tabs.create({}); + browser.test.assertEq(tab1.id, (await browser.tabs.get(tab1.id)).id, "tabs.get should return tab with given id"); + browser.test.assertEq(tab2.id, (await browser.tabs.get(tab2.id)).id, "tabs.get should return tab with given id"); + await browser.tabs.remove(tab1.id); + await browser.tabs.remove(tab2.id); + browser.test.notifyPass("tabs.get"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.get"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html new file mode 100644 index 0000000000..c32f93f44a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs getCurrent Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + files: { + "tab.js": function() { + const url = document.location.href; + + browser.tabs.getCurrent().then(currentTab => { + browser.test.assertEq(currentTab.url, url, "getCurrent in non-active background tab"); + + // Activate the tab. + browser.tabs.onActivated.addListener(function listener({tabId}) { + if (tabId == currentTab.id) { + browser.tabs.onActivated.removeListener(listener); + + browser.tabs.getCurrent().then(currentTab => { + browser.test.assertEq(currentTab.id, tabId, "in active background tab"); + browser.test.assertEq(currentTab.url, url, "getCurrent in non-active background tab"); + + browser.test.sendMessage("tab-finished"); + }); + } + }); + browser.tabs.update(currentTab.id, {active: true}); + }); + }, + + "tab.html": `<head><meta charset="utf-8"><script src="tab.js"><\/script></head>`, + }, + + background: function() { + browser.tabs.getCurrent().then(tab => { + browser.test.assertEq(tab, undefined, "getCurrent in background script"); + browser.test.sendMessage("background-finished"); + }); + + browser.tabs.create({url: "tab.html", active: false}); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-finished"); + await extension.awaitMessage("tab-finished"); + + // The extension tab is automatically closed when the extension unloads. + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html new file mode 100644 index 0000000000..0d143e2ac6 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html @@ -0,0 +1,134 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Tabs goBack and goForward Test</title> + <script + type="text/javascript" + src="/tests/SimpleTest/SimpleTest.js" + ></script> + <script + type="text/javascript" + src="/tests/SimpleTest/ExtensionTestUtils.js" + ></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> + </head> + <body> + <script type="text/javascript"> + "use strict"; + + add_task(async function test_tabs_goBack_goForward() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab1.html": `<head> + <meta charset=UTF-8"> + <title>tab1</title> + </head>`, + "tab2.html": `<head> + <meta charset=UTF-8"> + <title>tab2</title> + </head>`, + }, + + async background() { + let tabUpdatedCount = 0; + let tab = {}; + + browser.tabs.onUpdated.addListener( + async (tabId, changeInfo, tabInfo) => { + if ( + changeInfo.status !== "complete" || + tabId !== tab.id || + tabInfo.url === "about:blank" + ) { + return; + } + + tabUpdatedCount++; + switch (tabUpdatedCount) { + case 1: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab2.html" }); + break; + + case 2: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab1.html" }); + break; + + case 3: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.goBack(); + break; + + case 4: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating backward with empty parameter" + ); + browser.tabs.goBack(tabId); + break; + + case 5: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating backward with tabId as parameter" + ); + browser.tabs.goForward(); + break; + + case 6: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating forward with empty parameter" + ); + browser.tabs.goForward(tabId); + break; + + case 7: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating forward with tabId as parameter" + ); + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.goBack.goForward"); + break; + + default: + break; + } + } + ); + + tab = await browser.tabs.create({ url: "tab1.html", active: true }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.goBack.goForward"); + await extension.unload(); + }); + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html new file mode 100644 index 0000000000..718c2d6de4 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html @@ -0,0 +1,124 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScript() { + const win = window.open("http://mochi.test:8888/"); + + async function background() { + const tasks = [ + { + description: "CSS as moz-extension:// url", + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + { + description: "CSS as code snippet string", + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + { + description: "last of two author CSS wins", + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: async () => { + await browser.tabs.insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "author", + }); + await browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) !important }", + cssOrigin: "author", + }); + }, + }, + { + description: "user CSS has higher priority", + background: "rgb(100, 100, 100)", + foreground: "rgb(0, 113, 4)", + promise: async () => { + // User has higher importance + await browser.tabs.insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "user", + }); + await browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) !important }", + cssOrigin: "author", + }); + }, + }, + ]; + + function checkCSS() { + const computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (const {background, description, foreground, promise} of tasks) { + browser.test.log(`Run test case: ${description}`); + let result = await promise(); + + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + + browser.test.assertEq(background, result[0], "Expected background color"); + browser.test.assertEq(foreground, result[1], "Expected foreground color"); + } + + browser.test.notifyPass("insertCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("insertCSS"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html new file mode 100644 index 0000000000..5bb44ab645 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html @@ -0,0 +1,64 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs lastAccessed Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testLastAccessed() { + const past = Date.now(); + + window.open("https://example.com/?1"); + window.open("https://example.com/?2"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async function(msg, past) { + if (msg !== "past") { + return; + } + + const [tab1] = await browser.tabs.query({url: "https://example.com/?1"}); + const [tab2] = await browser.tabs.query({url: "https://example.com/?2"}); + + browser.test.assertTrue(tab1 && tab2, "Expected tabs were found"); + + const now = Date.now(); + + browser.test.assertTrue(typeof tab1.lastAccessed == "number", + "tab1 lastAccessed should be a number"); + + browser.test.assertTrue(typeof tab2.lastAccessed == "number", + "tab2 lastAccessed should be a number"); + + browser.test.assertTrue(past <= tab1.lastAccessed && + tab1.lastAccessed <= tab2.lastAccessed && + tab2.lastAccessed <= now, + "lastAccessed timestamps are recent and in the right order"); + + await browser.tabs.remove([tab1.id, tab2.id]); + + browser.test.notifyPass("tabs.lastAccessed"); + }); + }, + }); + + await extension.startup(); + await extension.sendMessage("past", past); + await extension.awaitFinish("tabs.lastAccessed"); + await extension.unload(); +}); +</script> + +</body> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html new file mode 100644 index 0000000000..8d96e79cc2 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html @@ -0,0 +1,187 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs onUpdated Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_onUpdated() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/context_tabs_onUpdated_page.html"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: function() { + const pageURL = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + + const expectedSequence = [ + {status: "loading"}, + {status: "loading", url: pageURL}, + {status: "complete"}, + ]; + + const collectedSequence = []; + + let tabId; + browser.tabs.onUpdated.addListener(function(tabId, updatedInfo) { + // onUpdated also fires with updatedInfo.faviconUrl, so explicitly + // check for updatedInfo.status before recording the event. + if ("status" in updatedInfo) { + collectedSequence.push(updatedInfo); + } + }); + + browser.runtime.onMessage.addListener(async msg => { + if (collectedSequence.length !== expectedSequence.length) { + browser.test.assertEq( + JSON.stringify(expectedSequence), + JSON.stringify(collectedSequence), + "got unexpected number of updateInfo data" + ); + } else { + for (let i = 0; i < expectedSequence.length; i++) { + browser.test.assertEq( + expectedSequence[i].status, + collectedSequence[i].status, + "check updatedInfo status" + ); + if (expectedSequence[i].url || collectedSequence[i].url) { + browser.test.assertEq( + expectedSequence[i].url, + collectedSequence[i].url, + "check updatedInfo url" + ); + } + } + } + + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.onUpdated"); + }); + + browser.tabs.create({url: pageURL}).then(tab => { + tabId = tab.id; + }); + }, + files: { + "content-script.js": ` + window.addEventListener("message", function(evt) { + if (evt.data == "frame-updated") { + browser.runtime.sendMessage("load-completed"); + } + }, true); + `, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitFinish("tabs.onUpdated"), + ]); + + await extension.unload(); +}); + +async function do_test_update(background, withPermissions = true) { + const manifest = {}; + if (withPermissions) { + manifest.permissions = ["tabs", "http://mochi.test/"]; + } + const extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + }); + + await extension.startup(); + await extension.awaitFinish("finish"); + + await extension.unload(); +} + +add_task(async function test_url() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({}, function(tab) { + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("url" in changeInfo) { + browser.test.assertEq("about:blank", changeInfo.url, + "Check changeInfo.url"); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, {url: "about:blank"}); + }); + }); +}); + +add_task(async function test_title() { + await do_test_update(async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({url}); + + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + if ("title" in changeInfo && changeInfo.title === "New Message (1)") { + browser.test.log("changeInfo.title is correct"); + browser.tabs.onUpdated.removeListener(onUpdated); + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + + browser.tabs.executeScript(tab.id, {code: "document.title = 'New Message (1)'"}); + }); +}); + +add_task(async function test_without_tabs_permission() { + await do_test_update(async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({url}); + let count = 0; + + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + + browser.test.assertFalse("url" in changeInfo, "url should not be included without tabs permission"); + browser.test.assertFalse("favIconUrl" in changeInfo, "favIconUrl should not be included without tabs permission"); + browser.test.assertFalse("title" in changeInfo, "title should not be included without tabs permission"); + + if (changeInfo.status == "complete") { + count++; + if (count === 2) { + browser.test.log("Reload complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + } + }); + + browser.tabs.reload(tab.id); + }, false /* withPermissions */); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html new file mode 100644 index 0000000000..9a907f47de --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_query_index() { + const extension = ExtensionTestUtils.loadExtension({ + background: function() { + browser.tabs.onCreated.addListener(async function({index, windowId, id}) { + browser.test.assertThrows( + () => browser.tabs.query({index: -1}), + /-1 is too small \(must be at least 0\)/, + "tab indices must be non-negative"); + + let tabs = await browser.tabs.query({index, windowId}); + browser.test.assertEq(tabs.length, 1, `Got one tab at index ${index}`); + browser.test.assertEq(tabs[0].id, id, "The tab is the right one"); + + tabs = await browser.tabs.query({index: 1e5, windowId}); + browser.test.assertEq(tabs.length, 0, "There is no tab at this index"); + + browser.test.notifyPass("tabs.query"); + }); + }, + }); + + await extension.startup(); + const win = window.open("http://example.com"); + await extension.awaitFinish("tabs.query"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html new file mode 100644 index 0000000000..30379f02a1 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html @@ -0,0 +1,66 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs reload Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + files: { + "tab.js": function() { + browser.runtime.sendMessage("tab-loaded"); + }, + "tab.html": + `<head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head>`, + }, + + async background() { + let tabLoadedCount = 0; + // eslint-disable-next-line prefer-const + let tab; + + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-loaded") { + tabLoadedCount++; + + if (tabLoadedCount == 1) { + // Reload the tab once passing no arguments. + return browser.tabs.reload(); + } + + if (tabLoadedCount == 2) { + // Reload the tab again with explicit arguments. + return browser.tabs.reload(tab.id, { + bypassCache: false, + }); + } + + if (tabLoadedCount == 3) { + browser.test.notifyPass("tabs.reload"); + } + } + }); + tab = await browser.tabs.create({url: "tab.html", active: true}); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html new file mode 100644 index 0000000000..87f90ad855 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript bypassCache Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs", "<all_urls>"], + }, + + async background() { + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_bypass_cache.sjs"; + + let tabId = null; + let loadPromise, resolveLoad; + function resetLoad() { + loadPromise = new Promise(resolve => { + resolveLoad = resolve; + }); + } + function awaitLoad() { + return loadPromise.then(() => { + resetLoad(); + }); + } + resetLoad(); + + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete" && tab.url == URL) { + resolveLoad(); + } + }); + + try { + const tab = await browser.tabs.create({url: URL}); + tabId = tab.id; + await awaitLoad(); + + await browser.tabs.reload(tab.id, {bypassCache: false}); + await awaitLoad(); + + let [textContent] = await browser.tabs.executeScript(tab.id, {code: "document.body.textContent"}); + browser.test.assertEq("", textContent, "`textContent` should be empty when bypassCache=false"); + + await browser.tabs.reload(tab.id, {bypassCache: true}); + await awaitLoad(); + + [textContent] = await browser.tabs.executeScript(tab.id, {code: "document.body.textContent"}); + + const [pragma, cacheControl] = textContent.split(":"); + browser.test.assertEq("no-cache", pragma, "`pragma` should be set to `no-cache` when bypassCache is true"); + browser.test.assertEq("no-cache", cacheControl, "`cacheControl` should be set to `no-cache` when bypassCache is true"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.reload_bypass_cache"); + } catch (error) { + browser.test.fail(`${error} :: ${error.stack}`); + browser.test.notifyFail("tabs.reload_bypass_cache"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload_bypass_cache"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html new file mode 100644 index 0000000000..320ce4dde6 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html @@ -0,0 +1,277 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs sendMessage Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function tabsSendMessageReply() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://example.com/"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: async function() { + // eslint-disable-next-line prefer-const + let firstTab; + const promiseResponse = new Promise(resolve => { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "content-script-ready") { + const tabId = sender.tab.id; + + Promise.all([ + promiseResponse, + + browser.tabs.sendMessage(tabId, "respond-now"), + browser.tabs.sendMessage(tabId, "respond-now-2"), + new Promise(resolve => browser.tabs.sendMessage(tabId, "respond-soon", resolve)), + browser.tabs.sendMessage(tabId, "respond-promise"), + browser.tabs.sendMessage(tabId, "respond-promise-false"), + browser.tabs.sendMessage(tabId, "respond-false"), + browser.tabs.sendMessage(tabId, "respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { resolve(response); }); + }), + + browser.tabs.sendMessage(tabId, "respond-error").catch(error => Promise.resolve({error})), + browser.tabs.sendMessage(tabId, "throw-error").catch(error => Promise.resolve({error})), + + browser.tabs.sendMessage(tabId, "respond-uncloneable").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "reject-uncloneable").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "reject-undefined").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "throw-undefined").catch(error => Promise.resolve({ error })), + + browser.tabs.sendMessage(firstTab, "no-listener").catch(error => Promise.resolve({error})), + ]).then(([response, respondNow, respondNow2, respondSoon, respondPromise, respondPromiseFalse, respondFalse, respondNever, respondNever2, respondError, throwError, respondUncloneable, rejectUncloneable, rejectUndefined, throwUndefined, noListener]) => { + browser.test.assertEq("expected-response", response, "Content script got the expected response"); + + browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response"); + browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener"); + browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response"); + browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response"); + browser.test.assertEq(false, respondPromiseFalse, "Got the expected false value as a promise result"); + browser.test.assertEq(undefined, respondFalse, "Got the expected no-response when onMessage returns false"); + browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution"); + browser.test.assertEq(undefined, respondNever2, "Got the expected no-response resolution"); + + browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response"); + browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response"); + + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", respondUncloneable.error.message, "An uncloneable response should be ignored"); + browser.test.assertEq("An unexpected error occurred", rejectUncloneable.error.message, "Got the expected error for a rejection with an uncloneable value"); + browser.test.assertEq("An unexpected error occurred", rejectUndefined.error.message, "Got the expected error for a void rejection"); + browser.test.assertEq("An unexpected error occurred", throwUndefined.error.message, "Got the expected error for a void throw"); + + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", + noListener.error.message, + "Got the expected no listener response"); + + return browser.tabs.remove(tabId); + }).then(() => { + browser.test.notifyPass("sendMessage"); + }); + + return Promise.resolve("expected-response"); + } else if (msg[0] == "got-response") { + resolve(msg[1]); + } + }); + }); + + const tabs = await browser.tabs.query({currentWindow: true, active: true}); + firstTab = tabs[0].id; + browser.tabs.create({url: "http://example.com/"}); + }, + + files: { + "content-script.js": async function() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { respond(msg); }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } else if (msg == "throw-error") { + throw new Error(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + const response = await browser.runtime.sendMessage("content-script-ready"); + browser.runtime.sendMessage(["got-response", response]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("sendMessage"); + + await extension.unload(); +}); + + +add_task(async function tabsSendHidden() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://example.com/content*"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: async function() { + let resolveContent; + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg[0] == "content-ready") { + resolveContent(msg[1]); + } + }); + + const awaitContent = url => { + return new Promise(resolve => { + resolveContent = resolve; + }).then(result => { + browser.test.assertEq(url, result, "Expected content script URL"); + }); + }; + + try { + const URL1 = "http://example.com/content1.html"; + const URL2 = "http://example.com/content2.html"; + + const tab = await browser.tabs.create({url: URL1}); + await awaitContent(URL1); + + let url = await browser.tabs.sendMessage(tab.id, URL1); + browser.test.assertEq(URL1, url, "Should get response from expected content window"); + + await browser.tabs.update(tab.id, {url: URL2}); + await awaitContent(URL2); + + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq(URL2, url, "Should get response from expected content window"); + + // Repeat once just to be sure the first message was processed by all + // listeners before we exit the test. + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq(URL2, url, "Should get response from expected content window"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("contentscript-bfcache-window"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("contentscript-bfcache-window"); + } + }, + + files: { + "content-script.js": function() { + // Store this in a local variable to make sure we don't touch any + // properties of the possibly-hidden content window. + const href = window.location.href; + + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq(href, msg, "Should be in the expected content window"); + + return Promise.resolve(href); + }); + + browser.runtime.sendMessage(["content-ready", href]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("contentscript-bfcache-window"); + + await extension.unload(); +}); + + +add_task(async function tabsSendMessageNoExceptionOnNonExistentTab() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + async background() { + const url = "http://example.com/mochitest/tests/mobile/android/components/extensions/test/mochitest/file_dummy.html"; + const tab = await browser.tabs.create({url}); + + try { + browser.tabs.sendMessage(tab.id, "message"); + browser.tabs.sendMessage(tab.id + 100, "message"); + } catch (e) { + browser.test.fail("no exception should be raised on tabs.sendMessage to nonexistent tabs"); + } + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.sendMessage"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.sendMessage"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html new file mode 100644 index 0000000000..9332efd516 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html @@ -0,0 +1,125 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs update Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>tab page</h1> + </body> + </html> + `.trim(), + }, + background: function() { + browser.test.sendMessage("ready", browser.runtime.getURL("tab.html")); + + browser.test.onMessage.addListener(async (msg, tabsUpdateURL, isErrorExpected) => { + const tabs = await browser.tabs.query({lastFocusedWindow: true}); + + try { + const tab = await browser.tabs.update(tabs[0].id, {url: tabsUpdateURL}); + + browser.test.assertFalse(isErrorExpected, `tabs.update with URL ${tabsUpdateURL} should be rejected`); + browser.test.assertTrue(tab, "on success the tab should be defined"); + } catch (error) { + browser.test.assertTrue(isErrorExpected, `tabs.update with URL ${tabsUpdateURL} should not be rejected`); + browser.test.assertTrue(/^Illegal URL/.test(error.message), + "tabs.update should be rejected with the expected error message"); + } + + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + + const mozExtTabURL = await extension.awaitMessage("ready"); + + if (tabsUpdateURL == "self") { + tabsUpdateURL = mozExtTabURL; + } + + info(`tab.update URL "${tabsUpdateURL}" on tab with URL "${existentTabURL}"`); + + const tab1 = window.open(existentTabURL); + + extension.sendMessage("start", tabsUpdateURL, isErrorExpected); + await extension.awaitMessage("done"); + + tab1.close(); + await extension.unload(); +} + +add_task(async function() { + info("Start testing tabs.update on javascript URLs"); + + const dataURLPage = `data:text/html, + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>data url page</h1> + </body> + </html>`; + + const checkList = [ + { + tabsUpdateURL: "http://example.net", + isErrorExpected: false, + }, + { + tabsUpdateURL: "self", + isErrorExpected: false, + }, + { + tabsUpdateURL: "about:addons", + isErrorExpected: true, + }, + { + tabsUpdateURL: "javascript:console.log('tabs.update execute javascript')", + isErrorExpected: true, + }, + { + tabsUpdateURL: dataURLPage, + isErrorExpected: true, + }, + ]; + + const testCases = checkList + .map((check) => Object.assign({}, check, {existentTabURL: "about:blank"})); + + for (const {existentTabURL, tabsUpdateURL, isErrorExpected} of testCases) { + await testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected); + } + + info("done"); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html b/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html new file mode 100644 index 0000000000..33f178492d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>WebNavigation onCommitted Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation", "tabs"], + }, + async background() { + const url = "http://mochi.test:8888/"; + const [tab, tabDetails] = await Promise.all([ + browser.tabs.create({url}), + new Promise(resolve => { + browser.webNavigation.onCommitted.addListener(details => { + if (details.url === "about:blank") { + // skip initial about:blank + return; + } + resolve(details); + }); + }), + ]); + + browser.test.assertEq(url, tabDetails.url, "webNavigation.onCommitted detects correct url"); + browser.test.assertEq(tab.id, tabDetails.tabId, "webNavigation.onCommitted fire for proper tabId"); + await browser.tabs.remove(tab.id); + browser.test.notifyPass("webNavigation.onCommitted"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("webNavigation.onCommitted"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/xpcshell/.eslintrc.js b/mobile/android/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..2e6d214f4b --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + extends: + "../../../../../../toolkit/components/extensions/test/xpcshell/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/test/xpcshell/head.js b/mobile/android/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..e79781fba6 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/head.js @@ -0,0 +1,24 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + ExtensionTestUtils: + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs", +}); + +// Remove this pref once bug 1535365 is fixed. +Services.prefs.setBoolPref("extensions.webextensions.remote", false); + +// https_first automatically upgrades http to https, but the tests are not +// designed to expect that. And it is not easy to change that because +// nsHttpServer does not support https (bug 1742061). So disable https_first. +Services.prefs.setBoolPref("dom.security.https_first", false); + +ExtensionTestUtils.init(this); + +Services.io.offline = true; + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; diff --git a/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_geckoview.js b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_geckoview.js new file mode 100644 index 0000000000..3ba2e26139 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_geckoview.js @@ -0,0 +1,424 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +ChromeUtils.defineESModuleGetters(this, { + GeckoViewConnection: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +// Save reference to original implementations to restore later. +const { sendMessage, onConnect } = GeckoViewConnection.prototype; +add_setup(async () => { + // This file replaces the implementation of GeckoViewConnection; + // make sure that it is restored upon test completion. + registerCleanupFunction(() => { + GeckoViewConnection.prototype.sendMessage = sendMessage; + GeckoViewConnection.prototype.onConnect = onConnect; + }); +}); + +// Mock the embedder communication port +class EmbedderPort { + constructor(portId, messenger) { + this.id = portId; + this.messenger = messenger; + } + close() { + Assert.ok(false, "close not expected to be called"); + } + onPortDisconnect() { + Assert.ok(false, "onPortDisconnect not expected to be called"); + } + onPortMessage(holder) { + Assert.ok(false, "onPortMessage not expected to be called"); + } + triggerPortDisconnect() { + this.messenger.sendPortDisconnect(this.id); + } +} + +function stubConnectNative() { + let port; + const firstCallPromise = new Promise(resolve => { + let callCount = 0; + GeckoViewConnection.prototype.onConnect = (portId, messenger) => { + Assert.equal(++callCount, 1, "onConnect called once"); + port = new EmbedderPort(portId, messenger); + resolve(); + return port; + }; + }); + const triggerPortDisconnect = () => { + if (!port) { + Assert.ok(false, "Undefined port, connection must be established first"); + } + port.triggerPortDisconnect(); + }; + const restore = () => { + GeckoViewConnection.prototype.onConnect = onConnect; + }; + return { firstCallPromise, triggerPortDisconnect, restore }; +} + +function stubSendNativeMessage() { + let sendResponse; + const returnPromise = new Promise(resolve => { + sendResponse = resolve; + }); + const firstCallPromise = new Promise(resolve => { + let callCount = 0; + GeckoViewConnection.prototype.sendMessage = data => { + Assert.equal(++callCount, 1, "sendMessage called once"); + resolve(data); + return returnPromise; + }; + }); + const restore = () => { + GeckoViewConnection.prototype.sendMessage = sendMessage; + }; + return { firstCallPromise, sendResponse, restore }; +} + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, (...args) => resolve(args)); + }); +} + +// verify that when background sends a native message, +// the background will not be terminated to allow native messaging +add_task(async function test_sendNativeMessage_event_page() { + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["geckoViewAddons", "nativeMessaging"], + background: { persistent: false }, + }, + async background() { + const res = await browser.runtime.sendNativeMessage("fake", "msg"); + browser.test.assertEq("myResp", res, "expected response"); + browser.test.sendMessage("done"); + browser.runtime.onSuspend.addListener(async () => { + browser.test.assertFail("unexpected onSuspend"); + }); + }, + }); + + const stub = stubSendNativeMessage(); + await extension.startup(); + info("Wait for sendNativeMessage to be received"); + Assert.equal( + (await stub.firstCallPromise).deserialize({}), + "msg", + "expected message" + ); + + info("Trigger background script idle timeout and expect to be reset"); + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + + stub.sendResponse("myResp"); + + info("Wait for extension to verify sendNativeMessage response"); + await extension.awaitMessage("done"); + await extension.unload(); + + stub.restore(); +}); + +// verify that when an extension tab sends a native message, +// the background will terminate as expected +add_task(async function test_sendNativeMessage_tab() { + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["geckoViewAddons", "nativeMessaging"], + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(async () => { + browser.test.sendMessage("onSuspend_called"); + }); + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="tab.js"></script> + `, + "tab.js": async () => { + const res = await browser.runtime.sendNativeMessage("fake", "msg"); + browser.test.assertEq("myResp", res, "expected response"); + browser.test.sendMessage("content_done"); + }, + }, + }); + + const stub = stubSendNativeMessage(); + await extension.startup(); + + const tab = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tab`, + { extension } + ); + + info("Wait for sendNativeMessage to be received"); + Assert.equal( + (await stub.firstCallPromise).deserialize({}), + "msg", + "expected message" + ); + + info("Terminate extension"); + await extension.terminateBackground(); + await extension.awaitMessage("onSuspend_called"); + + stub.sendResponse("myResp"); + + info("Wait for extension to verify sendNativeMessage response"); + await extension.awaitMessage("content_done"); + await tab.close(); + await extension.unload(); + + stub.restore(); +}); + +// verify that when a content script sends a native message, +// the background will terminate as expected +add_task(async function test_sendNativeMessage_content_script() { + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent", + ], + background: { persistent: false }, + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/"], + }, + ], + }, + files: { + "test.js": async () => { + const res = await browser.runtime.sendNativeMessage("fake", "msg"); + browser.test.assertEq("myResp", res, "expected response"); + browser.test.sendMessage("content_done"); + }, + }, + async background() { + browser.runtime.onSuspend.addListener(async () => { + browser.test.sendMessage("onSuspend_called"); + }); + }, + }); + + const stub = stubSendNativeMessage(); + await extension.startup(); + + info("Load content page"); + const page = await ExtensionTestUtils.loadContentPage("http://example.com/"); + + info("Wait for message from extension"); + Assert.equal( + (await stub.firstCallPromise).deserialize({}), + "msg", + "expected message" + ); + + info("Terminate extension"); + await extension.terminateBackground(); + await extension.awaitMessage("onSuspend_called"); + + stub.sendResponse("myResp"); + + info("Wait for extension to verify sendNativeMessage response"); + await extension.awaitMessage("content_done"); + await page.close(); + await extension.unload(); + + stub.restore(); +}); + +// verify that when native messaging ports are open, the background will not be terminated +// and once the ports disconnect, onSuspend can be called +add_task(async function test_connectNative_event_page() { + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["geckoViewAddons", "nativeMessaging"], + background: { persistent: false }, + }, + async background() { + const port = browser.runtime.connectNative("test"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + + browser.runtime.onSuspend.addListener(async () => { + browser.test.sendMessage("onSuspend_called"); + }); + }, + }); + + const stub = stubConnectNative(); + await extension.startup(); + info("Waiting for connectNative request"); + await stub.firstCallPromise; + + info("Trigger background script idle timeout and expect to be reset"); + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + + info("Trigger port disconnect, terminate background, and expect onSuspend()"); + stub.triggerPortDisconnect(); + await extension.awaitMessage("port_disconnected"); + + info("Terminate extension"); + await extension.terminateBackground(); + await extension.awaitMessage("onSuspend_called"); + + await extension.unload(); + stub.restore(); +}); + +// verify that when an extension tab opens native messaging ports, +// the background will terminate as expected +add_task(async function test_connectNative_tab() { + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["geckoViewAddons", "nativeMessaging"], + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(async () => { + browser.test.sendMessage("onSuspend_called"); + }); + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="tab.js"></script> + `, + "tab.js": async () => { + const port = browser.runtime.connectNative("test"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + browser.test.sendMessage("content_done"); + }, + }, + }); + + const stub = stubConnectNative(); + await extension.startup(); + + const tab = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tab`, + { extension } + ); + await extension.awaitMessage("content_done"); + await stub.firstCallPromise; + + info("Terminate extension"); + await extension.terminateBackground(); + await extension.awaitMessage("onSuspend_called"); + + stub.triggerPortDisconnect(); + await extension.awaitMessage("port_disconnected"); + await tab.close(); + await extension.unload(); + + stub.restore(); +}); + +// verify that when a content script opens native messaging ports, +// the background will terminate as expected +add_task(async function test_connectNative_content_script() { + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent", + ], + background: { persistent: false }, + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/"], + }, + ], + }, + files: { + "test.js": async () => { + const port = browser.runtime.connectNative("test"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + browser.test.sendMessage("content_done"); + }, + }, + async background() { + browser.runtime.onSuspend.addListener(async () => { + browser.test.sendMessage("onSuspend_called"); + }); + }, + }); + + const stub = stubConnectNative(); + await extension.startup(); + + info("Load content page"); + const page = await ExtensionTestUtils.loadContentPage("http://example.com/"); + await extension.awaitMessage("content_done"); + await stub.firstCallPromise; + + info("Terminate extension"); + await extension.terminateBackground(); + await extension.awaitMessage("onSuspend_called"); + + stub.triggerPortDisconnect(); + await extension.awaitMessage("port_disconnected"); + await page.close(); + await extension.unload(); + + stub.restore(); +}); diff --git a/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js new file mode 100644 index 0000000000..63f64b487e --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js @@ -0,0 +1,167 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dum", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +async function testNativeMessaging({ + isPrivileged = false, + permissions, + testBackground, + testContent, +}) { + async function runTest(testFn, completionMessage) { + try { + dump(`Running test before sending ${completionMessage}\n`); + await testFn(); + } catch (e) { + browser.test.fail(`Unexpected error: ${e}`); + } + browser.test.sendMessage(completionMessage); + } + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + background: `(${runTest})(${testBackground}, "background_done");`, + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + permissions, + }, + files: { + "test.js": `(${runTest})(${testContent}, "content_done");`, + }, + }); + + // Run background script. + await extension.startup(); + await extension.awaitMessage("background_done"); + + // Run content script. + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content_done"); + await page.close(); + + await extension.unload(); +} + +// Checks that unprivileged extensions cannot use any of the nativeMessaging +// APIs on Android. +add_task(async function test_nativeMessaging_unprivileged() { + function testScript() { + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "connectNative should not be available in unprivileged extensions" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "sendNativeMessage should not be available in unprivileged extensions" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: false, + permissions: [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent", + ], + testBackground: testScript, + testContent: testScript, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /Invalid extension permission: geckoViewAddons/ }, + { message: /Invalid extension permission: nativeMessaging/ }, + { message: /Invalid extension permission: nativeMessagingFromContent/ }, + ], + }); +}); + +// Checks that privileged extensions can still not use native messaging without +// the geckoViewAddons permission. +add_task(async function test_geckoViewAddons_missing() { + const ERROR_NATIVE_MESSAGE_FROM_BACKGROUND = + "Native manifests are not supported on android"; + const ERROR_NATIVE_MESSAGE_FROM_CONTENT = + /^Native messaging not allowed: \{.*"envType":"content_child","url":"http:\/\/example\.com\/dummy"\}$/; + + async function testBackground() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_BACKGROUND + "An unexpected error occurred", + "Background script cannot use nativeMessaging without geckoViewAddons" + ); + } + async function testContent() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_CONTENT + "An unexpected error occurred", + "Content script cannot use nativeMessaging without geckoViewAddons" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: true, + permissions: ["nativeMessaging", "nativeMessagingFromContent"], + testBackground, + testContent, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [ + { errorMessage: ERROR_NATIVE_MESSAGE_FROM_BACKGROUND }, + { errorMessage: ERROR_NATIVE_MESSAGE_FROM_CONTENT }, + ], + }); +}); + +// Checks that privileged extensions cannot use native messaging from content +// without the nativeMessagingFromContent permission. +add_task(async function test_nativeMessagingFromContent_missing() { + const ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM = + /^Unexpected messaging sender: \{.*"envType":"content_child","url":"http:\/\/example\.com\/dummy"\}$/; + function testBackground() { + // sendNativeMessage / connectNative are expected to succeed, but we + // are not testing that here because XpcshellTestRunnerService does not + // have a WebExtension.MessageDelegate that handles the message. + // There are plenty of mochitests that rely on connectNative, so we are + // not testing that here. + } + async function testContent() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM + "An unexpected error occurred", + "Trying to get through to native messaging but without luck" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: true, + permissions: ["geckoViewAddons", "nativeMessaging"], + testBackground, + testContent, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [{ errorMessage: ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM }], + }); +}); diff --git a/mobile/android/components/extensions/test/xpcshell/xpcshell.toml b/mobile/android/components/extensions/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..c9486971a0 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/xpcshell.toml @@ -0,0 +1,9 @@ +[DEFAULT] +head = "head.js" +firefox-appdir = "browser" +tags = "webextensions in-process-webextensions" +run-if = ["os == 'android'"] + +["test_ext_native_messaging_geckoview.js"] + +["test_ext_native_messaging_permissions.js"] diff --git a/mobile/android/components/geckoview/ColorPickerDelegate.sys.mjs b/mobile/android/components/geckoview/ColorPickerDelegate.sys.mjs new file mode 100644 index 0000000000..dc1aff7629 --- /dev/null +++ b/mobile/android/components/geckoview/ColorPickerDelegate.sys.mjs @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("ColorPickerDelegate"); + +export class ColorPickerDelegate { + // TODO(bug 1805397): Implement default colors + init(aParent, aTitle, aInitialColor, aDefaultColors) { + this._prompt = new lazy.GeckoViewPrompter(aParent); + this._msg = { + type: "color", + title: aTitle, + value: aInitialColor, + predefinedValues: aDefaultColors, + }; + } + + open(aColorPickerShownCallback) { + this._prompt.asyncShowPrompt(this._msg, result => { + // OK: result + // Cancel: !result + aColorPickerShownCallback.done((result && result.color) || ""); + }); + } +} + +ColorPickerDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIColorPicker", +]); diff --git a/mobile/android/components/geckoview/FilePickerDelegate.sys.mjs b/mobile/android/components/geckoview/FilePickerDelegate.sys.mjs new file mode 100644 index 0000000000..3972017b40 --- /dev/null +++ b/mobile/android/components/geckoview/FilePickerDelegate.sys.mjs @@ -0,0 +1,189 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("FilePickerDelegate"); + +export class FilePickerDelegate { + /* ---------- nsIFilePicker ---------- */ + init(aParent, aTitle, aMode) { + if ( + aMode === Ci.nsIFilePicker.modeGetFolder || + aMode === Ci.nsIFilePicker.modeSave + ) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + this._prompt = new lazy.GeckoViewPrompter(aParent); + this._msg = { + type: "file", + title: aTitle, + mode: aMode === Ci.nsIFilePicker.modeOpenMultiple ? "multiple" : "single", + }; + this._mode = aMode; + this._mimeTypes = []; + this._capture = 0; + } + + get mode() { + return this._mode; + } + + appendRawFilter(aFilter) { + this._mimeTypes.push(aFilter); + } + + show() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + open(aFilePickerShownCallback) { + this._msg.mimeTypes = this._mimeTypes; + this._msg.capture = this._capture; + this._prompt.asyncShowPrompt(this._msg, result => { + // OK: result + // Cancel: !result + if (!result || !result.files || !result.files.length) { + aFilePickerShownCallback.done(Ci.nsIFilePicker.returnCancel); + } else { + this._resolveFiles(result.files, aFilePickerShownCallback); + } + }); + } + + async _resolveFiles(aFiles, aCallback) { + const fileData = []; + + try { + for (const file of aFiles) { + const domFile = await this._getDOMFile(file); + fileData.push({ + file, + domFile, + }); + } + } catch (ex) { + warn`Error resolving files from file picker: ${ex}`; + aCallback.done(Ci.nsIFilePicker.returnCancel); + return; + } + + this._fileData = fileData; + aCallback.done(Ci.nsIFilePicker.returnOK); + } + + get file() { + if (!this._fileData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + const fileData = this._fileData[0]; + if (!fileData) { + return null; + } + return new lazy.FileUtils.File(fileData.file); + } + + get fileURL() { + return Services.io.newFileURI(this.file); + } + + *_getEnumerator(aDOMFile) { + if (!this._fileData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + for (const fileData of this._fileData) { + if (aDOMFile) { + yield fileData.domFile; + } + yield new lazy.FileUtils.File(fileData.file); + } + } + + get files() { + return this._getEnumerator(/* aDOMFile */ false); + } + + _getDOMFile(aPath) { + if (this._prompt.domWin) { + return this._prompt.domWin.File.createFromFileName(aPath); + } + return File.createFromFileName(aPath); + } + + get domFileOrDirectory() { + if (!this._fileData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + return this._fileData[0] ? this._fileData[0].domFile : null; + } + + get domFileOrDirectoryEnumerator() { + return this._getEnumerator(/* aDOMFile */ true); + } + + get defaultString() { + return ""; + } + + set defaultString(aValue) {} + + get defaultExtension() { + return ""; + } + + set defaultExtension(aValue) {} + + get filterIndex() { + return 0; + } + + set filterIndex(aValue) {} + + get displayDirectory() { + return null; + } + + set displayDirectory(aValue) {} + + get displaySpecialDirectory() { + return ""; + } + + set displaySpecialDirectory(aValue) {} + + get addToRecentDocs() { + return false; + } + + set addToRecentDocs(aValue) {} + + get okButtonLabel() { + return ""; + } + + set okButtonLabel(aValue) {} + + get capture() { + return this._capture; + } + + set capture(aValue) { + this._capture = aValue; + } +} + +FilePickerDelegate.prototype.classID = Components.ID( + "{e4565e36-f101-4bf5-950b-4be0887785a9}" +); +FilePickerDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIFilePicker", +]); diff --git a/mobile/android/components/geckoview/GeckoView.manifest b/mobile/android/components/geckoview/GeckoView.manifest new file mode 100644 index 0000000000..472c4d7298 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoView.manifest @@ -0,0 +1,4 @@ +# GeckoViewStartup.js +category app-startup GeckoViewStartup @mozilla.org/geckoview/startup;1 process=main +category content-process-ready-for-script GeckoViewStartup @mozilla.org/geckoview/startup;1 process=content +category profile-after-change GeckoViewStartup @mozilla.org/geckoview/startup;1 process=main diff --git a/mobile/android/components/geckoview/GeckoViewContentChannel.cpp b/mobile/android/components/geckoview/GeckoViewContentChannel.cpp new file mode 100644 index 0000000000..49236b074c --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewContentChannel.cpp @@ -0,0 +1,47 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "GeckoViewContentChannel.h" +#include "GeckoViewInputStream.h" +#include "mozilla/java/ContentInputStreamWrappers.h" + +using namespace mozilla; + +GeckoViewContentChannel::GeckoViewContentChannel(nsIURI* aURI) { + SetURI(aURI); + SetOriginalURI(aURI); +} + +NS_IMETHODIMP +GeckoViewContentChannel::OpenContentStream(bool aAsync, + nsIInputStream** aResult, + nsIChannel** aChannel) { + nsCOMPtr<nsIURI> uri; + nsresult rv = GetURI(getter_AddRefs(uri)); + NS_ENSURE_SUCCESS(rv, NS_ERROR_MALFORMED_URI); + + nsAutoCString spec; + rv = uri->GetSpec(spec); + NS_ENSURE_SUCCESS(rv, rv); + + bool isReadable = GeckoViewContentInputStream::isReadable(spec); + if (!isReadable) { + return NS_ERROR_FILE_NOT_FOUND; + } + + nsCOMPtr<nsIInputStream> inputStream; + rv = GeckoViewContentInputStream::getInstance(spec, + getter_AddRefs(inputStream)); + NS_ENSURE_SUCCESS(rv, rv); + + if (NS_WARN_IF(!inputStream)) { + return NS_ERROR_MALFORMED_URI; + } + + inputStream.forget(aResult); + + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewContentChannel.h b/mobile/android/components/geckoview/GeckoViewContentChannel.h new file mode 100644 index 0000000000..70252e7134 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewContentChannel.h @@ -0,0 +1,23 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cin: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef GeckoViewContentChannel_h__ +#define GeckoViewContentChannel_h__ + +#include "nsBaseChannel.h" + +class GeckoViewContentChannel final : public nsBaseChannel { + public: + explicit GeckoViewContentChannel(nsIURI* aUri); + + private: + ~GeckoViewContentChannel() = default; + + nsresult OpenContentStream(bool async, nsIInputStream** result, + nsIChannel** channel) override; +}; + +#endif // !GeckoViewContentChannel_h__ diff --git a/mobile/android/components/geckoview/GeckoViewContentProtocolHandler.cpp b/mobile/android/components/geckoview/GeckoViewContentProtocolHandler.cpp new file mode 100644 index 0000000000..dffd84e608 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewContentProtocolHandler.cpp @@ -0,0 +1,54 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +// vim:ts=4 sw=2 sts=2 et cin: +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "GeckoViewContentProtocolHandler.h" +#include "GeckoViewContentChannel.h" +#include "nsStandardURL.h" +#include "nsURLHelper.h" +#include "nsIURIMutator.h" + +#include "nsNetUtil.h" + +#include "mozilla/ResultExtensions.h" + +//----------------------------------------------------------------------------- + +nsresult GeckoViewContentProtocolHandler::Init() { return NS_OK; } + +NS_IMPL_ISUPPORTS(GeckoViewContentProtocolHandler, nsIProtocolHandler, + nsISupportsWeakReference) + +//----------------------------------------------------------------------------- +// nsIProtocolHandler methods: + +NS_IMETHODIMP +GeckoViewContentProtocolHandler::GetScheme(nsACString& result) { + result.AssignLiteral("content"); + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewContentProtocolHandler::NewChannel(nsIURI* uri, nsILoadInfo* aLoadInfo, + nsIChannel** result) { + nsresult rv; + RefPtr<GeckoViewContentChannel> chan = new GeckoViewContentChannel(uri); + + rv = chan->SetLoadInfo(aLoadInfo); + if (NS_FAILED(rv)) { + return rv; + } + + chan.forget(result); + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewContentProtocolHandler::AllowPort(int32_t port, const char* scheme, + bool* result) { + // don't override anything. + *result = false; + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewContentProtocolHandler.h b/mobile/android/components/geckoview/GeckoViewContentProtocolHandler.h new file mode 100644 index 0000000000..1763aaf121 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewContentProtocolHandler.h @@ -0,0 +1,27 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef GeckoViewContentProtocolHandler_h__ +#define GeckoViewContentProtocolHandler_h__ + +#include "nsIProtocolHandler.h" +#include "nsWeakReference.h" + +class nsIURIMutator; + +class GeckoViewContentProtocolHandler : public nsIProtocolHandler, + public nsSupportsWeakReference { + virtual ~GeckoViewContentProtocolHandler() = default; + + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIPROTOCOLHANDLER + + GeckoViewContentProtocolHandler() = default; + + [[nodiscard]] nsresult Init(); +}; + +#endif // !GeckoViewContentProtocolHandler_h__ diff --git a/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp b/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp new file mode 100644 index 0000000000..ffabd6ef2a --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp @@ -0,0 +1,98 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "GeckoViewExternalAppService.h" + +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/CanonicalBrowsingContext.h" +#include "mozilla/dom/WindowGlobalParent.h" +#include "nsIChannel.h" + +#include "mozilla/widget/EventDispatcher.h" +#include "mozilla/widget/nsWindow.h" +#include "GeckoViewStreamListener.h" + +#include "JavaBuiltins.h" + +class StreamListener final : public mozilla::GeckoViewStreamListener { + public: + explicit StreamListener(nsWindow* aWindow) + : GeckoViewStreamListener(), mWindow(aWindow) {} + + void SendWebResponse(mozilla::java::WebResponse::Param aResponse) { + mWindow->PassExternalResponse(aResponse); + } + + void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) { + // Currently we don't do anything about errors here + } + + virtual ~StreamListener() {} + + private: + RefPtr<nsWindow> mWindow; +}; + +mozilla::StaticRefPtr<GeckoViewExternalAppService> + GeckoViewExternalAppService::sService; + +/* static */ +already_AddRefed<GeckoViewExternalAppService> +GeckoViewExternalAppService::GetSingleton() { + if (!sService) { + sService = new GeckoViewExternalAppService(); + } + RefPtr<GeckoViewExternalAppService> service = sService; + return service.forget(); +} + +GeckoViewExternalAppService::GeckoViewExternalAppService() {} + +NS_IMPL_ISUPPORTS(GeckoViewExternalAppService, nsIExternalHelperAppService); + +NS_IMETHODIMP GeckoViewExternalAppService::DoContent( + const nsACString& aMimeContentType, nsIChannel* aChannel, + nsIInterfaceRequestor* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + return NS_ERROR_FAILURE; +} + +NS_IMETHODIMP GeckoViewExternalAppService::CreateListener( + const nsACString& aMimeContentType, nsIChannel* aChannel, + mozilla::dom::BrowsingContext* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + using namespace mozilla; + using namespace mozilla::dom; + MOZ_ASSERT(XRE_IsParentProcess()); + NS_ENSURE_ARG_POINTER(aChannel); + + nsCOMPtr<nsIWidget> widget = + aContentContext->Canonical()->GetParentProcessWidgetContaining(); + if (!widget) { + return NS_ERROR_ABORT; + } + + RefPtr<nsWindow> window = nsWindow::From(widget); + MOZ_ASSERT(window); + + RefPtr<StreamListener> listener = new StreamListener(window); + + nsresult rv; + rv = aChannel->SetNotificationCallbacks(listener); + NS_ENSURE_SUCCESS(rv, rv); + + listener.forget(aStreamListener); + return NS_OK; +} + +NS_IMETHODIMP GeckoViewExternalAppService::ApplyDecodingForExtension( + const nsACString& aExtension, const nsACString& aEncodingType, + bool* aApplyDecoding) { + // This currently doesn't matter, because we never read the stream. + *aApplyDecoding = true; + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewExternalAppService.h b/mobile/android/components/geckoview/GeckoViewExternalAppService.h new file mode 100644 index 0000000000..1dfb7c9491 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewExternalAppService.h @@ -0,0 +1,26 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef GeckoViewExternalAppService_h__ +#define GeckoViewExternalAppService_h__ + +#include "nsIExternalHelperAppService.h" +#include "mozilla/StaticPtr.h" + +class GeckoViewExternalAppService : public nsIExternalHelperAppService { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIEXTERNALHELPERAPPSERVICE + + GeckoViewExternalAppService(); + + static already_AddRefed<GeckoViewExternalAppService> GetSingleton(); + + private: + virtual ~GeckoViewExternalAppService() {} + static mozilla::StaticRefPtr<GeckoViewExternalAppService> sService; +}; + +#endif diff --git a/mobile/android/components/geckoview/GeckoViewHistory.cpp b/mobile/android/components/geckoview/GeckoViewHistory.cpp new file mode 100644 index 0000000000..c69e419b18 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewHistory.cpp @@ -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/. */ + +#include "GeckoViewHistory.h" + +#include "JavaBuiltins.h" +#include "jsapi.h" +#include "js/Array.h" // JS::GetArrayLength, JS::IsArrayObject +#include "js/PropertyAndElement.h" // JS_GetElement +#include "nsIURI.h" +#include "nsXULAppAPI.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/StaticPrefs_layout.h" + +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/Link.h" +#include "mozilla/dom/BrowserChild.h" + +#include "mozilla/ipc/URIUtils.h" + +#include "mozilla/widget/EventDispatcher.h" +#include "mozilla/widget/nsWindow.h" + +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::ipc; +using namespace mozilla::widget; + +static const char16_t kOnVisitedMessage[] = u"GeckoView:OnVisited"; +static const char16_t kGetVisitedMessage[] = u"GeckoView:GetVisited"; + +// Keep in sync with `GeckoSession.HistoryDelegate.VisitFlags`. +enum class GeckoViewVisitFlags : int32_t { + VISIT_TOP_LEVEL = 1 << 0, + VISIT_REDIRECT_TEMPORARY = 1 << 1, + VISIT_REDIRECT_PERMANENT = 1 << 2, + VISIT_REDIRECT_SOURCE = 1 << 3, + VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4, + VISIT_UNRECOVERABLE_ERROR = 1 << 5, +}; + +GeckoViewHistory::GeckoViewHistory() {} + +GeckoViewHistory::~GeckoViewHistory() {} + +NS_IMPL_ISUPPORTS(GeckoViewHistory, IHistory) + +StaticRefPtr<GeckoViewHistory> GeckoViewHistory::sHistory; + +/* static */ +already_AddRefed<GeckoViewHistory> GeckoViewHistory::GetSingleton() { + if (!sHistory) { + sHistory = new GeckoViewHistory(); + ClearOnShutdown(&sHistory); + } + RefPtr<GeckoViewHistory> history = sHistory; + return history.forget(); +} + +// Handles a request to fetch visited statuses for new tracked URIs in the +// content process (e10s). +void GeckoViewHistory::QueryVisitedStateInContentProcess( + const PendingVisitedQueries& aQueries) { + // Holds an array of new tracked URIs for a tab in the content process. + struct NewURIEntry { + explicit NewURIEntry(BrowserChild* aBrowserChild, nsIURI* aURI) + : mBrowserChild(aBrowserChild) { + AddURI(aURI); + } + + void AddURI(nsIURI* aURI) { mURIs.AppendElement(aURI); } + + BrowserChild* mBrowserChild; + nsTArray<RefPtr<nsIURI>> mURIs; + }; + + MOZ_ASSERT(XRE_IsContentProcess()); + + // First, serialize all the new URIs that we need to look up. Note that this + // could be written as `nsTHashMap<nsUint64HashKey, + // nsTArray<URIParams>` instead, but, since we don't expect to have many tab + // children, we can avoid the cost of hashing. + AutoTArray<NewURIEntry, 8> newEntries; + for (auto& query : aQueries) { + nsIURI* uri = query.GetKey(); + MOZ_ASSERT(query.GetData().IsEmpty(), + "Shouldn't have parents to notify in child processes"); + auto entry = mTrackedURIs.Lookup(uri); + if (!entry) { + continue; + } + ObservingLinks& links = entry.Data(); + for (Link* link : links.mLinks.BackwardRange()) { + nsIWidget* widget = nsContentUtils::WidgetForContent(link->GetElement()); + if (!widget) { + continue; + } + BrowserChild* browserChild = widget->GetOwningBrowserChild(); + if (!browserChild) { + continue; + } + // Add to the list of new URIs for this document, or make a new entry. + bool hasEntry = false; + for (NewURIEntry& entry : newEntries) { + if (entry.mBrowserChild == browserChild) { + entry.AddURI(uri); + hasEntry = true; + break; + } + } + if (!hasEntry) { + newEntries.AppendElement(NewURIEntry(browserChild, uri)); + } + } + } + + // Send the request to the parent process, one message per tab child. + for (const NewURIEntry& entry : newEntries) { + Unused << NS_WARN_IF( + !entry.mBrowserChild->SendQueryVisitedState(entry.mURIs)); + } +} + +// Handles a request to fetch visited statuses for new tracked URIs in the +// parent process (non-e10s). +void GeckoViewHistory::QueryVisitedStateInParentProcess( + const PendingVisitedQueries& aQueries) { + // Holds an array of new URIs for a window in the parent process. Unlike + // the content process case, we don't need to track tab children, since we + // have the outer window and can send the request directly to Java. + struct NewURIEntry { + explicit NewURIEntry(nsIWidget* aWidget, nsIURI* aURI) : mWidget(aWidget) { + AddURI(aURI); + } + + void AddURI(nsIURI* aURI) { mURIs.AppendElement(aURI); } + + nsCOMPtr<nsIWidget> mWidget; + nsTArray<RefPtr<nsIURI>> mURIs; + }; + + MOZ_ASSERT(XRE_IsParentProcess()); + + nsTArray<NewURIEntry> newEntries; + for (const auto& query : aQueries) { + nsIURI* uri = query.GetKey(); + auto entry = mTrackedURIs.Lookup(uri); + if (!entry) { + continue; // Nobody cares about this uri anymore. + } + + ObservingLinks& links = entry.Data(); + nsTObserverArray<Link*>::BackwardIterator linksIter(links.mLinks); + while (linksIter.HasMore()) { + Link* link = linksIter.GetNext(); + + nsIWidget* widget = nsContentUtils::WidgetForContent(link->GetElement()); + if (!widget) { + continue; + } + + bool hasEntry = false; + for (NewURIEntry& entry : newEntries) { + if (entry.mWidget != widget) { + continue; + } + entry.AddURI(uri); + hasEntry = true; + } + if (!hasEntry) { + newEntries.AppendElement(NewURIEntry(widget, uri)); + } + } + } + + for (NewURIEntry& entry : newEntries) { + QueryVisitedState(entry.mWidget, nullptr, std::move(entry.mURIs)); + } +} + +void GeckoViewHistory::StartPendingVisitedQueries( + PendingVisitedQueries&& aQueries) { + if (XRE_IsContentProcess()) { + QueryVisitedStateInContentProcess(aQueries); + } else { + QueryVisitedStateInParentProcess(aQueries); + } +} + +/** + * Called from the session handler for the history delegate, after the new + * visit is recorded. + */ +class OnVisitedCallback final : public nsIAndroidEventCallback { + public: + explicit OnVisitedCallback(GeckoViewHistory* aHistory, nsIURI* aURI) + : mHistory(aHistory), mURI(aURI) {} + + NS_DECL_ISUPPORTS + + NS_IMETHOD + OnSuccess(JS::Handle<JS::Value> aData, JSContext* aCx) override { + Maybe<bool> visitedState = GetVisitedValue(aCx, aData); + JS_ClearPendingException(aCx); + if (visitedState) { + AutoTArray<VisitedURI, 1> visitedURIs; + visitedURIs.AppendElement(VisitedURI{mURI.get(), *visitedState}); + mHistory->HandleVisitedState(visitedURIs, nullptr); + } + return NS_OK; + } + + NS_IMETHOD + OnError(JS::Handle<JS::Value> aData, JSContext* aCx) override { + return NS_OK; + } + + private: + virtual ~OnVisitedCallback() {} + + Maybe<bool> GetVisitedValue(JSContext* aCx, JS::Handle<JS::Value> aData) { + if (NS_WARN_IF(!aData.isBoolean())) { + return Nothing(); + } + return Some(aData.toBoolean()); + } + + RefPtr<GeckoViewHistory> mHistory; + nsCOMPtr<nsIURI> mURI; +}; + +NS_IMPL_ISUPPORTS(OnVisitedCallback, nsIAndroidEventCallback) + +NS_IMETHODIMP +GeckoViewHistory::VisitURI(nsIWidget* aWidget, nsIURI* aURI, + nsIURI* aLastVisitedURI, uint32_t aFlags, + uint64_t aBrowserId) { + if (!aURI) { + return NS_OK; + } + + if (XRE_IsContentProcess()) { + // If we're in the content process, send the visit to the parent. The parent + // will find the matching chrome window for the content process and tab, + // then forward the visit to Java. + if (NS_WARN_IF(!aWidget)) { + return NS_OK; + } + BrowserChild* browserChild = aWidget->GetOwningBrowserChild(); + if (NS_WARN_IF(!browserChild)) { + return NS_OK; + } + Unused << NS_WARN_IF( + !browserChild->SendVisitURI(aURI, aLastVisitedURI, aFlags, aBrowserId)); + return NS_OK; + } + + // Otherwise, we're in the parent process. Wrap the URIs up in a bundle, and + // send them to Java. + MOZ_ASSERT(XRE_IsParentProcess()); + RefPtr<nsWindow> window = nsWindow::From(aWidget); + if (NS_WARN_IF(!window)) { + return NS_OK; + } + widget::EventDispatcher* dispatcher = window->GetEventDispatcher(); + if (NS_WARN_IF(!dispatcher)) { + return NS_OK; + } + + // If nobody is listening for this, we can stop now. + if (!dispatcher->HasListener(kOnVisitedMessage)) { + return NS_OK; + } + + AutoTArray<jni::String::LocalRef, 3> keys; + AutoTArray<jni::Object::LocalRef, 3> values; + + nsAutoCString uriSpec; + if (NS_WARN_IF(NS_FAILED(aURI->GetSpec(uriSpec)))) { + return NS_OK; + } + keys.AppendElement(jni::StringParam(u"url"_ns)); + values.AppendElement(jni::StringParam(uriSpec)); + + if (aLastVisitedURI) { + nsAutoCString lastVisitedURISpec; + if (NS_WARN_IF(NS_FAILED(aLastVisitedURI->GetSpec(lastVisitedURISpec)))) { + return NS_OK; + } + keys.AppendElement(jni::StringParam(u"lastVisitedURL"_ns)); + values.AppendElement(jni::StringParam(lastVisitedURISpec)); + } + + int32_t flags = 0; + if (aFlags & TOP_LEVEL) { + flags |= static_cast<int32_t>(GeckoViewVisitFlags::VISIT_TOP_LEVEL); + } + if (aFlags & REDIRECT_TEMPORARY) { + flags |= + static_cast<int32_t>(GeckoViewVisitFlags::VISIT_REDIRECT_TEMPORARY); + } + if (aFlags & REDIRECT_PERMANENT) { + flags |= + static_cast<int32_t>(GeckoViewVisitFlags::VISIT_REDIRECT_PERMANENT); + } + if (aFlags & REDIRECT_SOURCE) { + flags |= static_cast<int32_t>(GeckoViewVisitFlags::VISIT_REDIRECT_SOURCE); + } + if (aFlags & REDIRECT_SOURCE_PERMANENT) { + flags |= static_cast<int32_t>( + GeckoViewVisitFlags::VISIT_REDIRECT_SOURCE_PERMANENT); + } + if (aFlags & UNRECOVERABLE_ERROR) { + flags |= + static_cast<int32_t>(GeckoViewVisitFlags::VISIT_UNRECOVERABLE_ERROR); + } + keys.AppendElement(jni::StringParam(u"flags"_ns)); + values.AppendElement(java::sdk::Integer::ValueOf(flags)); + + MOZ_ASSERT(keys.Length() == values.Length()); + + auto bundleKeys = jni::ObjectArray::New<jni::String>(keys.Length()); + auto bundleValues = jni::ObjectArray::New<jni::Object>(values.Length()); + for (size_t i = 0; i < keys.Length(); ++i) { + bundleKeys->SetElement(i, keys[i]); + bundleValues->SetElement(i, values[i]); + } + auto bundle = java::GeckoBundle::New(bundleKeys, bundleValues); + + nsCOMPtr<nsIAndroidEventCallback> callback = + new OnVisitedCallback(this, aURI); + + Unused << NS_WARN_IF( + NS_FAILED(dispatcher->Dispatch(kOnVisitedMessage, bundle, callback))); + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewHistory::SetURITitle(nsIURI* aURI, const nsAString& aTitle) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/** + * Called from the session handler for the history delegate, with visited + * statuses for all requested URIs. + */ +class GetVisitedCallback final : public nsIAndroidEventCallback { + public: + explicit GetVisitedCallback(GeckoViewHistory* aHistory, + ContentParent* aInterestedProcess, + nsTArray<RefPtr<nsIURI>>&& aURIs) + : mHistory(aHistory), + mInterestedProcess(aInterestedProcess), + mURIs(std::move(aURIs)) {} + + NS_DECL_ISUPPORTS + + NS_IMETHOD + OnSuccess(JS::Handle<JS::Value> aData, JSContext* aCx) override { + nsTArray<VisitedURI> visitedURIs; + if (!ExtractVisitedURIs(aCx, aData, visitedURIs)) { + JS_ClearPendingException(aCx); + return NS_ERROR_FAILURE; + } + IHistory::ContentParentSet interestedProcesses; + if (mInterestedProcess) { + interestedProcesses.Insert(mInterestedProcess); + } + mHistory->HandleVisitedState(visitedURIs, &interestedProcesses); + return NS_OK; + } + + NS_IMETHOD + OnError(JS::Handle<JS::Value> aData, JSContext* aCx) override { + return NS_OK; + } + + private: + virtual ~GetVisitedCallback() {} + + /** + * Unpacks an array of Boolean visited statuses from the session handler into + * an array of `VisitedURI` structs. Each element in the array corresponds to + * a URI in `mURIs`. + * + * Returns `false` on error, `true` if the array is `null` or was successfully + * unpacked. + * + * TODO (bug 1503482): Remove this unboxing. + */ + bool ExtractVisitedURIs(JSContext* aCx, JS::Handle<JS::Value> aData, + nsTArray<VisitedURI>& aVisitedURIs) { + if (aData.isNull()) { + return true; + } + bool isArray = false; + if (NS_WARN_IF(!JS::IsArrayObject(aCx, aData, &isArray))) { + return false; + } + if (NS_WARN_IF(!isArray)) { + return false; + } + JS::Rooted<JSObject*> visited(aCx, &aData.toObject()); + uint32_t length = 0; + if (NS_WARN_IF(!JS::GetArrayLength(aCx, visited, &length))) { + return false; + } + if (NS_WARN_IF(length != mURIs.Length())) { + return false; + } + if (!aVisitedURIs.SetCapacity(length, mozilla::fallible)) { + return false; + } + for (uint32_t i = 0; i < length; ++i) { + JS::Rooted<JS::Value> value(aCx); + if (NS_WARN_IF(!JS_GetElement(aCx, visited, i, &value))) { + JS_ClearPendingException(aCx); + aVisitedURIs.AppendElement(VisitedURI{mURIs[i].get(), false}); + continue; + } + if (NS_WARN_IF(!value.isBoolean())) { + aVisitedURIs.AppendElement(VisitedURI{mURIs[i].get(), false}); + continue; + } + aVisitedURIs.AppendElement(VisitedURI{mURIs[i].get(), value.toBoolean()}); + } + return true; + } + + RefPtr<GeckoViewHistory> mHistory; + RefPtr<ContentParent> mInterestedProcess; + nsTArray<RefPtr<nsIURI>> mURIs; +}; + +NS_IMPL_ISUPPORTS(GetVisitedCallback, nsIAndroidEventCallback) + +/** + * Queries the history delegate to find which URIs have been visited. This + * is always called in the parent process: from `GetVisited` in non-e10s, and + * from `ContentParent::RecvGetVisited` in e10s. + */ +void GeckoViewHistory::QueryVisitedState(nsIWidget* aWidget, + ContentParent* aInterestedProcess, + nsTArray<RefPtr<nsIURI>>&& aURIs) { + MOZ_ASSERT(XRE_IsParentProcess()); + RefPtr<nsWindow> window = nsWindow::From(aWidget); + if (NS_WARN_IF(!window)) { + return; + } + widget::EventDispatcher* dispatcher = window->GetEventDispatcher(); + if (NS_WARN_IF(!dispatcher)) { + return; + } + + // If nobody is listening for this we can stop now + if (!dispatcher->HasListener(kGetVisitedMessage)) { + return; + } + + // Assemble a bundle like `{ urls: ["http://example.com/1", ...] }`. + auto uris = jni::ObjectArray::New<jni::String>(aURIs.Length()); + for (size_t i = 0; i < aURIs.Length(); ++i) { + nsAutoCString uriSpec; + if (NS_WARN_IF(NS_FAILED(aURIs[i]->GetSpec(uriSpec)))) { + continue; + } + jni::String::LocalRef value{jni::StringParam(uriSpec)}; + uris->SetElement(i, value); + } + + auto bundleKeys = jni::ObjectArray::New<jni::String>(1); + jni::String::LocalRef key(jni::StringParam(u"urls"_ns)); + bundleKeys->SetElement(0, key); + + auto bundleValues = jni::ObjectArray::New<jni::Object>(1); + jni::Object::LocalRef value(uris); + bundleValues->SetElement(0, value); + + auto bundle = java::GeckoBundle::New(bundleKeys, bundleValues); + + nsCOMPtr<nsIAndroidEventCallback> callback = + new GetVisitedCallback(this, aInterestedProcess, std::move(aURIs)); + + Unused << NS_WARN_IF( + NS_FAILED(dispatcher->Dispatch(kGetVisitedMessage, bundle, callback))); +} + +/** + * Updates link states for all tracked links, forwarding the visited statuses to + * the content process in e10s. This is always called in the parent process, + * from `VisitedCallback::OnSuccess` and `GetVisitedCallback::OnSuccess`. + */ +void GeckoViewHistory::HandleVisitedState( + const nsTArray<VisitedURI>& aVisitedURIs, + ContentParentSet* aInterestedProcesses) { + MOZ_ASSERT(XRE_IsParentProcess()); + + for (const VisitedURI& visitedURI : aVisitedURIs) { + auto status = + visitedURI.mVisited ? VisitedStatus::Visited : VisitedStatus::Unvisited; + NotifyVisited(visitedURI.mURI, status, aInterestedProcesses); + } +} diff --git a/mobile/android/components/geckoview/GeckoViewHistory.h b/mobile/android/components/geckoview/GeckoViewHistory.h new file mode 100644 index 0000000000..a3b96ba58f --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewHistory.h @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef GECKOVIEWHISTORY_H +#define GECKOVIEWHISTORY_H + +#include "mozilla/BaseHistory.h" +#include "nsTObserverArray.h" +#include "nsURIHashKey.h" +#include "nsINamed.h" +#include "nsITimer.h" +#include "nsIURI.h" + +#include "mozilla/StaticPtr.h" + +class nsIWidget; + +namespace mozilla { +namespace dom { +class Document; +} +} // namespace mozilla + +struct VisitedURI { + nsCOMPtr<nsIURI> mURI; + bool mVisited = false; +}; + +class GeckoViewHistory final : public mozilla::BaseHistory { + public: + NS_DECL_ISUPPORTS + + // IHistory + NS_IMETHOD VisitURI(nsIWidget*, nsIURI*, nsIURI* aLastVisitedURI, + uint32_t aFlags, uint64_t aBrowserId) final; + NS_IMETHOD SetURITitle(nsIURI*, const nsAString&) final; + + static already_AddRefed<GeckoViewHistory> GetSingleton(); + + void StartPendingVisitedQueries(PendingVisitedQueries&&) final; + + GeckoViewHistory(); + + void QueryVisitedState(nsIWidget* aWidget, + mozilla::dom::ContentParent* aInterestedProcess, + nsTArray<RefPtr<nsIURI>>&& aURIs); + void HandleVisitedState(const nsTArray<VisitedURI>& aVisitedURIs, + ContentParentSet* aInterestedProcesses); + + private: + virtual ~GeckoViewHistory(); + + void QueryVisitedStateInContentProcess(const PendingVisitedQueries&); + void QueryVisitedStateInParentProcess(const PendingVisitedQueries&); + + static mozilla::StaticRefPtr<GeckoViewHistory> sHistory; +}; + +#endif diff --git a/mobile/android/components/geckoview/GeckoViewInputStream.cpp b/mobile/android/components/geckoview/GeckoViewInputStream.cpp new file mode 100644 index 0000000000..187a0ce10c --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewInputStream.cpp @@ -0,0 +1,110 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cin: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "GeckoViewInputStream.h" +#include "nsStreamUtils.h" + +NS_IMPL_ISUPPORTS(GeckoViewInputStream, nsIInputStream); + +NS_IMETHODIMP +GeckoViewInputStream::Close() { + mClosed = true; + mInstance->Close(); + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewInputStream::Available(uint64_t* aCount) { + if (mClosed) { + return NS_BASE_STREAM_CLOSED; + } + + *aCount = static_cast<uint64_t>(mInstance->Available()); + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewInputStream::StreamStatus() { + return mClosed ? NS_BASE_STREAM_CLOSED : NS_OK; +} + +NS_IMETHODIMP +GeckoViewInputStream::Read(char* aBuf, uint32_t aCount, uint32_t* aReadCount) { + return ReadSegments(NS_CopySegmentToBuffer, aBuf, aCount, aReadCount); +} + +NS_IMETHODIMP +GeckoViewInputStream::ReadSegments(nsWriteSegmentFun writer, void* aClosure, + uint32_t aCount, uint32_t* result) { + NS_ASSERTION(result, "null ptr"); + + if (mClosed) { + return NS_BASE_STREAM_CLOSED; + } + + auto bufferAddress = + static_cast<const char*>(mInstance->MBuffer()->Address()); + uint32_t segmentPos = static_cast<uint32_t>(mInstance->MPos()); + int32_t dataLength = 0; + nsresult rv; + + *result = 0; + while (aCount) { + rv = mInstance->Read(static_cast<int64_t>(aCount), &dataLength); + if (NS_FAILED(rv)) { + return NS_BASE_STREAM_OSERROR; + } + + if (dataLength == -1) { + break; + } + + uint32_t uDataLength = static_cast<uint32_t>(dataLength); + uint32_t written; + rv = writer(this, aClosure, bufferAddress + segmentPos, *result, + uDataLength, &written); + + if (NS_FAILED(rv)) { + // InputStreams do not propagate errors to caller. + break; + } + + NS_ASSERTION(written > 0, "Must have written something"); + + *result += written; + aCount -= written; + + segmentPos = static_cast<uint32_t>(mInstance->ConsumedData(written)); + } + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewInputStream::IsNonBlocking(bool* aNonBlocking) { + *aNonBlocking = true; + return NS_OK; +} + +bool GeckoViewInputStream::isClosed() const { return mInstance->IsClosed(); } + +bool GeckoViewContentInputStream::isReadable(const nsAutoCString& aUri) { + return mozilla::java::ContentInputStream::IsReadable( + mozilla::jni::StringParam(aUri)); +} + +nsresult GeckoViewContentInputStream::getInstance(const nsAutoCString& aUri, + nsIInputStream** aInstance) { + RefPtr<GeckoViewContentInputStream> instance = + new GeckoViewContentInputStream(aUri); + if (instance->isClosed()) { + return NS_ERROR_FILE_NOT_FOUND; + } + *aInstance = instance.forget().take(); + + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewInputStream.h b/mobile/android/components/geckoview/GeckoViewInputStream.h new file mode 100644 index 0000000000..ac48e0db25 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewInputStream.h @@ -0,0 +1,43 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et cin: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef GeckoViewInputStream_h__ +#define GeckoViewInputStream_h__ + +#include "mozilla/java/GeckoViewInputStreamWrappers.h" +#include "mozilla/java/ContentInputStreamWrappers.h" +#include "nsIInputStream.h" + +class GeckoViewInputStream : public nsIInputStream { + NS_DECL_ISUPPORTS + NS_DECL_NSIINPUTSTREAM + + explicit GeckoViewInputStream( + mozilla::java::GeckoViewInputStream::LocalRef aInstance) + : mInstance(aInstance){}; + bool isClosed() const; + + protected: + virtual ~GeckoViewInputStream() = default; + + private: + mozilla::java::GeckoViewInputStream::LocalRef mInstance; + bool mClosed{false}; +}; + +class GeckoViewContentInputStream final : public GeckoViewInputStream { + public: + static nsresult getInstance(const nsAutoCString& aUri, + nsIInputStream** aInstance); + static bool isReadable(const nsAutoCString& aUri); + + private: + explicit GeckoViewContentInputStream(const nsAutoCString& aUri) + : GeckoViewInputStream(mozilla::java::ContentInputStream::GetInstance( + mozilla::jni::StringParam(aUri))) {} +}; + +#endif // !GeckoViewInputStream_h__ diff --git a/mobile/android/components/geckoview/GeckoViewOutputStream.cpp b/mobile/android/components/geckoview/GeckoViewOutputStream.cpp new file mode 100644 index 0000000000..6368363f59 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewOutputStream.cpp @@ -0,0 +1,61 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; 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/. */ + +#include "GeckoViewOutputStream.h" +#include "mozilla/fallible.h" + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(GeckoViewOutputStream, nsIOutputStream); + +NS_IMETHODIMP +GeckoViewOutputStream::Close() { + mStream->SendEof(); + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewOutputStream::Flush() { return NS_ERROR_NOT_IMPLEMENTED; } + +NS_IMETHODIMP +GeckoViewOutputStream::StreamStatus() { + return mStream->IsStreamClosed() ? NS_BASE_STREAM_CLOSED : NS_OK; +} + +NS_IMETHODIMP +GeckoViewOutputStream::Write(const char* buf, uint32_t count, + uint32_t* retval) { + jni::ByteArray::LocalRef buffer = jni::ByteArray::New( + reinterpret_cast<const int8_t*>(buf), count, fallible); + if (!buffer) { + return NS_ERROR_OUT_OF_MEMORY; + } + if (NS_FAILED(mStream->AppendBuffer(buffer))) { + // The stream was closed, abort reading this channel. + return NS_BASE_STREAM_CLOSED; + } + // Return amount of bytes written + *retval = count; + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewOutputStream::WriteFrom(nsIInputStream* fromStream, uint32_t count, + uint32_t* retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +GeckoViewOutputStream::WriteSegments(nsReadSegmentFun reader, void* closure, + uint32_t count, uint32_t* retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +GeckoViewOutputStream::IsNonBlocking(bool* retval) { + *retval = true; + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewOutputStream.h b/mobile/android/components/geckoview/GeckoViewOutputStream.h new file mode 100644 index 0000000000..70ab8a9198 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewOutputStream.h @@ -0,0 +1,29 @@ + +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; 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/. */ + +#ifndef GeckoViewOutputStream_h__ +#define GeckoViewOutputStream_h__ + +#include "mozilla/java/GeckoInputStreamNatives.h" +#include "mozilla/java/GeckoInputStreamWrappers.h" + +#include "nsIOutputStream.h" +#include "nsIRequest.h" + +class GeckoViewOutputStream : public nsIOutputStream { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIOUTPUTSTREAM + explicit GeckoViewOutputStream( + mozilla::java::GeckoInputStream::GlobalRef aStream) + : mStream(aStream) {} + + private: + const mozilla::java::GeckoInputStream::GlobalRef mStream; + virtual ~GeckoViewOutputStream() = default; +}; + +#endif // GeckoViewOutputStream_h__ diff --git a/mobile/android/components/geckoview/GeckoViewPermission.sys.mjs b/mobile/android/components/geckoview/GeckoViewPermission.sys.mjs new file mode 100644 index 0000000000..51cc0f5588 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPermission.sys.mjs @@ -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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +export class GeckoViewPermission { + constructor() { + this.wrappedJSObject = this; + } + + async prompt(aRequest) { + const window = aRequest.window + ? aRequest.window + : aRequest.element.ownerGlobal; + + const actor = window.windowGlobalChild.getActor("GeckoViewPermission"); + const result = await actor.promptPermission(aRequest); + if (!result.allow) { + aRequest.cancel(); + } else { + // Note: permission could be undefined, that's what aRequest expects. + const { permission } = result; + aRequest.allow(permission); + } + } +} + +GeckoViewPermission.prototype.classID = Components.ID( + "{42f3c238-e8e8-4015-9ca2-148723a8afcf}" +); +GeckoViewPermission.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIContentPermissionPrompt", +]); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPermission"); diff --git a/mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs b/mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs new file mode 100644 index 0000000000..08087a4e63 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPrompt.sys.mjs @@ -0,0 +1,831 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", + GeckoViewClipboardPermission: + "resource://gre/modules/GeckoViewClipboardPermission.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPrompt"); + +export class PromptFactory { + constructor() { + this.wrappedJSObject = this; + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "mozshowdropdown": + case "mozshowdropdown-sourcetouch": + this._handleSelect( + aEvent.composedTarget, + aEvent.composedTarget.isCombobox + ); + break; + case "MozOpenDateTimePicker": + this._handleDateTime(aEvent.composedTarget); + break; + case "click": + this._handleClick(aEvent); + break; + case "DOMPopupBlocked": + this._handlePopupBlocked(aEvent); + break; + } + } + + _handleClick(aEvent) { + const target = aEvent.composedTarget; + const className = ChromeUtils.getClassName(target); + if (className !== "HTMLInputElement" && className !== "HTMLSelectElement") { + return; + } + + if ( + target.isContentEditable || + target.disabled || + target.readOnly || + !target.willValidate + ) { + // target.willValidate is false when any associated fieldset is disabled, + // in which case this element is treated as disabled per spec. + return; + } + + if (className === "HTMLSelectElement") { + if (!target.isCombobox) { + this._handleSelect(target, /* aIsDropDown = */ false); + return; + } + // combobox select is handled by mozshowdropdown. + return; + } + + const type = target.type; + if (type === "month" || type === "week") { + // If there's a shadow root, the MozOpenDateTimePicker event takes care + // of this. Right now for these input types there's never a shadow root. + // Once we support UA widgets for month/week inputs (see bug 888320), we + // can remove this. + if (!target.openOrClosedShadowRoot) { + this._handleDateTime(target); + aEvent.preventDefault(); + } + } + } + + _generateSelectItems(aElement) { + const win = aElement.ownerGlobal; + let id = 0; + const map = {}; + + const items = (function enumList(elem, disabled) { + const items = []; + const children = elem.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (win.getComputedStyle(child).display === "none") { + continue; + } + const item = { + id: String(id), + disabled: disabled || child.disabled, + }; + if (win.HTMLOptGroupElement.isInstance(child)) { + item.label = child.label; + item.items = enumList(child, item.disabled); + } else if (win.HTMLOptionElement.isInstance(child)) { + item.label = child.label || child.text; + item.selected = child.selected; + } else if (win.HTMLHRElement.isInstance(child)) { + item.separator = true; + } else { + continue; + } + items.push(item); + map[id++] = child; + } + return items; + })(aElement); + + return [items, map, id]; + } + + _handleSelect(aElement, aIsDropDown) { + const win = aElement.ownerGlobal; + const [items] = this._generateSelectItems(aElement); + + if (aIsDropDown) { + aElement.openInParentProcess = true; + } + + const prompt = new lazy.GeckoViewPrompter(win); + + // Something changed the <select> while it was open. + const deferredUpdate = new lazy.DeferredTask(() => { + // Inner contents in choice prompt are updated. + const [newItems] = this._generateSelectItems(aElement); + prompt.update({ + type: "choice", + mode: aElement.multiple ? "multiple" : "single", + choices: newItems, + }); + }, 0); + const mut = new win.MutationObserver(() => { + deferredUpdate.arm(); + }); + mut.observe(aElement, { + childList: true, + subtree: true, + attributes: true, + }); + + const dismissPrompt = () => prompt.dismiss(); + aElement.addEventListener("blur", dismissPrompt, { mozSystemGroup: true }); + const hidedropdown = event => { + if (aElement === event.target) { + prompt.dismiss(); + } + }; + const chromeEventHandler = aElement.ownerGlobal.docShell.chromeEventHandler; + chromeEventHandler.addEventListener("mozhidedropdown", hidedropdown, { + mozSystemGroup: true, + }); + + prompt.asyncShowPrompt( + { + type: "choice", + mode: aElement.multiple ? "multiple" : "single", + choices: items, + }, + result => { + deferredUpdate.disarm(); + mut.disconnect(); + aElement.removeEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + chromeEventHandler.removeEventListener( + "mozhidedropdown", + hidedropdown, + { mozSystemGroup: true } + ); + + if (aIsDropDown) { + aElement.openInParentProcess = false; + } + // OK: result + // Cancel: !result + if (!result || result.choices === undefined) { + return; + } + + const [, map, id] = this._generateSelectItems(aElement); + let dispatchEvents = false; + if (!aElement.multiple) { + const elem = map[result.choices[0]]; + if (elem && win.HTMLOptionElement.isInstance(elem)) { + dispatchEvents = !elem.selected; + elem.selected = true; + } else { + console.error("Invalid id for select result: " + result.choices[0]); + } + } else { + for (let i = 0; i < id; i++) { + const elem = map[i]; + const index = result.choices.indexOf(String(i)); + if ( + win.HTMLOptionElement.isInstance(elem) && + elem.selected !== index >= 0 + ) { + // Current selected is not the same as the new selected state. + dispatchEvents = true; + elem.selected = !elem.selected; + } + result.choices[index] = undefined; + } + for (let i = 0; i < result.choices.length; i++) { + if (result.choices[i] !== undefined && result.choices[i] !== null) { + console.error( + "Invalid id for select result: " + result.choices[i] + ); + break; + } + } + } + + if (dispatchEvents) { + this._dispatchEvents(aElement); + } + } + ); + } + + _handleDateTime(aElement) { + const win = aElement.ownerGlobal; + const prompt = new lazy.GeckoViewPrompter(win); + + const chromeEventHandler = aElement.ownerGlobal.docShell.chromeEventHandler; + const dismissPrompt = () => prompt.dismiss(); + // Some controls don't have UA widget (bug 888320) + { + const dateTimeBoxElement = aElement.dateTimeBoxElement; + if (["month", "week"].includes(aElement.type) && !dateTimeBoxElement) { + aElement.addEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + } else { + chromeEventHandler.addEventListener( + "MozCloseDateTimePicker", + dismissPrompt + ); + + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: true }) + ); + } + } + + prompt.asyncShowPrompt( + { + type: "datetime", + mode: aElement.type, + value: aElement.value, + min: aElement.min, + max: aElement.max, + step: aElement.step, + }, + result => { + // Some controls don't have UA widget (bug 888320) + const dateTimeBoxElement = aElement.dateTimeBoxElement; + if (["month", "week"].includes(aElement.type) && !dateTimeBoxElement) { + aElement.removeEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + } else { + chromeEventHandler.removeEventListener( + "MozCloseDateTimePicker", + dismissPrompt + ); + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: false }) + ); + } + + // OK: result + // Cancel: !result + if ( + !result || + result.datetime === undefined || + result.datetime === aElement.value + ) { + return; + } + aElement.value = result.datetime; + this._dispatchEvents(aElement); + } + ); + } + + _dispatchEvents(aElement) { + // Fire both "input" and "change" events for <select> and <input> for + // date/time. + aElement.dispatchEvent( + new aElement.ownerGlobal.Event("input", { bubbles: true, composed: true }) + ); + aElement.dispatchEvent( + new aElement.ownerGlobal.Event("change", { bubbles: true }) + ); + } + + _handlePopupBlocked(aEvent) { + const dwi = aEvent.requestingWindow; + const popupWindowURISpec = aEvent.popupWindowURI + ? aEvent.popupWindowURI.displaySpec + : "about:blank"; + + const prompt = new lazy.GeckoViewPrompter(aEvent.requestingWindow); + prompt.asyncShowPrompt( + { + type: "popup", + targetUri: popupWindowURISpec, + }, + ({ response }) => { + if (response && dwi) { + dwi.open( + popupWindowURISpec, + aEvent.popupWindowName, + aEvent.popupWindowFeatures + ); + } + } + ); + } + + /* ---------- nsIPromptFactory ---------- */ + getPrompt(aDOMWin, aIID) { + // Delegated to login manager here, which in turn calls back into us via nsIPromptService. + if (aIID.equals(Ci.nsIAuthPrompt2) || aIID.equals(Ci.nsIAuthPrompt)) { + try { + const pwmgr = Cc[ + "@mozilla.org/passwordmanager/authpromptfactory;1" + ].getService(Ci.nsIPromptFactory); + return pwmgr.getPrompt(aDOMWin, aIID); + } catch (e) { + console.error("Delegation to password manager failed:", e); + } + } + + const p = new PromptDelegate(aDOMWin); + p.QueryInterface(aIID); + return p; + } + + /* ---------- private memebers ---------- */ + + // nsIPromptService methods proxy to our Prompt class + callProxy(aMethod, aArguments) { + const prompt = new PromptDelegate(aArguments[0]); + let promptArgs; + if (BrowsingContext.isInstance(aArguments[0])) { + // Called by BrowsingContext prompt method, strip modalType. + [, , /*browsingContext*/ /*modalType*/ ...promptArgs] = aArguments; + } else { + [, /*domWindow*/ ...promptArgs] = aArguments; + } + return prompt[aMethod].apply(prompt, promptArgs); + } + + /* ---------- nsIPromptService ---------- */ + + alert() { + return this.callProxy("alert", arguments); + } + alertBC() { + return this.callProxy("alert", arguments); + } + alertCheck() { + return this.callProxy("alertCheck", arguments); + } + alertCheckBC() { + return this.callProxy("alertCheck", arguments); + } + confirm() { + return this.callProxy("confirm", arguments); + } + confirmBC() { + return this.callProxy("confirm", arguments); + } + confirmCheck() { + return this.callProxy("confirmCheck", arguments); + } + confirmCheckBC() { + return this.callProxy("confirmCheck", arguments); + } + confirmEx() { + return this.callProxy("confirmEx", arguments); + } + confirmExBC() { + return this.callProxy("confirmEx", arguments); + } + prompt() { + return this.callProxy("prompt", arguments); + } + promptBC() { + return this.callProxy("prompt", arguments); + } + promptUsernameAndPassword() { + return this.callProxy("promptUsernameAndPassword", arguments); + } + promptUsernameAndPasswordBC() { + return this.callProxy("promptUsernameAndPassword", arguments); + } + promptPassword() { + return this.callProxy("promptPassword", arguments); + } + promptPasswordBC() { + return this.callProxy("promptPassword", arguments); + } + select() { + return this.callProxy("select", arguments); + } + selectBC() { + return this.callProxy("select", arguments); + } + promptAuth() { + return this.callProxy("promptAuth", arguments); + } + promptAuthBC() { + return this.callProxy("promptAuth", arguments); + } + asyncPromptAuth() { + return this.callProxy("asyncPromptAuth", arguments); + } + confirmUserPaste() { + return lazy.GeckoViewClipboardPermission.confirmUserPaste(...arguments); + } +} + +PromptFactory.prototype.classID = Components.ID( + "{076ac188-23c1-4390-aa08-7ef1f78ca5d9}" +); +PromptFactory.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPromptFactory", + "nsIPromptService", +]); + +class PromptDelegate { + constructor(aParent) { + this._prompter = new lazy.GeckoViewPrompter(aParent); + } + + BUTTON_TYPE_POSITIVE = 0; + BUTTON_TYPE_NEUTRAL = 1; + BUTTON_TYPE_NEGATIVE = 2; + + /* ---------- internal methods ---------- */ + + _addText(aTitle, aText, aMsg) { + return Object.assign(aMsg, { + title: aTitle, + msg: aText, + }); + } + + _addCheck(aCheckMsg, aCheckState, aMsg) { + return Object.assign(aMsg, { + hasCheck: !!aCheckMsg, + checkMsg: aCheckMsg, + checkValue: aCheckState && aCheckState.value, + }); + } + + /* ---------- nsIPrompt ---------- */ + + alert(aTitle, aText) { + this.alertCheck(aTitle, aText); + } + + alertCheck(aTitle, aText, aCheckMsg, aCheckState) { + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "alert", + }) + ) + ); + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + } + + confirm(aTitle, aText) { + // Button 0 is OK. + return this.confirmCheck(aTitle, aText); + } + + confirmCheck(aTitle, aText, aCheckMsg, aCheckState) { + // Button 0 is OK. + return ( + this.confirmEx( + aTitle, + aText, + Ci.nsIPrompt.STD_OK_CANCEL_BUTTONS, + /* aButton0 */ null, + /* aButton1 */ null, + /* aButton2 */ null, + aCheckMsg, + aCheckState + ) == 0 + ); + } + + confirmEx( + aTitle, + aText, + aButtonFlags, + aButton0, + aButton1, + aButton2, + aCheckMsg, + aCheckState + ) { + const btnMap = Array(3).fill(null); + const btnTitle = Array(3).fill(null); + const btnCustomTitle = Array(3).fill(null); + const savedButtonId = []; + for (let i = 0; i < 3; i++) { + const btnFlags = aButtonFlags >> (i * 8); + switch (btnFlags & 0xff) { + case Ci.nsIPrompt.BUTTON_TITLE_OK: + btnMap[this.BUTTON_TYPE_POSITIVE] = i; + btnTitle[this.BUTTON_TYPE_POSITIVE] = "ok"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_CANCEL: + btnMap[this.BUTTON_TYPE_NEGATIVE] = i; + btnTitle[this.BUTTON_TYPE_NEGATIVE] = "cancel"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_YES: + btnMap[this.BUTTON_TYPE_POSITIVE] = i; + btnTitle[this.BUTTON_TYPE_POSITIVE] = "yes"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_NO: + btnMap[this.BUTTON_TYPE_NEGATIVE] = i; + btnTitle[this.BUTTON_TYPE_NEGATIVE] = "no"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_IS_STRING: + // We don't know if this is positive/negative/neutral, so save for later. + savedButtonId.push(i); + break; + case Ci.nsIPrompt.BUTTON_TITLE_SAVE: + case Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE: + case Ci.nsIPrompt.BUTTON_TITLE_REVERT: + // Not supported; fall-through. + default: + break; + } + } + + // Put saved buttons into available slots. + for (let i = 0; i < 3 && savedButtonId.length; i++) { + if (btnMap[i] === null) { + btnMap[i] = savedButtonId.shift(); + btnTitle[i] = "custom"; + btnCustomTitle[i] = [aButton0, aButton1, aButton2][btnMap[i]]; + } + } + + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "button", + btnTitle, + btnCustomTitle, + }) + ) + ); + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + return result && result.button in btnMap ? btnMap[result.button] : -1; + } + + prompt(aTitle, aText, aValue, aCheckMsg, aCheckState) { + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "text", + value: aValue.value, + }) + ) + ); + // OK: result && result.text !== undefined + // Cancel: result && result.text === undefined + // Error: !result + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + if (!result || result.text === undefined) { + return false; + } + aValue.value = result.text || ""; + return true; + } + + promptPassword(aTitle, aText, aPassword) { + return this._promptUsernameAndPassword( + aTitle, + aText, + /* aUsername */ undefined, + aPassword + ); + } + + promptUsernameAndPassword(aTitle, aText, aUsername, aPassword) { + const msg = { + type: "auth", + mode: aUsername ? "auth" : "password", + options: { + flags: aUsername ? 0 : Ci.nsIAuthInformation.ONLY_PASSWORD, + username: aUsername ? aUsername.value : undefined, + password: aPassword.value, + }, + }; + const result = this._prompter.showPrompt(this._addText(aTitle, aText, msg)); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + if (!result || result.password === undefined) { + return false; + } + if (aUsername) { + aUsername.value = result.username || ""; + } + aPassword.value = result.password || ""; + return true; + } + + select(aTitle, aText, aSelectList, aOutSelection) { + const choices = Array.prototype.map.call(aSelectList, (item, index) => ({ + id: String(index), + label: item, + disabled: false, + selected: false, + })); + const result = this._prompter.showPrompt( + this._addText(aTitle, aText, { + type: "choice", + mode: "single", + choices, + }) + ); + // OK: result + // Cancel: !result + if (!result || result.choices === undefined) { + return false; + } + aOutSelection.value = Number(result.choices[0]); + return true; + } + + _getAuthMsg(aChannel, aLevel, aAuthInfo) { + let username; + if ( + aAuthInfo.flags & Ci.nsIAuthInformation.NEED_DOMAIN && + aAuthInfo.domain + ) { + username = aAuthInfo.domain + "\\" + aAuthInfo.username; + } else { + username = aAuthInfo.username; + } + return this._addText( + /* title */ null, + this._getAuthText(aChannel, aAuthInfo), + { + type: "auth", + mode: + aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD + ? "password" + : "auth", + options: { + flags: aAuthInfo.flags, + uri: aChannel && aChannel.URI.displaySpec, + level: aLevel, + username, + password: aAuthInfo.password, + }, + } + ); + } + + _fillAuthInfo(aAuthInfo, aResult) { + if (!aResult || aResult.password === undefined) { + return false; + } + + aAuthInfo.password = aResult.password || ""; + if (aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD) { + return true; + } + + const username = aResult.username || ""; + if (aAuthInfo.flags & Ci.nsIAuthInformation.NEED_DOMAIN) { + // Domain is separated from username by a backslash + var idx = username.indexOf("\\"); + if (idx >= 0) { + aAuthInfo.domain = username.substring(0, idx); + aAuthInfo.username = username.substring(idx + 1); + return true; + } + } + aAuthInfo.username = username; + return true; + } + + promptAuth(aChannel, aLevel, aAuthInfo) { + const result = this._prompter.showPrompt( + this._getAuthMsg(aChannel, aLevel, aAuthInfo) + ); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + return this._fillAuthInfo(aAuthInfo, result); + } + + async asyncPromptAuth(aChannel, aLevel, aAuthInfo) { + const result = await this._prompter.asyncShowPromptPromise( + this._getAuthMsg(aChannel, aLevel, aAuthInfo) + ); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + return this._fillAuthInfo(aAuthInfo, result); + } + + _getAuthText(aChannel, aAuthInfo) { + const isProxy = aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY; + const isPassOnly = aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD; + const isCrossOrig = + aAuthInfo.flags & Ci.nsIAuthInformation.CROSS_ORIGIN_SUB_RESOURCE; + + const username = aAuthInfo.username; + const authTarget = this._getAuthTarget(aChannel, aAuthInfo); + const { displayHost } = authTarget; + let { realm } = authTarget; + + // Suppress "the site says: $realm" when we synthesized a missing realm. + if (!aAuthInfo.realm && !isProxy) { + realm = ""; + } + + // Trim obnoxiously long realms. + if (realm.length > 50) { + realm = realm.substring(0, 50) + "\u2026"; + } + + const bundle = Services.strings.createBundle( + "chrome://global/locale/commonDialogs.properties" + ); + let text; + if (isProxy) { + text = bundle.formatStringFromName("EnterLoginForProxy3", [ + realm, + displayHost, + ]); + } else if (isPassOnly) { + text = bundle.formatStringFromName("EnterPasswordFor", [ + username, + displayHost, + ]); + } else if (isCrossOrig) { + text = bundle.formatStringFromName("EnterUserPasswordForCrossOrigin2", [ + displayHost, + ]); + } else if (!realm) { + text = bundle.formatStringFromName("EnterUserPasswordFor2", [ + displayHost, + ]); + } else { + text = bundle.formatStringFromName("EnterLoginForRealm3", [ + realm, + displayHost, + ]); + } + + return text; + } + + _getAuthTarget(aChannel, aAuthInfo) { + // If our proxy is demanding authentication, don't use the + // channel's actual destination. + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { + if (!(aChannel instanceof Ci.nsIProxiedChannel)) { + throw new Error("proxy auth needs nsIProxiedChannel"); + } + const info = aChannel.proxyInfo; + if (!info) { + throw new Error("proxy auth needs nsIProxyInfo"); + } + // Proxies don't have a scheme, but we'll use "moz-proxy://" + // so that it's more obvious what the login is for. + const idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + const displayHost = + "moz-proxy://" + + idnService.convertUTF8toACE(info.host) + + ":" + + info.port; + let realm = aAuthInfo.realm; + if (!realm) { + realm = displayHost; + } + return { displayHost, realm }; + } + + const displayHost = + aChannel.URI.scheme + "://" + aChannel.URI.displayHostPort; + // If a HTTP WWW-Authenticate header specified a realm, that value + // will be available here. If it wasn't set or wasn't HTTP, we'll use + // the formatted hostname instead. + let realm = aAuthInfo.realm; + if (!realm) { + realm = displayHost; + } + return { displayHost, realm }; + } +} + +PromptDelegate.prototype.QueryInterface = ChromeUtils.generateQI(["nsIPrompt"]); diff --git a/mobile/android/components/geckoview/GeckoViewPrompter.sys.mjs b/mobile/android/components/geckoview/GeckoViewPrompter.sys.mjs new file mode 100644 index 0000000000..f81c155678 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPrompter.sys.mjs @@ -0,0 +1,206 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPrompter"); + +export class GeckoViewPrompter { + constructor(aParent) { + this.id = Services.uuid.generateUUID().toString().slice(1, -1); // Discard surrounding braces + + if (aParent) { + if (Window.isInstance(aParent)) { + this._domWin = aParent; + } else if (aParent.window) { + this._domWin = aParent.window; + } else { + this._domWin = + aParent.embedderElement && aParent.embedderElement.ownerGlobal; + } + } + + if (!this._domWin) { + this._domWin = Services.wm.getMostRecentWindow("navigator:geckoview"); + } + + this._innerWindowId = + this._domWin?.browsingContext.currentWindowContext.innerWindowId; + } + + get domWin() { + return this._domWin; + } + + get prompterActor() { + const actor = this.domWin?.windowGlobalChild.getActor("GeckoViewPrompter"); + return actor; + } + + _changeModalState(aEntering) { + if (!this._domWin) { + // Allow not having a DOM window. + return true; + } + // Accessing the document object can throw if this window no longer exists. See bug 789888. + try { + const winUtils = this._domWin.windowUtils; + if (!aEntering) { + winUtils.leaveModalState(); + } + + const event = this._domWin.document.createEvent("Events"); + event.initEvent( + aEntering ? "DOMWillOpenModalDialog" : "DOMModalDialogClosed", + true, + true + ); + winUtils.dispatchEventToChromeOnly(this._domWin, event); + + if (aEntering) { + winUtils.enterModalState(); + } + return true; + } catch (ex) { + console.error("Failed to change modal state:", ex); + } + return false; + } + + _dismissUi() { + this.prompterActor?.dismissPrompt(this); + } + + accept(aInputText = this.inputText) { + if (this.callback) { + let acceptMsg = {}; + switch (this.message.type) { + case "alert": + acceptMsg = null; + break; + case "button": + acceptMsg.button = 0; + break; + case "text": + acceptMsg.text = aInputText; + break; + default: + acceptMsg = null; + break; + } + this.callback(acceptMsg); + // Notify the UI that this prompt should be hidden. + this._dismissUi(); + } + } + + dismiss() { + this.callback(null); + // Notify the UI that this prompt should be hidden. + this._dismissUi(); + } + + getPromptType() { + switch (this.message.type) { + case "alert": + return this.message.checkValue ? "alertCheck" : "alert"; + case "button": + return this.message.checkValue ? "confirmCheck" : "confirm"; + case "text": + return this.message.checkValue ? "promptCheck" : "prompt"; + default: + return this.message.type; + } + } + + getPromptText() { + return this.message.msg; + } + + getInputText() { + return this.inputText; + } + + setInputText(aInput) { + this.inputText = aInput; + } + + /** + * Shows a native prompt, and then spins the event loop for this thread while we wait + * for a response + */ + showPrompt(aMsg) { + let result = undefined; + if (!this._domWin || !this._changeModalState(/* aEntering */ true)) { + return result; + } + try { + this.asyncShowPrompt(aMsg, res => (result = res)); + + // Spin this thread while we wait for a result + Services.tm.spinEventLoopUntil( + "GeckoViewPrompter.jsm:showPrompt", + () => this._domWin.closed || result !== undefined + ); + } finally { + this._changeModalState(/* aEntering */ false); + } + return result; + } + + checkInnerWindow() { + // Checks that the innerWindow where this prompt was created still matches + // the current innerWindow. + // This checks will fail if the page navigates away, making this prompt + // obsolete. + return ( + this._innerWindowId === + this._domWin.browsingContext.currentWindowContext.innerWindowId + ); + } + + asyncShowPromptPromise(aMsg) { + return new Promise(resolve => { + this.asyncShowPrompt(aMsg, resolve); + }); + } + + async asyncShowPrompt(aMsg, aCallback) { + this.message = aMsg; + this.inputText = aMsg.value; + this.callback = aCallback; + + aMsg.id = this.id; + + let response = null; + try { + if (this.checkInnerWindow()) { + response = await this.prompterActor.prompt(this, aMsg); + } + } catch (error) { + // Nothing we can do really, we will treat this as a dismiss. + warn`Error while prompting: ${error}`; + } + + if (!this.checkInnerWindow()) { + // Page has navigated away, let's dismiss the prompt + aCallback(null); + } else { + aCallback(response); + } + // This callback object is tied to the Java garbage collector because + // it is invoked from Java. Manually release the target callback + // here; otherwise we may hold onto resources for too long, because + // we would be relying on both the Java and the JS garbage collectors + // to run. + aMsg = undefined; + aCallback = undefined; + } + + update(aMsg) { + this.message = aMsg; + aMsg.id = this.id; + this.prompterActor?.updatePrompt(aMsg); + } +} diff --git a/mobile/android/components/geckoview/GeckoViewPush.sys.mjs b/mobile/android/components/geckoview/GeckoViewPush.sys.mjs new file mode 100644 index 0000000000..d6625942e8 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPush.sys.mjs @@ -0,0 +1,264 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPush"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs", +}); + +// Observer notification topics for push messages and subscription status +// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed +// on `nsIPushService` so that JS callers only need to import this service. +const OBSERVER_TOPIC_PUSH = "push-message"; +const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change"; +const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified"; + +function createSubscription({ + scope, + principal, + browserPublicKey, + authSecret, + endpoint, + appServerKey, +}) { + const decodedBrowserKey = ChromeUtils.base64URLDecode(browserPublicKey, { + padding: "ignore", + }); + const decodedAuthSecret = ChromeUtils.base64URLDecode(authSecret, { + padding: "ignore", + }); + + return new PushSubscription({ + endpoint, + scope, + p256dhKey: decodedBrowserKey, + authenticationSecret: decodedAuthSecret, + appServerKey, + }); +} + +function scopeWithAttrs(scope, attrs) { + return scope + ChromeUtils.originAttributesToSuffix(attrs); +} + +export class PushService { + constructor() { + this.wrappedJSObject = this; + } + + pushTopic = OBSERVER_TOPIC_PUSH; + subscriptionChangeTopic = OBSERVER_TOPIC_SUBSCRIPTION_CHANGE; + subscriptionModifiedTopic = OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED; + + // nsIObserver methods + + observe(subject, topic, data) {} + + // nsIPushService methods + + subscribe(scope, principal, callback) { + this.subscribeWithKey(scope, principal, null, callback); + } + + async subscribeWithKey(scope, principal, appServerKey, callback) { + const keyView = new Uint8Array(appServerKey); + + if (appServerKey != null) { + try { + await lazy.PushCrypto.validateAppServerKey(keyView); + } catch (error) { + callback.onPushSubscription(Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR, null); + return; + } + } + + try { + const response = await lazy.EventDispatcher.instance.sendRequestForResult( + { + type: "GeckoView:PushSubscribe", + scope: scopeWithAttrs(scope, principal.originAttributes), + appServerKey: appServerKey + ? ChromeUtils.base64URLEncode(keyView, { + pad: true, + }) + : null, + } + ); + + let subscription = null; + if (response) { + subscription = createSubscription({ + ...response, + scope, + principal, + appServerKey, + }); + } + + callback.onPushSubscription(Cr.NS_OK, subscription); + } catch (e) { + callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null); + } + } + + async unsubscribe(scope, principal, callback) { + try { + await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:PushUnsubscribe", + scope: scopeWithAttrs(scope, principal.originAttributes), + }); + + callback.onUnsubscribe(Cr.NS_OK, true); + } catch (e) { + callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false); + } + } + + async getSubscription(scope, principal, callback) { + try { + const response = await lazy.EventDispatcher.instance.sendRequestForResult( + { + type: "GeckoView:PushGetSubscription", + scope: scopeWithAttrs(scope, principal.originAttributes), + } + ); + + let subscription = null; + if (response) { + subscription = createSubscription({ + ...response, + scope, + principal, + }); + } + + callback.onPushSubscription(Cr.NS_OK, subscription); + } catch (e) { + callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null); + } + } + + clearForDomain(domain, callback) { + callback.onClear(Cr.NS_OK); + } + + // nsIPushQuotaManager methods + + notificationForOriginShown(origin) {} + + notificationForOriginClosed(origin) {} + + // nsIPushErrorReporter methods + + reportDeliveryError(messageId, reason) {} +} + +PushService.prototype.classID = Components.ID( + "{a54d84d7-98a4-4fec-b664-e42e512ae9cc}" +); +PushService.prototype.contractID = "@mozilla.org/push/Service;1"; +PushService.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + "nsIPushService", + "nsIPushQuotaManager", + "nsIPushErrorReporter", +]); + +/** `PushSubscription` instances are passed to all subscription callbacks. */ +class PushSubscription { + constructor(props) { + this._props = props; + } + + /** The URL for sending messages to this subscription. */ + get endpoint() { + return this._props.endpoint; + } + + /** The last time a message was sent to this subscription. */ + get lastPush() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** The total number of messages sent to this subscription. */ + get pushCount() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * The app will take care of throttling, so we don't + * care about the quota stuff here. + */ + get quota() { + return -1; + } + + /** + * Indicates whether this subscription was created with the system principal. + * System subscriptions are exempt from the background message quota and + * permission checks. + */ + get isSystemSubscription() { + return false; + } + + /** The private key used to decrypt incoming push messages, in JWK format */ + get p256dhPrivateKey() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Indicates whether this subscription is subject to the background message + * quota. + */ + quotaApplies() { + return false; + } + + /** + * Indicates whether this subscription exceeded the background message quota, + * or the user revoked the notification permission. The caller must request a + * new subscription to continue receiving push messages. + */ + isExpired() { + return false; + } + + /** + * Returns a key for encrypting messages sent to this subscription. JS + * callers receive the key buffer as a return value, while C++ callers + * receive the key size and buffer as out parameters. + */ + getKey(name) { + switch (name) { + case "p256dh": + return this._getRawKey(this._props.p256dhKey); + + case "auth": + return this._getRawKey(this._props.authenticationSecret); + + case "appServer": + return this._getRawKey(this._props.appServerKey); + } + return []; + } + + _getRawKey(key) { + if (!key) { + return []; + } + return new Uint8Array(key); + } +} + +PushSubscription.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPushSubscription", +]); diff --git a/mobile/android/components/geckoview/GeckoViewStartup.sys.mjs b/mobile/android/components/geckoview/GeckoViewStartup.sys.mjs new file mode 100644 index 0000000000..734db47887 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewStartup.sys.mjs @@ -0,0 +1,364 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { DelayedInit } from "resource://gre/modules/DelayedInit.sys.mjs"; +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + PdfJs: "resource://pdf.js/PdfJs.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("Startup"); + +function InitLater(fn, object, name) { + return DelayedInit.schedule(fn, object, name, 15000 /* 15s max wait */); +} + +const JSPROCESSACTORS = { + GeckoViewPermissionProcess: { + parent: { + esModuleURI: + "resource:///actors/GeckoViewPermissionProcessParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/GeckoViewPermissionProcessChild.sys.mjs", + observers: [ + "getUserMedia:ask-device-permission", + "getUserMedia:request", + "recording-device-events", + "PeerConnection:request", + ], + }, + }, +}; + +const JSWINDOWACTORS = { + LoadURIDelegate: { + parent: { + esModuleURI: "resource:///actors/LoadURIDelegateParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/LoadURIDelegateChild.sys.mjs", + }, + messageManagerGroups: ["browsers"], + }, + GeckoViewPermission: { + parent: { + esModuleURI: "resource:///actors/GeckoViewPermissionParent.sys.mjs", + }, + child: { + esModuleURI: "resource:///actors/GeckoViewPermissionChild.sys.mjs", + }, + allFrames: true, + includeChrome: true, + }, + GeckoViewPrompt: { + child: { + esModuleURI: "resource:///actors/GeckoViewPromptChild.sys.mjs", + events: { + click: { capture: false, mozSystemGroup: true }, + contextmenu: { capture: false, mozSystemGroup: true }, + mozshowdropdown: {}, + "mozshowdropdown-sourcetouch": {}, + MozOpenDateTimePicker: {}, + DOMPopupBlocked: { capture: false, mozSystemGroup: true }, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + GeckoViewFormValidation: { + child: { + esModuleURI: "resource:///actors/GeckoViewFormValidationChild.sys.mjs", + events: { + MozInvalidForm: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + GeckoViewPdfjs: { + parent: { + esModuleURI: "resource://pdf.js/GeckoViewPdfjsParent.sys.mjs", + }, + child: { + esModuleURI: "resource://pdf.js/GeckoViewPdfjsChild.sys.mjs", + }, + allFrames: true, + }, +}; + +export class GeckoViewStartup { + /* ---------- nsIObserver ---------- */ + observe(aSubject, aTopic, aData) { + debug`observe: ${aTopic}`; + switch (aTopic) { + case "content-process-ready-for-script": + case "app-startup": { + GeckoViewUtils.addLazyGetter(this, "GeckoViewConsole", { + module: "resource://gre/modules/GeckoViewConsole.sys.mjs", + }); + + GeckoViewUtils.addLazyGetter(this, "GeckoViewStorageController", { + module: "resource://gre/modules/GeckoViewStorageController.sys.mjs", + ged: [ + "GeckoView:ClearData", + "GeckoView:ClearSessionContextData", + "GeckoView:ClearHostData", + "GeckoView:ClearBaseDomainData", + "GeckoView:GetAllPermissions", + "GeckoView:GetPermissionsByURI", + "GeckoView:SetPermission", + "GeckoView:SetPermissionByURI", + "GeckoView:GetCookieBannerModeForDomain", + "GeckoView:SetCookieBannerModeForDomain", + "GeckoView:RemoveCookieBannerModeForDomain", + ], + }); + + GeckoViewUtils.addLazyGetter(this, "GeckoViewPushController", { + module: "resource://gre/modules/GeckoViewPushController.sys.mjs", + ged: ["GeckoView:PushEvent", "GeckoView:PushSubscriptionChanged"], + }); + + GeckoViewUtils.addLazyPrefObserver( + { + name: "geckoview.console.enabled", + default: false, + }, + { + handler: _ => this.GeckoViewConsole, + } + ); + + // Parent process only + if ( + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT + ) { + lazy.ActorManagerParent.addJSWindowActors(JSWINDOWACTORS); + lazy.ActorManagerParent.addJSProcessActors(JSPROCESSACTORS); + + if (Services.appinfo.sessionHistoryInParent) { + GeckoViewUtils.addLazyGetter(this, "GeckoViewSessionStore", { + module: "resource://gre/modules/GeckoViewSessionStore.sys.mjs", + observers: [ + "browsing-context-did-set-embedder", + "browsing-context-discarded", + ], + }); + } + + GeckoViewUtils.addLazyGetter(this, "GeckoViewWebExtension", { + module: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + ged: [ + "GeckoView:ActionDelegate:Attached", + "GeckoView:BrowserAction:Click", + "GeckoView:PageAction:Click", + "GeckoView:RegisterWebExtension", + "GeckoView:UnregisterWebExtension", + "GeckoView:WebExtension:CancelInstall", + "GeckoView:WebExtension:Disable", + "GeckoView:WebExtension:Enable", + "GeckoView:WebExtension:EnsureBuiltIn", + "GeckoView:WebExtension:Get", + "GeckoView:WebExtension:Install", + "GeckoView:WebExtension:InstallBuiltIn", + "GeckoView:WebExtension:List", + "GeckoView:WebExtension:PortDisconnect", + "GeckoView:WebExtension:PortMessageFromApp", + "GeckoView:WebExtension:SetPBAllowed", + "GeckoView:WebExtension:Uninstall", + "GeckoView:WebExtension:Update", + "GeckoView:WebExtension:EnableProcessSpawning", + "GeckoView:WebExtension:DisableProcessSpawning", + ], + observers: [ + "devtools-installed-addon", + "testing-installed-addon", + "testing-uninstalled-addon", + ], + }); + + GeckoViewUtils.addLazyGetter(this, "ChildCrashHandler", { + module: "resource://gre/modules/ChildCrashHandler.sys.mjs", + observers: [ + "compositor:process-aborted", + "ipc:content-created", + "ipc:content-shutdown", + "process-type-set", + ], + }); + + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:StorageDelegate:Attached", + ]); + } + + GeckoViewUtils.addLazyGetter(this, "GeckoViewTranslationsSettings", { + module: "resource://gre/modules/GeckoViewTranslations.sys.mjs", + ged: [ + "GeckoView:Translations:IsTranslationEngineSupported", + "GeckoView:Translations:PreferredLanguages", + "GeckoView:Translations:ManageModel", + "GeckoView:Translations:TranslationInformation", + "GeckoView:Translations:ModelInformation", + "GeckoView:Translations:GetLanguageSetting", + "GeckoView:Translations:GetLanguageSettings", + "GeckoView:Translations:SetLanguageSettings", + "GeckoView:Translations:GetNeverTranslateSpecifiedSites", + "GeckoView:Translations:SetNeverTranslateSpecifiedSite", + "GeckoView:Translations:GetTranslateDownloadSize", + ], + }); + + break; + } + + case "profile-after-change": { + GeckoViewUtils.addLazyGetter(this, "GeckoViewRemoteDebugger", { + module: "resource://gre/modules/GeckoViewRemoteDebugger.sys.mjs", + init: gvrd => gvrd.onInit(), + }); + + GeckoViewUtils.addLazyPrefObserver( + { + name: "devtools.debugger.remote-enabled", + default: false, + }, + { + handler: _ => this.GeckoViewRemoteDebugger, + } + ); + + GeckoViewUtils.addLazyGetter(this, "DownloadTracker", { + module: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", + ged: ["GeckoView:WebExtension:DownloadChanged"], + }); + + ChromeUtils.importESModule( + "resource://gre/modules/NotificationDB.sys.mjs" + ); + + // Listen for global EventDispatcher messages + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:ResetUserPrefs", + "GeckoView:SetDefaultPrefs", + "GeckoView:SetLocale", + "GeckoView:InitialForeground", + ]); + + Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); + Services.obs.addObserver(this, "handlersvc-store-initialized"); + + Services.obs.notifyObservers(null, "geckoview-startup-complete"); + break; + } + case "browser-idle-startup-tasks-finished": { + // TODO bug 1730026: when an alternative is introduced that runs once, + // replace this observer topic with that alternative. + // This only needs to happen once during startup. + Services.obs.removeObserver(this, aTopic); + // Notify the start up crash tracker that the browser has successfully + // started up so the startup cache isn't rebuilt on next startup. + Services.startup.trackStartupCrashEnd(); + break; + } + case "handlersvc-store-initialized": { + // Initialize PdfJs when running in-process and remote. This only + // happens once since PdfJs registers global hooks. If the PdfJs + // extension is installed the init method below will be overridden + // leaving initialization to the extension. + // parent only: configure default prefs, set up pref observers, register + // pdf content handler, and initializes parent side message manager + // shim for privileged api access. + try { + lazy.PdfJs.init(this._isNewProfile); + } catch {} + break; + } + } + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent}`; + + switch (aEvent) { + case "GeckoView:InitialForeground": { + // ExtensionProcessCrashObserver observes this topic to determine when + // the app goes into the foreground for the first time. This could be useful + // when the app is initially created in the background because, in this case, + // the "application-foreground" topic isn't notified when the application is + // moved into the foreground later. That is because "application-foreground" + // is only going to be notified when the application was first paused. + Services.obs.notifyObservers(null, "geckoview-initial-foreground"); + break; + } + case "GeckoView:ResetUserPrefs": { + for (const name of aData.names) { + Services.prefs.clearUserPref(name); + } + break; + } + case "GeckoView:SetDefaultPrefs": { + const prefs = Services.prefs.getDefaultBranch(""); + for (const [name, value] of Object.entries(aData)) { + try { + switch (typeof value) { + case "string": + prefs.setStringPref(name, value); + break; + case "number": + prefs.setIntPref(name, value); + break; + case "boolean": + prefs.setBoolPref(name, value); + break; + default: + throw new Error( + `Can't set ${name} to ${value}. Type ${typeof value} is not supported.` + ); + } + } catch (e) { + warn`Failed to set preference ${name}: ${e}`; + } + } + break; + } + case "GeckoView:SetLocale": + if (aData.requestedLocales) { + Services.locale.requestedLocales = aData.requestedLocales; + } + const pls = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + pls.data = aData.acceptLanguages; + Services.prefs.setComplexValue( + "intl.accept_languages", + Ci.nsIPrefLocalizedString, + pls + ); + break; + + case "GeckoView:StorageDelegate:Attached": + InitLater(() => { + const loginDetection = Cc[ + "@mozilla.org/login-detection-service;1" + ].createInstance(Ci.nsILoginDetectionService); + loginDetection.init(); + }); + break; + } + } +} + +GeckoViewStartup.prototype.classID = Components.ID( + "{8e993c34-fdd6-432c-967e-f995d888777f}" +); +GeckoViewStartup.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", +]); diff --git a/mobile/android/components/geckoview/GeckoViewStreamListener.cpp b/mobile/android/components/geckoview/GeckoViewStreamListener.cpp new file mode 100644 index 0000000000..71b9fadeb5 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewStreamListener.cpp @@ -0,0 +1,298 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; 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/. */ + +#include "GeckoViewStreamListener.h" + +#include "mozilla/fallible.h" +#include "nsIAsyncVerifyRedirectCallback.h" +#include "nsIChannelEventSink.h" +#include "nsIHttpChannel.h" +#include "nsIHttpHeaderVisitor.h" +#include "nsIInputStream.h" +#include "nsINSSErrorsService.h" +#include "nsITransportSecurityInfo.h" +#include "nsIWebProgressListener.h" +#include "nsIX509Cert.h" +#include "nsPrintfCString.h" + +#include "nsNetUtil.h" + +#include "JavaBuiltins.h" + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(GeckoViewStreamListener, nsIStreamListener, + nsIInterfaceRequestor, nsIChannelEventSink) + +class HeaderVisitor final : public nsIHttpHeaderVisitor { + public: + NS_DECL_THREADSAFE_ISUPPORTS + + explicit HeaderVisitor(java::WebResponse::Builder::Param aBuilder) + : mBuilder(aBuilder) {} + + NS_IMETHOD + VisitHeader(const nsACString& aHeader, const nsACString& aValue) override { + mBuilder->Header(aHeader, aValue); + return NS_OK; + } + + private: + virtual ~HeaderVisitor() {} + + const java::WebResponse::Builder::GlobalRef mBuilder; +}; + +NS_IMPL_ISUPPORTS(HeaderVisitor, nsIHttpHeaderVisitor) + +class StreamSupport final + : public java::GeckoInputStream::Support::Natives<StreamSupport> { + public: + typedef java::GeckoInputStream::Support::Natives<StreamSupport> Base; + using Base::AttachNative; + using Base::GetNative; + + explicit StreamSupport(java::GeckoInputStream::Support::Param aInstance, + nsIRequest* aRequest) + : mInstance(aInstance), mRequest(aRequest) {} + + void Close() { + mRequest->Cancel(NS_ERROR_ABORT); + mRequest->Resume(); + + // This is basically `delete this`, so don't run anything else! + Base::DisposeNative(mInstance); + } + + void Resume() { mRequest->Resume(); } + + private: + java::GeckoInputStream::Support::GlobalRef mInstance; + nsCOMPtr<nsIRequest> mRequest; +}; + +NS_IMETHODIMP +GeckoViewStreamListener::OnStartRequest(nsIRequest* aRequest) { + MOZ_ASSERT(!mStream); + + nsresult status; + aRequest->GetStatus(&status); + if (NS_FAILED(status)) { + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest); + CompleteWithError(status, channel); + return NS_OK; + } + + // We're expecting data later via OnDataAvailable, so create the stream now. + InitializeStreamSupport(aRequest); + + mStream = java::GeckoInputStream::New(mSupport); + + // Suspend the request immediately. It will be resumed when (if) someone + // tries to read the Java stream. + aRequest->Suspend(); + + nsresult rv = HandleWebResponse(aRequest); + if (NS_FAILED(rv)) { + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest); + CompleteWithError(rv, channel); + return NS_OK; + } + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewStreamListener::OnStopRequest(nsIRequest* aRequest, + nsresult aStatusCode) { + if (mStream) { + if (NS_FAILED(aStatusCode)) { + mStream->SendError(); + } else { + mStream->SendEof(); + } + } + return NS_OK; +} + +NS_IMETHODIMP GeckoViewStreamListener::OnDataAvailable( + nsIRequest* aRequest, nsIInputStream* aInputStream, uint64_t aOffset, + uint32_t aCount) { + MOZ_ASSERT(mStream); + + // We only need this for the ReadSegments call, the value is unused. + uint32_t countRead; + nsresult rv = + aInputStream->ReadSegments(WriteSegment, this, aCount, &countRead); + NS_ENSURE_SUCCESS(rv, rv); + return rv; +} + +NS_IMETHODIMP +GeckoViewStreamListener::GetInterface(const nsIID& aIID, void** aResultOut) { + if (aIID.Equals(NS_GET_IID(nsIChannelEventSink))) { + *aResultOut = static_cast<nsIChannelEventSink*>(this); + NS_ADDREF_THIS(); + return NS_OK; + } + + return NS_ERROR_NO_INTERFACE; +} + +NS_IMETHODIMP +GeckoViewStreamListener::AsyncOnChannelRedirect( + nsIChannel* aOldChannel, nsIChannel* aNewChannel, uint32_t flags, + nsIAsyncVerifyRedirectCallback* callback) { + callback->OnRedirectVerifyCallback(NS_OK); + return NS_OK; +} + +/* static */ +nsresult GeckoViewStreamListener::WriteSegment( + nsIInputStream* aInputStream, void* aClosure, const char* aFromSegment, + uint32_t aToOffset, uint32_t aCount, uint32_t* aWriteCount) { + GeckoViewStreamListener* self = + static_cast<GeckoViewStreamListener*>(aClosure); + MOZ_ASSERT(self); + MOZ_ASSERT(self->mStream); + + *aWriteCount = aCount; + + jni::ByteArray::LocalRef buffer = jni::ByteArray::New( + reinterpret_cast<signed char*>(const_cast<char*>(aFromSegment)), + *aWriteCount, fallible); + if (!buffer) { + return NS_ERROR_OUT_OF_MEMORY; + } + + if (NS_FAILED(self->mStream->AppendBuffer(buffer))) { + // The stream was closed or something, abort reading this channel. + return NS_ERROR_ABORT; + } + + return NS_OK; +} + +nsresult GeckoViewStreamListener::HandleWebResponse(nsIRequest* aRequest) { + nsresult rv; + + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + // URI + nsCOMPtr<nsIURI> uri; + rv = channel->GetURI(getter_AddRefs(uri)); + NS_ENSURE_SUCCESS(rv, rv); + + nsAutoCString uriSpec; + rv = uri->GetSpec(uriSpec); + NS_ENSURE_SUCCESS(rv, rv); + + java::WebResponse::Builder::LocalRef builder = + java::WebResponse::Builder::New(uriSpec); + + // Body stream + if (mStream) { + builder->Body(mStream); + } + + // Redirected + nsCOMPtr<nsILoadInfo> loadInfo = channel->LoadInfo(); + builder->Redirected(!loadInfo->RedirectChain().IsEmpty()); + + // Secure status + auto [certBytes, isSecure] = CertificateFromChannel(channel); + builder->IsSecure(isSecure); + if (certBytes) { + rv = builder->CertificateBytes(certBytes); + NS_ENSURE_SUCCESS(rv, rv); + } + + // We might need some additional info for response to http/https request + nsCOMPtr<nsIHttpChannel> httpChannel(do_QueryInterface(channel, &rv)); + if (httpChannel) { + // Status code + uint32_t statusCode; + rv = httpChannel->GetResponseStatus(&statusCode); + NS_ENSURE_SUCCESS(rv, rv); + builder->StatusCode(statusCode); + + // Headers + RefPtr<HeaderVisitor> visitor = new HeaderVisitor(builder); + rv = httpChannel->VisitResponseHeaders(visitor); + NS_ENSURE_SUCCESS(rv, rv); + } else { + // Headers for other responses + // try to provide some basic metadata about the response + nsString filename; + if (NS_SUCCEEDED(channel->GetContentDispositionFilename(filename))) { + builder->Header(jni::StringParam(u"content-disposition"_ns), + nsPrintfCString("attachment; filename=\"%s\"", + NS_ConvertUTF16toUTF8(filename).get())); + } + + nsCString contentType; + if (NS_SUCCEEDED(channel->GetContentType(contentType))) { + builder->Header(jni::StringParam(u"content-type"_ns), contentType); + } + + int64_t contentLength = 0; + if (NS_SUCCEEDED(channel->GetContentLength(&contentLength))) { + nsString contentLengthString; + contentLengthString.AppendInt(contentLength); + builder->Header(jni::StringParam(u"content-length"_ns), + contentLengthString); + } + } + + java::WebResponse::GlobalRef response = builder->Build(); + + SendWebResponse(response); + return NS_OK; +} + +void GeckoViewStreamListener::InitializeStreamSupport(nsIRequest* aRequest) { + StreamSupport::Init(); + + mSupport = java::GeckoInputStream::Support::New(); + StreamSupport::AttachNative( + mSupport, mozilla::MakeUnique<StreamSupport>(mSupport, aRequest)); +} + +std::tuple<jni::ByteArray::LocalRef, java::sdk::Boolean::LocalRef> +GeckoViewStreamListener::CertificateFromChannel(nsIChannel* aChannel) { + MOZ_ASSERT(aChannel); + + nsCOMPtr<nsITransportSecurityInfo> securityInfo; + aChannel->GetSecurityInfo(getter_AddRefs(securityInfo)); + if (!securityInfo) { + return std::make_tuple((jni::ByteArray::LocalRef) nullptr, + (java::sdk::Boolean::LocalRef) nullptr); + } + + uint32_t securityState = 0; + securityInfo->GetSecurityState(&securityState); + auto isSecure = securityState == nsIWebProgressListener::STATE_IS_SECURE + ? java::sdk::Boolean::TRUE() + : java::sdk::Boolean::FALSE(); + + nsCOMPtr<nsIX509Cert> cert; + securityInfo->GetServerCert(getter_AddRefs(cert)); + if (!cert) { + return std::make_tuple((jni::ByteArray::LocalRef) nullptr, + (java::sdk::Boolean::LocalRef) nullptr); + } + + nsTArray<uint8_t> derBytes; + nsresult rv = cert->GetRawDER(derBytes); + NS_ENSURE_SUCCESS(rv, + std::make_tuple((jni::ByteArray::LocalRef) nullptr, + (java::sdk::Boolean::LocalRef) nullptr)); + + auto certBytes = jni::ByteArray::New( + reinterpret_cast<const int8_t*>(derBytes.Elements()), derBytes.Length()); + + return std::make_tuple(certBytes, isSecure); +} diff --git a/mobile/android/components/geckoview/GeckoViewStreamListener.h b/mobile/android/components/geckoview/GeckoViewStreamListener.h new file mode 100644 index 0000000000..b42249f458 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewStreamListener.h @@ -0,0 +1,57 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; 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/. */ + +#ifndef GeckoViewStreamListener_h__ +#define GeckoViewStreamListener_h__ + +#include "nsIStreamListener.h" +#include "nsIInterfaceRequestor.h" +#include "nsIChannelEventSink.h" + +#include "mozilla/widget/EventDispatcher.h" +#include "mozilla/java/GeckoInputStreamNatives.h" +#include "mozilla/java/WebResponseWrappers.h" + +#include "JavaBuiltins.h" + +namespace mozilla { + +class GeckoViewStreamListener : public nsIStreamListener, + public nsIInterfaceRequestor, + public nsIChannelEventSink { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIINTERFACEREQUESTOR + NS_DECL_NSICHANNELEVENTSINK + + explicit GeckoViewStreamListener() {} + + static std::tuple<jni::ByteArray::LocalRef, java::sdk::Boolean::LocalRef> + CertificateFromChannel(nsIChannel* aChannel); + + protected: + virtual ~GeckoViewStreamListener() {} + + java::GeckoInputStream::GlobalRef mStream; + java::GeckoInputStream::Support::GlobalRef mSupport; + + void InitializeStreamSupport(nsIRequest* aRequest); + + static nsresult WriteSegment(nsIInputStream* aInputStream, void* aClosure, + const char* aFromSegment, uint32_t aToOffset, + uint32_t aCount, uint32_t* aWriteCount); + + virtual nsresult HandleWebResponse(nsIRequest* aRequest); + + virtual void SendWebResponse(java::WebResponse::Param aResponse) = 0; + + virtual void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) = 0; +}; + +} // namespace mozilla + +#endif // GeckoViewStreamListener_h__ diff --git a/mobile/android/components/geckoview/LoginStorageDelegate.sys.mjs b/mobile/android/components/geckoview/LoginStorageDelegate.sys.mjs new file mode 100644 index 0000000000..28916917ca --- /dev/null +++ b/mobile/android/components/geckoview/LoginStorageDelegate.sys.mjs @@ -0,0 +1,126 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", + LoginEntry: "resource://gre/modules/GeckoViewAutocomplete.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("LoginStorageDelegate"); + +// Sync with LoginSaveOption.Hint in Autocomplete.java. +const LoginStorageHint = { + NONE: 0, + GENERATED: 1 << 0, + LOW_CONFIDENCE: 1 << 1, +}; + +export class LoginStorageDelegate { + _createMessage({ dismissed, autoSavedLoginGuid }, aLogins) { + let hint = LoginStorageHint.NONE; + if (dismissed) { + hint |= LoginStorageHint.LOW_CONFIDENCE; + } + if (autoSavedLoginGuid) { + hint |= LoginStorageHint.GENERATED; + } + return { + // Sync with PromptController + type: "Autocomplete:Save:Login", + hint, + logins: aLogins, + }; + } + + promptToSavePassword( + aBrowser, + aLogin, + dismissed = false, + notifySaved = false + ) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + this._createMessage({ dismissed }, [ + lazy.LoginEntry.fromLoginInfo(aLogin), + ]), + result => { + const selectedLogin = result?.selection?.value; + + if (!selectedLogin) { + return; + } + + const loginInfo = lazy.LoginEntry.parse(selectedLogin).toLoginInfo(); + Services.obs.notifyObservers(loginInfo, "passwordmgr-prompt-save"); + + lazy.GeckoViewAutocomplete.onLoginSave(selectedLogin); + } + ); + + return { + dismiss() { + prompt.dismiss(); + }, + }; + } + + promptToChangePassword( + aBrowser, + aOldLogin, + aNewLogin, + dismissed = false, + notifySaved = false, + autoSavedLoginGuid = "" + ) { + const newLogin = lazy.LoginEntry.fromLoginInfo(aOldLogin || aNewLogin); + const oldGuid = (aOldLogin && newLogin.guid) || null; + newLogin.origin = aNewLogin.origin; + newLogin.formActionOrigin = aNewLogin.formActionOrigin; + newLogin.password = aNewLogin.password; + newLogin.username = aNewLogin.username; + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + this._createMessage({ dismissed, autoSavedLoginGuid }, [newLogin]), + result => { + const selectedLogin = result?.selection?.value; + + if (!selectedLogin) { + return; + } + + lazy.GeckoViewAutocomplete.onLoginSave(selectedLogin); + + const loginInfo = lazy.LoginEntry.parse(selectedLogin).toLoginInfo(); + Services.obs.notifyObservers( + loginInfo, + "passwordmgr-prompt-change", + oldGuid + ); + } + ); + + return { + dismiss() { + prompt.dismiss(); + }, + }; + } + + promptToChangePasswordWithUsernames(aBrowser, aLogins, aNewLogin) { + this.promptToChangePassword(aBrowser, null /* oldLogin */, aNewLogin); + } +} + +LoginStorageDelegate.prototype.classID = Components.ID( + "{3d765750-1c3d-11ea-aaef-0800200c9a66}" +); +LoginStorageDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsILoginManagerPrompter", +]); diff --git a/mobile/android/components/geckoview/PromptCollection.sys.mjs b/mobile/android/components/geckoview/PromptCollection.sys.mjs new file mode 100644 index 0000000000..472dea3316 --- /dev/null +++ b/mobile/android/components/geckoview/PromptCollection.sys.mjs @@ -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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("PromptCollection"); + +export class PromptCollection { + confirmRepost(browsingContext) { + const msg = { + type: "repost", + }; + const prompter = new lazy.GeckoViewPrompter(browsingContext); + const result = prompter.showPrompt(msg); + return !!result?.allow; + } + + asyncBeforeUnloadCheck(browsingContext) { + return new Promise(resolve => { + const msg = { + type: "beforeUnload", + }; + const prompter = new lazy.GeckoViewPrompter(browsingContext); + prompter.asyncShowPrompt(msg, resolve); + }).then(result => !!result?.allow); + } + + confirmFolderUpload() { + // Folder upload is not supported by GeckoView yet, see Bug 1674428. + return false; + } +} + +PromptCollection.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPromptCollection", +]); diff --git a/mobile/android/components/geckoview/ShareDelegate.sys.mjs b/mobile/android/components/geckoview/ShareDelegate.sys.mjs new file mode 100644 index 0000000000..986d2b85d5 --- /dev/null +++ b/mobile/android/components/geckoview/ShareDelegate.sys.mjs @@ -0,0 +1,77 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const domBundle = Services.strings.createBundle( + "chrome://global/locale/dom/dom.properties" +); + +const { debug, warn } = GeckoViewUtils.initLogging("ShareDelegate"); + +export class ShareDelegate { + init(aParent) { + this._openerWindow = aParent; + } + + get openerWindow() { + return this._openerWindow; + } + + async share(aTitle, aText, aUri) { + const ABORT = 2; + const FAILURE = 1; + const SUCCESS = 0; + + const msg = { + type: "share", + title: aTitle, + text: aText, + uri: aUri ? aUri.displaySpec : null, + }; + const prompt = new lazy.GeckoViewPrompter(this._openerWindow); + const result = await new Promise(resolve => { + prompt.asyncShowPrompt(msg, resolve); + }); + + if (!result) { + // A null result is treated as a dismissal in GeckoViewPrompter. + throw new DOMException( + domBundle.GetStringFromName("WebShareAPI_Aborted"), + "AbortError" + ); + } + + const res = result && result.response; + switch (res) { + case FAILURE: + throw new DOMException( + domBundle.GetStringFromName("WebShareAPI_Failed"), + "DataError" + ); + case ABORT: // Handle aborted attempt and invalid responses the same. + throw new DOMException( + domBundle.GetStringFromName("WebShareAPI_Aborted"), + "AbortError" + ); + case SUCCESS: + return; + default: + throw new DOMException("Unknown error.", "UnknownError"); + } + } +} + +ShareDelegate.prototype.classID = Components.ID( + "{1201d357-8417-4926-a694-e6408fbedcf8}" +); +ShareDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsISharePicker", +]); diff --git a/mobile/android/components/geckoview/components.conf b/mobile/android/components/geckoview/components.conf new file mode 100644 index 0000000000..ea9b9eba09 --- /dev/null +++ b/mobile/android/components/geckoview/components.conf @@ -0,0 +1,107 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{3e30d2a0-9934-11ea-bb37-0242ac130002}', + 'contract_ids': ['@mozilla.org/embedcomp/prompt-collection;1'], + 'esModule': 'resource://gre/modules/PromptCollection.sys.mjs', + 'constructor': 'PromptCollection', + }, + { + 'js_name': 'prompt', + 'cid': '{076ac188-23c1-4390-aa08-7ef1f78ca5d9}', + 'contract_ids': [ + '@mozilla.org/prompter;1', + ], + 'interfaces': ['nsIPromptService'], + 'esModule': 'resource://gre/modules/GeckoViewPrompt.sys.mjs', + 'constructor': 'PromptFactory', + }, + { + 'cid': '{8e993c34-fdd6-432c-967e-f995d888777f}', + 'contract_ids': ['@mozilla.org/geckoview/startup;1'], + 'esModule': 'resource://gre/modules/GeckoViewStartup.sys.mjs', + 'constructor': 'GeckoViewStartup', + }, + { + 'cid': '{42f3c238-e8e8-4015-9ca2-148723a8afcf}', + 'contract_ids': ['@mozilla.org/content-permission/prompt;1'], + 'esModule': 'resource://gre/modules/GeckoViewPermission.sys.mjs', + 'constructor': 'GeckoViewPermission', + }, + { + 'cid': '{a54d84d7-98a4-4fec-b664-e42e512ae9cc}', + 'contract_ids': ['@mozilla.org/push/Service;1'], + 'esModule': 'resource://gre/modules/GeckoViewPush.sys.mjs', + 'constructor': 'PushService', + }, + { + 'cid': '{fc4bec74-ddd0-4ea8-9a66-9a5081258e32}', + 'contract_ids': ['@mozilla.org/parent/colorpicker;1'], + 'esModule': 'resource://gre/modules/ColorPickerDelegate.sys.mjs', + 'constructor': 'ColorPickerDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{25fdbae6-f684-4bf0-b773-ff2b7a6273c8}', + 'contract_ids': ['@mozilla.org/parent/filepicker;1'], + 'esModule': 'resource://gre/modules/FilePickerDelegate.sys.mjs', + 'constructor': 'FilePickerDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{1201d357-8417-4926-a694-e6408fbedcf8}', + 'contract_ids': ['@mozilla.org/sharepicker;1'], + 'esModule': 'resource://gre/modules/ShareDelegate.sys.mjs', + 'constructor': 'ShareDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{3d765750-1c3d-11ea-aaef-0800200c9a66}', + 'contract_ids': ['@mozilla.org/login-manager/prompter;1'], + 'esModule': 'resource://gre/modules/LoginStorageDelegate.sys.mjs', + 'constructor': 'LoginStorageDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{91455c77-64a1-4c37-be00-f94eb9c7b8e1}', + 'contract_ids': [ + '@mozilla.org/uriloader/external-helper-app-service;1', + ], + 'type': 'GeckoViewExternalAppService', + 'constructor': 'GeckoViewExternalAppService::GetSingleton', + 'headers': ['GeckoViewExternalAppService.h'], + 'processes': ProcessSelector.ALLOW_IN_SOCKET_PROCESS, + }, + { + 'cid': '{a8f4582e-4b47-4e06-970d-b94b76977bf7}', + 'contract_ids': ['@mozilla.org/network/protocol;1?name=content'], + 'type': 'GeckoViewContentProtocolHandler', + 'headers': ['./GeckoViewContentProtocolHandler.h'], + 'protocol_config': { + 'scheme': 'content', + 'flags': [ + 'URI_IS_POTENTIALLY_TRUSTWORTHY', + 'URI_IS_LOCAL_RESOURCE', + 'URI_DANGEROUS_TO_LOAD', + ], + }, + }, +] + +if defined('MOZ_ANDROID_HISTORY'): + Classes += [ + { + 'name': 'History', + 'cid': '{0937a705-91a6-417a-8292-b22eb10da86c}', + 'contract_ids': ['@mozilla.org/browser/history;1'], + 'singleton': True, + 'type': 'GeckoViewHistory', + 'headers': ['GeckoViewHistory.h'], + 'constructor': 'GeckoViewHistory::GetSingleton', + }, + ] diff --git a/mobile/android/components/geckoview/moz.build b/mobile/android/components/geckoview/moz.build new file mode 100644 index 0000000000..7b115ed03b --- /dev/null +++ b/mobile/android/components/geckoview/moz.build @@ -0,0 +1,55 @@ +# -*- 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/. + +SOURCES += [ + "GeckoViewContentChannel.cpp", + "GeckoViewContentProtocolHandler.cpp", + "GeckoViewExternalAppService.cpp", + "GeckoViewInputStream.cpp", + "GeckoViewOutputStream.cpp", + "GeckoViewStreamListener.cpp", +] + +EXPORTS += [ + "GeckoViewContentChannel.h", + "GeckoViewContentProtocolHandler.h", + "GeckoViewExternalAppService.h", + "GeckoViewInputStream.h", + "GeckoViewOutputStream.h", + "GeckoViewStreamListener.h", +] + +if CONFIG["MOZ_ANDROID_HISTORY"]: + EXPORTS += [ + "GeckoViewHistory.h", + ] + SOURCES += [ + "GeckoViewHistory.cpp", + ] + include("/ipc/chromium/chromium-config.mozbuild") + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXTRA_COMPONENTS += [ + "GeckoView.manifest", +] + +EXTRA_JS_MODULES += [ + "ColorPickerDelegate.sys.mjs", + "FilePickerDelegate.sys.mjs", + "GeckoViewPermission.sys.mjs", + "GeckoViewPrompt.sys.mjs", + "GeckoViewPrompter.sys.mjs", + "GeckoViewPush.sys.mjs", + "GeckoViewStartup.sys.mjs", + "LoginStorageDelegate.sys.mjs", + "PromptCollection.sys.mjs", + "ShareDelegate.sys.mjs", +] + +FINAL_LIBRARY = "xul" diff --git a/mobile/android/components/moz.build b/mobile/android/components/moz.build new file mode 100644 index 0000000000..a6300de349 --- /dev/null +++ b/mobile/android/components/moz.build @@ -0,0 +1,16 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +with Files("extensions/**"): + BUG_COMPONENT = ("WebExtensions", "Android") + +DIRS += [ + "extensions", + "geckoview", +] diff --git a/mobile/android/config/js_wrapper.sh b/mobile/android/config/js_wrapper.sh new file mode 100755 index 0000000000..464d5c63c9 --- /dev/null +++ b/mobile/android/config/js_wrapper.sh @@ -0,0 +1,20 @@ +#! /bin/sh +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Wrapper for running SpiderMonkey js shell in automation with correct +# LD_LIBRARY_PATH. + +# We don't have a reference to topsrcdir at this point, but we are invoked as +# "$topsrcdir/mobile/android/config/js_wrapper.sh" so we can extract topsrcdir +# from $0. +topsrcdir=`cd \`dirname $0\`/../../..; pwd` + +JS_BINARY="$topsrcdir/jsshell/js" + +LD_LIBRARY_PATH="$topsrcdir/jsshell${LD_LIBRARY_PATH+:$LD_LIBRARY_PATH}" +export LD_LIBRARY_PATH + +# Pass through all arguments and exit with status from js shell. +exec "$JS_BINARY" "$@" diff --git a/mobile/android/config/mozconfigs/android-aarch64/beta b/mobile/android/config/mozconfigs/android-aarch64/beta new file mode 100644 index 0000000000..81bc5a50b8 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/beta @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=aarch64-linux-android + +ac_add_options --with-branding=mobile/android/branding/beta + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/debug b/mobile/android/config/mozconfigs/android-aarch64/debug new file mode 100644 index 0000000000..b745f8d41f --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/debug @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=aarch64-linux-android + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/debug-beta b/mobile/android/config/mozconfigs/android-aarch64/debug-beta new file mode 100644 index 0000000000..e592b75247 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/debug-beta @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=aarch64-linux-android + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/beta + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/debug-lite b/mobile/android/config/mozconfigs/android-aarch64/debug-lite new file mode 100644 index 0000000000..baf668cee5 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/debug-lite @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +ac_add_options --enable-geckoview-lite + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/debug" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly b/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly new file mode 100644 index 0000000000..4f33a0580d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly @@ -0,0 +1,23 @@ +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly" + +# L10n + +# Don't autoclobber l10n, as this can lead to missing binaries and broken builds +# Bug 1283438 +mk_add_options AUTOCLOBBER= + +. "$topsrcdir/build/mozconfig.no-compile" + +# Global options +ac_add_options --disable-tests +ac_add_options --disable-nodejs +unset NODEJS + +ac_add_options --enable-updater +ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL} + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly-lite b/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly-lite new file mode 100644 index 0000000000..68a76fa51e --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly-lite @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly-lite" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/l10n-nightly" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/nightly b/mobile/android/config/mozconfigs/android-aarch64/nightly new file mode 100644 index 0000000000..67fb41475d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/nightly @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=aarch64-linux-android + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact b/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact new file mode 100644 index 0000000000..83de907ee6 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact @@ -0,0 +1,12 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly" + +. "$topsrcdir/build/mozconfig.artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact-lite b/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact-lite new file mode 100644 index 0000000000..88b43b841c --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact-lite @@ -0,0 +1,11 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly-artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly-lite" + +. "$topsrcdir/build/mozconfig.artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/nightly-lite b/mobile/android/config/mozconfigs/android-aarch64/nightly-lite new file mode 100644 index 0000000000..c1d3b71b0d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/nightly-lite @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +ac_add_options --enable-geckoview-lite + +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-aarch64/profile-generate b/mobile/android/config/mozconfigs/android-aarch64/profile-generate new file mode 100644 index 0000000000..dc04beed70 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-aarch64/profile-generate @@ -0,0 +1,6 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-aarch64/nightly" + +mk_add_options "export MOZ_AUTOMATION_PACKAGE_GENERATED_SOURCES=0" + +ac_add_options --enable-profile-generate=cross +ac_add_options --disable-tests diff --git a/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/base b/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/base new file mode 100644 index 0000000000..7384a99e0d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/base @@ -0,0 +1,42 @@ +# Many things aren't appropriate for a frontend-only build. +MOZ_AUTOMATION_BUILD_SYMBOLS=0 +MOZ_AUTOMATION_PACKAGE=0 +MOZ_AUTOMATION_UPLOAD=0 +MOZ_AUTOMATION_PACKAGE_GENERATED_SOURCES=0 + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# We want to download Gradle. +ac_add_options --with-gradle +# We want to use (and populate!) the local Nexus repositories. +export GRADLE_MAVEN_REPOSITORIES="http://localhost:8081/nexus/content/repositories/mozilla/","http://localhost:8081/nexus/content/repositories/google/","http://localhost:8081/nexus/content/repositories/central/","http://localhost:8081/nexus/content/repositories/gradle-plugins/" +# Nexus runs on HTTP +ac_add_options --allow-insecure-gradle-repositories +# Some dependencies may be conditionally-loaded (eg. semanticdb compiler plugins) +ac_add_options --download-all-gradle-dependencies + +# From here on, just like ../android-arm/nightly. + +. "$topsrcdir/build/mozconfig.no-compile" + +ac_add_options --target=arm-linux-androideabi + +ac_add_options --disable-tests + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +# mozconfigs/common.override would be here, but it needs to be last in the file. +# End ../android-arm/nightly. + +# Disable Keyfile Loading (and checks) since dependency fetching doesn't need these keys. +# This overrides the settings in the common android mozconfig +ac_add_options --without-mozilla-api-keyfile +ac_add_options --without-google-location-service-api-keyfile +ac_add_options --without-google-safebrowsing-api-keyfile + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/nightly b/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/nightly new file mode 100644 index 0000000000..17eb2d53be --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/nightly @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/build/mozconfig.no-compile" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/base" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/nightly-lite b/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/nightly-lite new file mode 100644 index 0000000000..b6ce2a3e3b --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/nightly-lite @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/build/mozconfig.no-compile" + +# Android +ac_add_options --enable-geckoview-lite + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm-gradle-dependencies/base" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/beta b/mobile/android/config/mozconfigs/android-arm/beta new file mode 100644 index 0000000000..6d76977c94 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/beta @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=arm-linux-androideabi + +ac_add_options --with-branding=mobile/android/branding/beta + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/debug b/mobile/android/config/mozconfigs/android-arm/debug new file mode 100644 index 0000000000..bcf9cad4c4 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/debug @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=arm-linux-androideabi + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/debug-beta b/mobile/android/config/mozconfigs/android-arm/debug-beta new file mode 100644 index 0000000000..ca8697c647 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/debug-beta @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=arm-linux-androideabi + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/beta + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/debug-ccov b/mobile/android/config/mozconfigs/android-arm/debug-ccov new file mode 100644 index 0000000000..9dc98f1971 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/debug-ccov @@ -0,0 +1,21 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug +ac_add_options --enable-java-coverage + +# Android +ac_add_options --target=arm-linux-androideabi + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm/debug" + +. "$topsrcdir/build/mozconfig.artifact" + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/debug-ccov-lite b/mobile/android/config/mozconfigs/android-arm/debug-ccov-lite new file mode 100644 index 0000000000..04bc844085 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/debug-ccov-lite @@ -0,0 +1,22 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug +ac_add_options --enable-java-coverage + +# Android +ac_add_options --target=arm-linux-androideabi +ac_add_options --enable-geckoview-lite + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm/debug" + +. "$topsrcdir/build/mozconfig.artifact" + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/debug-lite b/mobile/android/config/mozconfigs/android-arm/debug-lite new file mode 100644 index 0000000000..be2a05fe59 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/debug-lite @@ -0,0 +1,14 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=arm-linux-androideabi +ac_add_options --enable-geckoview-lite + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/debug-searchfox b/mobile/android/config/mozconfigs/android-arm/debug-searchfox new file mode 100644 index 0000000000..2b3e3ffed4 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/debug-searchfox @@ -0,0 +1,15 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=arm-linux-androideabi + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +ac_add_options --enable-mozsearch-plugin + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/l10n-nightly b/mobile/android/config/mozconfigs/android-arm/l10n-nightly new file mode 100644 index 0000000000..62e02bcc3e --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/l10n-nightly @@ -0,0 +1,23 @@ +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm/nightly" + +# L10n + +# Don't autoclobber l10n, as this can lead to missing binaries and broken builds +# Bug 1283438 +mk_add_options AUTOCLOBBER= + +. "$topsrcdir/build/mozconfig.no-compile" + +# Global options +ac_add_options --disable-tests +ac_add_options --disable-nodejs +unset NODEJS + +ac_add_options --enable-updater +ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL} + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/l10n-nightly-lite b/mobile/android/config/mozconfigs/android-arm/l10n-nightly-lite new file mode 100644 index 0000000000..21d7fe55a3 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/l10n-nightly-lite @@ -0,0 +1,9 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm/nightly-lite" + +. "$topsrcdir/build/mozconfig.no-compile" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-arm/l10n-nightly" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/nightly b/mobile/android/config/mozconfigs/android-arm/nightly new file mode 100644 index 0000000000..c9232056e5 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/nightly @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=arm-linux-androideabi + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/nightly-android-lints b/mobile/android/config/mozconfigs/android-arm/nightly-android-lints new file mode 100644 index 0000000000..094ca33ebd --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/nightly-android-lints @@ -0,0 +1,34 @@ +# Many things aren't appropriate for a frontend-only build. +MOZ_AUTOMATION_BUILD_SYMBOLS=0 +MOZ_AUTOMATION_PACKAGE=0 +MOZ_AUTOMATION_UPLOAD=0 +MOZ_AUTOMATION_PACKAGE_GENERATED_SOURCES=0 + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/build/mozconfig.no-compile" + +ac_add_options --disable-tests + +# From here on, like ../android-arm/nightly. + +# Android +ac_add_options --target=arm-linux-androideabi + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +# mozconfigs/common.override would be here, but it needs to be last in the file. +# End ../android-arm/nightly. + +# Disable Keyfile Loading (and checks) since. +# This overrides the settings in the common android mozconfig +ac_add_options --without-mozilla-api-keyfile +ac_add_options --without-google-location-service-api-keyfile +ac_add_options --without-google-safebrowsing-api-keyfile + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/nightly-android-lints-lite b/mobile/android/config/mozconfigs/android-arm/nightly-android-lints-lite new file mode 100644 index 0000000000..bb0410e077 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/nightly-android-lints-lite @@ -0,0 +1,35 @@ +# Many things aren't appropriate for a frontend-only build. +MOZ_AUTOMATION_BUILD_SYMBOLS=0 +MOZ_AUTOMATION_PACKAGE=0 +MOZ_AUTOMATION_UPLOAD=0 +MOZ_AUTOMATION_PACKAGE_GENERATED_SOURCES=0 + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/build/mozconfig.no-compile" + +ac_add_options --disable-tests + +# From here on, like ../android-arm/nightly. + +# Android +ac_add_options --target=arm-linux-androideabi +ac_add_options --enable-geckoview-lite + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +# mozconfigs/common.override would be here, but it needs to be last in the file. +# End ../android-arm/nightly. + +# Disable Keyfile Loading (and checks) since. +# This overrides the settings in the common android mozconfig +ac_add_options --without-mozilla-api-keyfile +ac_add_options --without-google-location-service-api-keyfile +ac_add_options --without-google-safebrowsing-api-keyfile + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-arm/nightly-lite b/mobile/android/config/mozconfigs/android-arm/nightly-lite new file mode 100644 index 0000000000..46172b8f62 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-arm/nightly-lite @@ -0,0 +1,11 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=arm-linux-androideabi +ac_add_options --enable-geckoview-lite + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/beta b/mobile/android/config/mozconfigs/android-x86/beta new file mode 100644 index 0000000000..a8c55fe00e --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/beta @@ -0,0 +1,9 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +ac_add_options --target=i686-linux-android + +ac_add_options --with-branding=mobile/android/branding/beta + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/debug b/mobile/android/config/mozconfigs/android-x86/debug new file mode 100644 index 0000000000..6b0e7a859e --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/debug @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=i686-linux-android + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/debug-beta b/mobile/android/config/mozconfigs/android-x86/debug-beta new file mode 100644 index 0000000000..4b434be736 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/debug-beta @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=i686-linux-android + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/beta + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/debug-lite b/mobile/android/config/mozconfigs/android-x86/debug-lite new file mode 100644 index 0000000000..20fe5b3c9e --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/debug-lite @@ -0,0 +1,14 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=i686-linux-android +ac_add_options --enable-geckoview-lite + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/l10n-nightly b/mobile/android/config/mozconfigs/android-x86/l10n-nightly new file mode 100644 index 0000000000..04844c52d4 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/l10n-nightly @@ -0,0 +1,23 @@ +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86/nightly" + +# L10n + +# Don't autoclobber l10n, as this can lead to missing binaries and broken builds +# Bug 1283438 +mk_add_options AUTOCLOBBER= + +. "$topsrcdir/build/mozconfig.no-compile" + +# Global options +ac_add_options --disable-tests +ac_add_options --disable-nodejs +unset NODEJS + +ac_add_options --enable-updater +ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL} + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/l10n-nightly-lite b/mobile/android/config/mozconfigs/android-x86/l10n-nightly-lite new file mode 100644 index 0000000000..8f94e40df8 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/l10n-nightly-lite @@ -0,0 +1,9 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86/nightly-lite" + +. "$topsrcdir/build/mozconfig.no-compile" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86/l10n-nightly" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/nightly b/mobile/android/config/mozconfigs/android-x86/nightly new file mode 100644 index 0000000000..fcc7615615 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/nightly @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=i686-linux-android + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/nightly-artifact b/mobile/android/config/mozconfigs/android-x86/nightly-artifact new file mode 100644 index 0000000000..09fefe23f7 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/nightly-artifact @@ -0,0 +1,12 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86/nightly" + +. "$topsrcdir/build/mozconfig.artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/nightly-artifact-lite b/mobile/android/config/mozconfigs/android-x86/nightly-artifact-lite new file mode 100644 index 0000000000..0def1723a3 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/nightly-artifact-lite @@ -0,0 +1,12 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86/nightly-lite" + +. "$topsrcdir/build/mozconfig.artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/nightly-lite b/mobile/android/config/mozconfigs/android-x86/nightly-lite new file mode 100644 index 0000000000..9290c12df7 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/nightly-lite @@ -0,0 +1,11 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=i686-linux-android +ac_add_options --enable-geckoview-lite + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86/profile-generate b/mobile/android/config/mozconfigs/android-x86/profile-generate new file mode 100644 index 0000000000..2ba606187d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86/profile-generate @@ -0,0 +1,6 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86/nightly" + +mk_add_options "export MOZ_AUTOMATION_PACKAGE_GENERATED_SOURCES=0" + +ac_add_options --enable-profile-generate=cross +ac_add_options --disable-tests diff --git a/mobile/android/config/mozconfigs/android-x86_64/beta b/mobile/android/config/mozconfigs/android-x86_64/beta new file mode 100644 index 0000000000..43981a416f --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/beta @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=x86_64-linux-android + +ac_add_options --with-branding=mobile/android/branding/beta + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/debug b/mobile/android/config/mozconfigs/android-x86_64/debug new file mode 100644 index 0000000000..cfd951ce9d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/debug @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=x86_64-linux-android + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/debug-beta b/mobile/android/config/mozconfigs/android-x86_64/debug-beta new file mode 100644 index 0000000000..be4f930022 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/debug-beta @@ -0,0 +1,13 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --target=x86_64-linux-android + +export MOZILLA_OFFICIAL=1 + +ac_add_options --with-branding=mobile/android/branding/beta + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing b/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing new file mode 100644 index 0000000000..54cb818f5e --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing @@ -0,0 +1,11 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/debug" + +# Disable Telemetry +ac_add_options MOZ_TELEMETRY_REPORTING= + +ac_add_options --enable-fuzzing + +# This adds '-fuzzing' to the APK filename for local builds. +export MOZ_PKG_SPECIAL=fuzzing + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing-lite b/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing-lite new file mode 100644 index 0000000000..4fc59444aa --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing-lite @@ -0,0 +1,5 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/debug-lite" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/debug-fuzzing" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/debug-isolated-process b/mobile/android/config/mozconfigs/android-x86_64/debug-isolated-process new file mode 100644 index 0000000000..c166becf72 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/debug-isolated-process @@ -0,0 +1,15 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Global options +ac_add_options --enable-debug + +# Android +ac_add_options --with-android-min-sdk=21 +ac_add_options --target=x86_64-linux-android + +export MOZILLA_OFFICIAL=1 +export MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS=1 + +ac_add_options --with-branding=mobile/android/branding/nightly + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override"
\ No newline at end of file diff --git a/mobile/android/config/mozconfigs/android-x86_64/debug-lite b/mobile/android/config/mozconfigs/android-x86_64/debug-lite new file mode 100644 index 0000000000..ef97701075 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/debug-lite @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +ac_add_options --enable-geckoview-lite + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/debug" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly b/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly new file mode 100644 index 0000000000..c9c6894790 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly @@ -0,0 +1,23 @@ +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly" + +# L10n + +# Don't autoclobber l10n, as this can lead to missing binaries and broken builds +# Bug 1283438 +mk_add_options AUTOCLOBBER= + +. "$topsrcdir/build/mozconfig.no-compile" + +# Global options +ac_add_options --disable-tests +ac_add_options --disable-nodejs +unset NODEJS + +ac_add_options --enable-updater +ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL} + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly-lite b/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly-lite new file mode 100644 index 0000000000..c39db1107d --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly-lite @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/l10n-nightly" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly-lite" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/nightly b/mobile/android/config/mozconfigs/android-x86_64/nightly new file mode 100644 index 0000000000..457d93d4d3 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/nightly @@ -0,0 +1,10 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +# Android +ac_add_options --target=x86_64-linux-android + +ac_add_options --with-branding=mobile/android/branding/nightly + +export MOZILLA_OFFICIAL=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/nightly-artifact b/mobile/android/config/mozconfigs/android-x86_64/nightly-artifact new file mode 100644 index 0000000000..a56061dccd --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/nightly-artifact @@ -0,0 +1,12 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly" + +. "$topsrcdir/build/mozconfig.artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/nightly-artifact-lite b/mobile/android/config/mozconfigs/android-x86_64/nightly-artifact-lite new file mode 100644 index 0000000000..cc0918045c --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/nightly-artifact-lite @@ -0,0 +1,12 @@ +. "$topsrcdir/build/mozconfig.artifact.automation" + +NO_CACHE=1 +NO_NDK=1 + +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly-lite" + +. "$topsrcdir/build/mozconfig.artifact" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan b/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan new file mode 100644 index 0000000000..63467dafb2 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan @@ -0,0 +1,30 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly" + +# Remove unwanted environment variables from the 'nightly' mozconfig. +unset MOZ_ANDROID_POCKET + +# We still need to build with debug symbols +ac_add_options --disable-debug +ac_add_options --enable-optimize="-O2 -gline-tables-only" + +. $topsrcdir/build/unix/mozconfig.asan +ac_add_options --disable-elf-hack +ac_add_options --enable-linker=bfd + +ac_add_options --enable-fuzzing +unset MOZ_STDCXX_COMPAT +unset ENABLE_CLANG_PLUGIN + +# Add the path to the clang_rt used, so it can be packaged with the build. +if [ -d "$MOZ_FETCHES_DIR/clang" ]; then + CLANG_LIB_DIR="$(cd $MOZ_FETCHES_DIR/clang/lib/clang/*/lib/linux && pwd)" + export MOZ_CLANG_RT_ASAN_LIB_PATH="${CLANG_LIB_DIR}/libclang_rt.asan-x86_64-android.so" +fi + +# Package js shell. +export MOZ_PACKAGE_JSSHELL=1 + +# This adds '-fuzzing-asan' to the APK filename for local builds. +export MOZ_PKG_SPECIAL=fuzzing-asan + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan-lite b/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan-lite new file mode 100644 index 0000000000..beb35d13d8 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan-lite @@ -0,0 +1,5 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly-lite" + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly-fuzzing-asan" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/nightly-lite b/mobile/android/config/mozconfigs/android-x86_64/nightly-lite new file mode 100644 index 0000000000..abdb120300 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/nightly-lite @@ -0,0 +1,7 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/common" + +ac_add_options --enable-geckoview-lite + +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly" + +. "$topsrcdir/mobile/android/config/mozconfigs/common.override" diff --git a/mobile/android/config/mozconfigs/android-x86_64/profile-generate b/mobile/android/config/mozconfigs/android-x86_64/profile-generate new file mode 100644 index 0000000000..0bd9464da6 --- /dev/null +++ b/mobile/android/config/mozconfigs/android-x86_64/profile-generate @@ -0,0 +1,6 @@ +. "$topsrcdir/mobile/android/config/mozconfigs/android-x86_64/nightly" + +mk_add_options "export MOZ_AUTOMATION_PACKAGE_GENERATED_SOURCES=0" + +ac_add_options --enable-profile-generate=cross +ac_add_options --disable-tests diff --git a/mobile/android/config/mozconfigs/common b/mobile/android/config/mozconfigs/common new file mode 100644 index 0000000000..32b8b1b564 --- /dev/null +++ b/mobile/android/config/mozconfigs/common @@ -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/. + +. "$topsrcdir/build/mozconfig.common" + +# Build Fennec +ac_add_options --enable-project=mobile/android + +ac_add_options --with-gradle="$MOZ_FETCHES_DIR/android-gradle-dependencies/gradle-dist/bin/gradle" +export GRADLE_MAVEN_REPOSITORIES="file://$MOZ_FETCHES_DIR/android-gradle-dependencies/mozilla","file://$MOZ_FETCHES_DIR/android-gradle-dependencies/google","file://$MOZ_FETCHES_DIR/android-gradle-dependencies/central","file://$MOZ_FETCHES_DIR/android-gradle-dependencies/gradle-plugins" + +if [ -z "$NO_NDK" -a -z "$USE_ARTIFACT" ]; then + CFLAGS="$CFLAGS -fcrash-diagnostics-dir=${UPLOAD_PATH}" + CXXFLAGS="$CXXFLAGS -fcrash-diagnostics-dir=${UPLOAD_PATH}" + # Make sure that any host binaries we build use whatever libraries clang + # linked against, rather than what's on the system. + mk_add_options "export LD_LIBRARY_PATH=$MOZ_FETCHES_DIR/clang/lib" + # Enable static analysis plugin + export ENABLE_CLANG_PLUGIN=1 +fi + +ac_add_options --enable-update-channel=${MOZ_UPDATE_CHANNEL} + +ac_add_options --with-google-safebrowsing-api-keyfile=/builds/sb-gapi.data +ac_add_options --with-google-location-service-api-keyfile=/builds/gls-gapi.data +ac_add_options --with-mozilla-api-keyfile=/builds/mozilla-fennec-geoloc-api.key + +# Package js shell. +export MOZ_PACKAGE_JSSHELL=1 + +if [ -d "$MOZ_FETCHES_DIR/binutils/bin" ]; then + mk_add_options "export PATH=$MOZ_FETCHES_DIR/binutils/bin:$PATH" + export LDFLAGS="$LDFLAGS -B $MOZ_FETCHES_DIR/binutils/bin" +fi + +JS_BINARY="$topsrcdir/mobile/android/config/js_wrapper.sh" diff --git a/mobile/android/config/mozconfigs/common.override b/mobile/android/config/mozconfigs/common.override new file mode 100644 index 0000000000..1213a82f70 --- /dev/null +++ b/mobile/android/config/mozconfigs/common.override @@ -0,0 +1,10 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.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 file is included at the bottom of all native android mozconfigs +# +# Disable enforcing that add-ons are signed by the trusted root +MOZ_REQUIRE_SIGNING= + +. "$topsrcdir/build/mozconfig.common.override" diff --git a/mobile/android/config/proguard/adjust-keeps.cfg b/mobile/android/config/proguard/adjust-keeps.cfg new file mode 100644 index 0000000000..0c0fc2158d --- /dev/null +++ b/mobile/android/config/proguard/adjust-keeps.cfg @@ -0,0 +1,20 @@ +# Rules to make the Adjust install tracking library work. +# via https://github.com/adjust/android_sdk#5-add-permissions + +-keep class com.adjust.sdk.plugin.MacAddressUtil { + java.lang.String getMacAddress(android.content.Context); +} +-keep class com.adjust.sdk.plugin.AndroidIdUtil { + java.lang.String getAndroidId(android.content.Context); +} +-keep class com.google.android.gms.common.ConnectionResult { + int SUCCESS; +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient { + com.google.android.gms.ads.identifier.AdvertisingIdClient$Info + getAdvertisingIdInfo (android.content.Context); +} +-keep class com.google.android.gms.ads.identifier.AdvertisingIdClient$Info { + java.lang.String getId (); + boolean isLimitAdTrackingEnabled(); +} diff --git a/mobile/android/config/proguard/appcompat-v7-keeps.cfg b/mobile/android/config/proguard/appcompat-v7-keeps.cfg new file mode 100644 index 0000000000..bde6f0fb74 --- /dev/null +++ b/mobile/android/config/proguard/appcompat-v7-keeps.cfg @@ -0,0 +1,18 @@ +# Avoid https://code.google.com/p/android/issues/detail?id=187611 and +# http://stackoverflow.com/q/32813894 when building with Gradle. Why +# these aren't defined in appcompat-v7.aar/proguard.txt is beyond me. + +-keep public class android.support.v7.widget.** { *; } +-keep public class android.support.v7.internal.widget.** { *; } +-keep public class android.support.v7.internal.view.menu.** { *; } + +-keep public class * extends android.support.v4.view.ActionProvider { + public <init>(android.content.Context); +} + +-keepclassmembers class android.support.graphics.drawable.VectorDrawableCompat$* { + void set*(***); + *** get*(); +} + +-keepattributes LocalVariableTable diff --git a/mobile/android/config/proguard/leakcanary-keeps.cfg b/mobile/android/config/proguard/leakcanary-keeps.cfg new file mode 100644 index 0000000000..f9e5df87c1 --- /dev/null +++ b/mobile/android/config/proguard/leakcanary-keeps.cfg @@ -0,0 +1,7 @@ +# LeakCanary +-keep class org.eclipse.mat.** { *; } +-keep class com.squareup.leakcanary.** { *; } +-keep class com.squareup.haha.** { *; } + +# With LeakCanary 1.4-beta1 this creates a pile of warnings +-dontwarn com.squareup.haha.** diff --git a/mobile/android/config/proguard/play-services-keeps.cfg b/mobile/android/config/proguard/play-services-keeps.cfg new file mode 100644 index 0000000000..b3aaf80aa9 --- /dev/null +++ b/mobile/android/config/proguard/play-services-keeps.cfg @@ -0,0 +1,19 @@ +# Rules to prevent Google Play Services from exploding +# (From http://developer.android.com/google/play-services/setup.html#Proguard +# With the reference to "Object" changed so it'll actually *work*...) +-keep class * extends java.util.ListResourceBundle { + protected java.lang.Object[][] getContents(); +} + +-keep public class com.google.android.gms.common.internal.safeparcel.SafeParcelable { + public static final *** NULL; +} + +-keepnames @com.google.android.gms.common.annotation.KeepName class * +-keepclassmembernames class * { + @com.google.android.gms.common.annotation.KeepName *; +} + +-keepnames class * implements android.os.Parcelable { + public static final ** CREATOR; +} diff --git a/mobile/android/config/proguard/proguard-android.cfg b/mobile/android/config/proguard/proguard-android.cfg new file mode 100644 index 0000000000..93acf28d10 --- /dev/null +++ b/mobile/android/config/proguard/proguard-android.cfg @@ -0,0 +1,78 @@ +# This is a configuration file for ProGuard. +# http://proguard.sourceforge.net/index.html#manual/usage.html +# +# Starting with version 2.2 of the Android plugin for Gradle, these files are no longer used. Newer +# versions are distributed with the plugin and unpacked at build time. Files in this directory are +# no longer maintained. + +-dontusemixedcaseclassnames +-dontskipnonpubliclibraryclasses +-verbose + +# Optimization is turned off by default. Dex does not like code run +# through the ProGuard optimize and preverify steps (and performs some +# of these optimizations on its own). +-dontoptimize +-dontpreverify +# Note that if you want to enable optimization, you cannot just +# include optimization flags in your own project configuration file; +# instead you will need to point to the +# "proguard-android-optimize.txt" file instead of this one from your +# project.properties file. + +-keepattributes *Annotation* +-keep public class com.google.vending.licensing.ILicensingService +-keep public class com.android.vending.licensing.ILicensingService + +# For native methods, see http://proguard.sourceforge.net/manual/examples.html#native +-keepclasseswithmembernames class * { + native <methods>; +} + +# keep setters in Views so that animations can still work. +# see http://proguard.sourceforge.net/manual/examples.html#beans +-keepclassmembers public class * extends android.view.View { + void set*(***); + *** get*(); +} + +# We want to keep methods in Activity that could be used in the XML attribute onClick +-keepclassmembers class * extends android.app.Activity { + public void *(android.view.View); +} + +# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +-keepclassmembers class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator CREATOR; +} + +-keepclassmembers class **.R$* { + public static <fields>; +} + +# The support library contains references to newer platform versions. +# Don't warn about those in case this app is linking against an older +# platform version. We know about them, and they are safe. +-dontwarn android.support.** + +# Understand the @Keep support annotation. +-keep class android.support.annotation.Keep + +-keep @android.support.annotation.Keep class * {*;} + +-keepclasseswithmembers class * { + @android.support.annotation.Keep <methods>; +} + +-keepclasseswithmembers class * { + @android.support.annotation.Keep <fields>; +} + +-keepclasseswithmembers class * { + @android.support.annotation.Keep <init>(...); +} diff --git a/mobile/android/config/proguard/proguard-leanplum.cfg b/mobile/android/config/proguard/proguard-leanplum.cfg new file mode 100644 index 0000000000..9908cdcaa8 --- /dev/null +++ b/mobile/android/config/proguard/proguard-leanplum.cfg @@ -0,0 +1,347 @@ +-dontwarn com.actionbarsherlock.** + +-dontnote +-keepparameternames +-keepattributes EnclosingMethod + +# Keep - Library. Keep all public and protected classes, fields, and methods. +-keep public class com.leanplum.*, + com.leanplum.activities.*, + com.leanplum.annotations.*, + com.leanplum.callbacks.*, + com.leanplum.messagetemplates.*, + com.leanplum.utils.*, + com.leanplum.views.* +{ + public protected <fields>; + public protected <methods>; +} + +# Also keep - Enumerations. Keep the special static methods that are required in +# enumeration classes. +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# Also keep - Database drivers. Keep all implementations of java.sql.Driver. +-keep class * extends java.sql.Driver + + +# Keep names - Native method names. Keep all native class/method names. +-keepclasseswithmembers,allowshrinking class * { + native <methods>; +} + +# Remove - System method calls. Remove all invocations of System +# methods without side effects whose return values are not used. +-assumenosideeffects public class java.lang.System { + public static long currentTimeMillis(); + static java.lang.Class getCallerClass(); + public static int identityHashCode(java.lang.Object); + public static java.lang.SecurityManager getSecurityManager(); + public static java.util.Properties getProperties(); + public static java.lang.String getProperty(java.lang.String); + public static java.lang.String getenv(java.lang.String); + public static java.lang.String mapLibraryName(java.lang.String); + public static java.lang.String getProperty(java.lang.String,java.lang.String); +} + +# Remove - Math method calls. Remove all invocations of Math +# methods without side effects whose return values are not used. +-assumenosideeffects public class java.lang.Math { + public static double sin(double); + public static double cos(double); + public static double tan(double); + public static double asin(double); + public static double acos(double); + public static double atan(double); + public static double toRadians(double); + public static double toDegrees(double); + public static double exp(double); + public static double log(double); + public static double log10(double); + public static double sqrt(double); + public static double cbrt(double); + public static double IEEEremainder(double,double); + public static double ceil(double); + public static double floor(double); + public static double rint(double); + public static double atan2(double,double); + public static double pow(double,double); + public static int round(float); + public static long round(double); + public static double random(); + public static int abs(int); + public static long abs(long); + public static float abs(float); + public static double abs(double); + public static int max(int,int); + public static long max(long,long); + public static float max(float,float); + public static double max(double,double); + public static int min(int,int); + public static long min(long,long); + public static float min(float,float); + public static double min(double,double); + public static double ulp(double); + public static float ulp(float); + public static double signum(double); + public static float signum(float); + public static double sinh(double); + public static double cosh(double); + public static double tanh(double); + public static double hypot(double,double); + public static double expm1(double); + public static double log1p(double); +} + +# Remove - Number method calls. Remove all invocations of Number +# methods without side effects whose return values are not used. +-assumenosideeffects public class java.lang.* extends java.lang.Number { + public static java.lang.String toString(byte); + public static java.lang.Byte valueOf(byte); + public static byte parseByte(java.lang.String); + public static byte parseByte(java.lang.String,int); + public static java.lang.Byte valueOf(java.lang.String,int); + public static java.lang.Byte valueOf(java.lang.String); + public static java.lang.Byte decode(java.lang.String); + public int compareTo(java.lang.Byte); + public static java.lang.String toString(short); + public static short parseShort(java.lang.String); + public static short parseShort(java.lang.String,int); + public static java.lang.Short valueOf(java.lang.String,int); + public static java.lang.Short valueOf(java.lang.String); + public static java.lang.Short valueOf(short); + public static java.lang.Short decode(java.lang.String); + public static short reverseBytes(short); + public int compareTo(java.lang.Short); + public static java.lang.String toString(int,int); + public static java.lang.String toHexString(int); + public static java.lang.String toOctalString(int); + public static java.lang.String toBinaryString(int); + public static java.lang.String toString(int); + public static int parseInt(java.lang.String,int); + public static int parseInt(java.lang.String); + public static java.lang.Integer valueOf(java.lang.String,int); + public static java.lang.Integer valueOf(java.lang.String); + public static java.lang.Integer valueOf(int); + public static java.lang.Integer getInteger(java.lang.String); + public static java.lang.Integer getInteger(java.lang.String,int); + public static java.lang.Integer getInteger(java.lang.String,java.lang.Integer); + public static java.lang.Integer decode(java.lang.String); + public static int highestOneBit(int); + public static int lowestOneBit(int); + public static int numberOfLeadingZeros(int); + public static int numberOfTrailingZeros(int); + public static int bitCount(int); + public static int rotateLeft(int,int); + public static int rotateRight(int,int); + public static int reverse(int); + public static int signum(int); + public static int reverseBytes(int); + public int compareTo(java.lang.Integer); + public static java.lang.String toString(long,int); + public static java.lang.String toHexString(long); + public static java.lang.String toOctalString(long); + public static java.lang.String toBinaryString(long); + public static java.lang.String toString(long); + public static long parseLong(java.lang.String,int); + public static long parseLong(java.lang.String); + public static java.lang.Long valueOf(java.lang.String,int); + public static java.lang.Long valueOf(java.lang.String); + public static java.lang.Long valueOf(long); + public static java.lang.Long decode(java.lang.String); + public static java.lang.Long getLong(java.lang.String); + public static java.lang.Long getLong(java.lang.String,long); + public static java.lang.Long getLong(java.lang.String,java.lang.Long); + public static long highestOneBit(long); + public static long lowestOneBit(long); + public static int numberOfLeadingZeros(long); + public static int numberOfTrailingZeros(long); + public static int bitCount(long); + public static long rotateLeft(long,int); + public static long rotateRight(long,int); + public static long reverse(long); + public static int signum(long); + public static long reverseBytes(long); + public int compareTo(java.lang.Long); + public static java.lang.String toString(float); + public static java.lang.String toHexString(float); + public static java.lang.Float valueOf(java.lang.String); + public static java.lang.Float valueOf(float); + public static float parseFloat(java.lang.String); + public static boolean isNaN(float); + public static boolean isInfinite(float); + public static int floatToIntBits(float); + public static int floatToRawIntBits(float); + public static float intBitsToFloat(int); + public static int compare(float,float); + public boolean isNaN(); + public boolean isInfinite(); + public int compareTo(java.lang.Float); + public static java.lang.String toString(double); + public static java.lang.String toHexString(double); + public static java.lang.Double valueOf(java.lang.String); + public static java.lang.Double valueOf(double); + public static double parseDouble(java.lang.String); + public static boolean isNaN(double); + public static boolean isInfinite(double); + public static long doubleToLongBits(double); + public static long doubleToRawLongBits(double); + public static double longBitsToDouble(long); + public static int compare(double,double); + public boolean isNaN(); + public boolean isInfinite(); + public int compareTo(java.lang.Double); + public <init>(byte); + public <init>(short); + public <init>(int); + public <init>(long); + public <init>(float); + public <init>(double); + public <init>(java.lang.String); + public byte byteValue(); + public short shortValue(); + public int intValue(); + public long longValue(); + public float floatValue(); + public double doubleValue(); + public int compareTo(java.lang.Object); + public boolean equals(java.lang.Object); + public int hashCode(); + public java.lang.String toString(); +} + +# Remove - String method calls. Remove all invocations of String +# methods without side effects whose return values are not used. +-assumenosideeffects public class java.lang.String { + public <init>(); + public <init>(byte[]); + public <init>(byte[],int); + public <init>(byte[],int,int); + public <init>(byte[],int,int,int); + public <init>(byte[],int,int,java.lang.String); + public <init>(byte[],java.lang.String); + public <init>(char[]); + public <init>(char[],int,int); + public <init>(java.lang.String); + public <init>(java.lang.StringBuffer); + public static java.lang.String copyValueOf(char[]); + public static java.lang.String copyValueOf(char[],int,int); + public static java.lang.String valueOf(boolean); + public static java.lang.String valueOf(char); + public static java.lang.String valueOf(char[]); + public static java.lang.String valueOf(char[],int,int); + public static java.lang.String valueOf(double); + public static java.lang.String valueOf(float); + public static java.lang.String valueOf(int); + public static java.lang.String valueOf(java.lang.Object); + public static java.lang.String valueOf(long); + public boolean contentEquals(java.lang.StringBuffer); + public boolean endsWith(java.lang.String); + public boolean equalsIgnoreCase(java.lang.String); + public boolean equals(java.lang.Object); + public boolean matches(java.lang.String); + public boolean regionMatches(boolean,int,java.lang.String,int,int); + public boolean regionMatches(int,java.lang.String,int,int); + public boolean startsWith(java.lang.String); + public boolean startsWith(java.lang.String,int); + public byte[] getBytes(); + public byte[] getBytes(java.lang.String); + public char charAt(int); + public char[] toCharArray(); + public int compareToIgnoreCase(java.lang.String); + public int compareTo(java.lang.Object); + public int compareTo(java.lang.String); + public int hashCode(); + public int indexOf(int); + public int indexOf(int,int); + public int indexOf(java.lang.String); + public int indexOf(java.lang.String,int); + public int lastIndexOf(int); + public int lastIndexOf(int,int); + public int lastIndexOf(java.lang.String); + public int lastIndexOf(java.lang.String,int); + public int length(); + public java.lang.CharSequence subSequence(int,int); + public java.lang.String concat(java.lang.String); + public java.lang.String replaceAll(java.lang.String,java.lang.String); + public java.lang.String replace(char,char); + public java.lang.String replaceFirst(java.lang.String,java.lang.String); + public java.lang.String[] split(java.lang.String); + public java.lang.String[] split(java.lang.String,int); + public java.lang.String substring(int); + public java.lang.String substring(int,int); + public java.lang.String toLowerCase(); + public java.lang.String toLowerCase(java.util.Locale); + public java.lang.String toString(); + public java.lang.String toUpperCase(); + public java.lang.String toUpperCase(java.util.Locale); + public java.lang.String trim(); +} + +# Remove - StringBuffer method calls. Remove all invocations of StringBuffer +# methods without side effects whose return values are not used. +-assumenosideeffects public class java.lang.StringBuffer { + public <init>(); + public <init>(int); + public <init>(java.lang.String); + public <init>(java.lang.CharSequence); + public java.lang.String toString(); + public char charAt(int); + public int capacity(); + public int codePointAt(int); + public int codePointBefore(int); + public int indexOf(java.lang.String,int); + public int lastIndexOf(java.lang.String); + public int lastIndexOf(java.lang.String,int); + public int length(); + public java.lang.String substring(int); + public java.lang.String substring(int,int); +} + +# Remove - StringBuilder method calls. Remove all invocations of StringBuilder +# methods without side effects whose return values are not used. +-assumenosideeffects public class java.lang.StringBuilder { + public <init>(); + public <init>(int); + public <init>(java.lang.String); + public <init>(java.lang.CharSequence); + public java.lang.String toString(); + public char charAt(int); + public int capacity(); + public int codePointAt(int); + public int codePointBefore(int); + public int indexOf(java.lang.String,int); + public int lastIndexOf(java.lang.String); + public int lastIndexOf(java.lang.String,int); + public int length(); + public java.lang.String substring(int); + public java.lang.String substring(int,int); +} + +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes Exceptions + +-keep class com.leanplum.Leanplum { + static void reset(); + static void setClient(java.lang.String, java.lang.String, java.lang.String); +} + +-keep class com.leanplum.utils.BitmapUtil { public private protected *; } + +-keep class com.leanplum.LocationManagerImplementation { *; } + +-keep class com.leanplum.messagetemplates.BaseMessageOptions { *; } + +#-dontwarn android.support.v7.** +-keep class android.support.v7.app.AppCompatActivity +#-keep interface android.support.v7.** { *; } + +-printmapping out.map +-renamesourcefileattribute SourceFile +-keepattributes SourceFile,LineNumberTable + +-optimizations !code/allocation/variable diff --git a/mobile/android/config/proguard/proguard.cfg b/mobile/android/config/proguard/proguard.cfg new file mode 100644 index 0000000000..f9a21ed339 --- /dev/null +++ b/mobile/android/config/proguard/proguard.cfg @@ -0,0 +1,165 @@ +# Dalvik renders preverification unuseful (Would just slightly bloat the file). +-dontpreverify + +# Uncomment to have Proguard list dead code detected during the run - useful for cleaning up the codebase. +# -printusage + +-dontskipnonpubliclibraryclassmembers +-verbose +-allowaccessmodification + +# Preserve all fundamental application classes. +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Application +-keep public class * extends android.app.Service +-keep public class * extends android.app.backup.BackupAgentHelper +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider +-keep public class * extends android.preference.Preference +-keep public class * extends org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter +-keep class org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter + +# Preserve all native method names and the names of their classes. +-keepclasseswithmembernames class * { + native <methods>; +} + +-keepclasseswithmembers class * { + public <init>(android.content.Context, android.util.AttributeSet, int); +} + +-keepclassmembers class * extends android.app.Activity { + public void *(android.view.View); +} + + +# 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 enums. (For awful reasons, the runtime accesses them using introspection...) +-keepclassmembers enum * { + *; +} + +# +# Rules from ProGuard's Android example: +# http://proguard.sourceforge.net/manual/examples.html#androidapplication +# + +# Keep a fixed source file attribute and all line number tables to get line +# numbers in the stack traces. +# You can comment this out if you're not interested in stack traces. + +-renamesourcefileattribute SourceFile +-keepattributes SourceFile,LineNumberTable + +# RemoteViews might need annotations. + +-keepattributes *Annotation* + +# Preserve all View implementations, their special context constructors, and +# their setters. + +-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*(...); +} + +# 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>; +} + +# Preserve the required interface from the License Verification Library +# (but don't nag the developer if the library is not used at all). + +-keep public interface com.android.vending.licensing.ILicensingService + +-dontnote com.android.vending.licensing.ILicensingService + +# Preserve all native method names and the names of their classes. + +-keepclasseswithmembernames class * { + native <methods>; +} + +# +# Mozilla-specific rules +# +# Merging classes can generate dex warnings about anonymous inner classes. +-optimizations !class/merging/horizontal +-optimizations !class/merging/vertical + +# This optimisation causes corrupt bytecode if we run more than two passes. +# Testing shows that running the extra passes of everything else saves us +# more than this optimisation does, so bye bye! +-optimizations !code/allocation/variable + +# Keep miscellaneous targets. + +# Keep Robocop targets. TODO: Can omit these from release builds. Also, Bug 916507. + +# Same formula as above... +-keep @interface org.mozilla.gecko.annotation.RobocopTarget +-keep @org.mozilla.gecko.annotation.RobocopTarget class * +-keepclassmembers class * { + @org.mozilla.gecko.annotation.RobocopTarget *; +} +-keepclassmembers @org.mozilla.gecko.annotation.RobocopTarget class * { + *; +} +-keepclasseswithmembers class * { + @org.mozilla.gecko.annotation.RobocopTarget <methods>; +} +-keepclasseswithmembers class * { + @org.mozilla.gecko.annotation.RobocopTarget <fields>; +} + +-keep class **.R$* + +# Disable obfuscation because it makes exception stack traces more difficult to read. +-dontobfuscate + +# Suppress warnings about missing descriptor classes. +#-dontnote **,!ch.boye.**,!org.mozilla.gecko.sync.** + +-include "play-services-keeps.cfg" + +# Don't warn when classes referenced by JaCoCo are missing when running the build from android-dependencies. +-dontwarn java.lang.instrument.** +-dontwarn java.lang.management.** +-dontwarn javax.management.** + +-include "adjust-keeps.cfg" + +-include "leakcanary-keeps.cfg" + +-include "appcompat-v7-keeps.cfg" + +-include "proguard-android.cfg" + +-include "proguard-leanplum.cfg" + +-include "../../geckoview/proguard-rules.txt" diff --git a/mobile/android/config/proguard/strip-libs.cfg b/mobile/android/config/proguard/strip-libs.cfg new file mode 100644 index 0000000000..80a1f151c9 --- /dev/null +++ b/mobile/android/config/proguard/strip-libs.cfg @@ -0,0 +1,41 @@ +# Proguard step for stripping debug information. +# +# This is useful to work around a bug in the way Proguard handles debug information: it +# sometimes corrupts it. Classes with corrupt debug information cannot be dexed, but +# classes with *no* debug information can be. There's no way to configure Proguard to +# delete debug information on a per-class basis, so we need this special extra step for +# stripping debug information only from those classes for which the Proguard bug is +# encountered. +# +# Currently, this pass is applied to all bundled library jars for which we are not +# compiling the source. This is slightly more than is strictly necessary to work around +# the Proguard bug, but such debug information is of negligible value and stripping it +# too slightly simplifies the makefile and saves us a handful of kilobytes of binary size. +# +# Configuring Proguard to do nothing except strip metadata is done by having it run only +# the obfuscation pass, but with a configuration that prevents it from renaming any classes. +# It then attempts to delete class metadata, so we further configure it not to do so for +# anything except the problematic debug information. + +# Run only the obfuscator. +-dontoptimize +-dontshrink +-dontpreverify +-verbose + +# Don't rename anything. +-keeppackagenames + +# Seriously, don't rename anything. +-keep class * +-keepclassmembers class * { + *; +} + +# Don't delete other useful metadata. +-keepattributes Exceptions,InnerClasses,Signature,Deprecated,*Annotation*,EnclosingMethod + +# Don't print spurious warnings from the support library. +# See: http://stackoverflow.com/questions/22441366/note-android-support-v4-text-icucompatics-cant-find-dynamically-referenced-cl +-dontnote android.support.** +-dontwarn android.support.** diff --git a/mobile/android/config/tooltool-manifests/android-x86/releng.manifest b/mobile/android/config/tooltool-manifests/android-x86/releng.manifest new file mode 100644 index 0000000000..e987e17991 --- /dev/null +++ b/mobile/android/config/tooltool-manifests/android-x86/releng.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 6856444, + "visibility": "public", + "unpack": true, + "digest": "d68dd7d31b0153095ecf5cde5837fb1f95dc6e3f799d496fb764f7afeb9c6095c332467177c3aa54d3749b1901e0d6fa84c42162526e764e8a9d2196a0189861", + "algorithm": "sha512", + "filename": "jsshell.tar.xz" + } +] diff --git a/mobile/android/config/tooltool-manifests/android/releng.manifest b/mobile/android/config/tooltool-manifests/android/releng.manifest new file mode 100644 index 0000000000..e987e17991 --- /dev/null +++ b/mobile/android/config/tooltool-manifests/android/releng.manifest @@ -0,0 +1,10 @@ +[ + { + "size": 6856444, + "visibility": "public", + "unpack": true, + "digest": "d68dd7d31b0153095ecf5cde5837fb1f95dc6e3f799d496fb764f7afeb9c6095c332467177c3aa54d3749b1901e0d6fa84c42162526e764e8a9d2196a0189861", + "algorithm": "sha512", + "filename": "jsshell.tar.xz" + } +] diff --git a/mobile/android/confvars.sh b/mobile/android/confvars.sh new file mode 100644 index 0000000000..c1041f317d --- /dev/null +++ b/mobile/android/confvars.sh @@ -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/. + +MOZ_APP_VENDOR=Mozilla + +MOZ_APP_UA_NAME=Firefox + +BROWSER_CHROME_URL=chrome://browser/content/browser.xul + +MOZ_BRANDING_DIRECTORY=mobile/android/branding/unofficial +MOZ_OFFICIAL_BRANDING_DIRECTORY=mobile/android/branding/official +# MOZ_APP_DISPLAYNAME is set by branding/configure.sh + +MOZ_RAW=1 + +MOZ_APP_ID={aa3c5121-dab2-40e2-81ca-7ea25febc110} diff --git a/mobile/android/docs/fenix.rst b/mobile/android/docs/fenix.rst new file mode 100644 index 0000000000..46ed237613 --- /dev/null +++ b/mobile/android/docs/fenix.rst @@ -0,0 +1,51 @@ +Building Firefox for Android +============================ + +First, you'll want to `set up your machine to build Firefox </setup>`_. +Follow the instructions there, choosing "GeckoView/Firefox for Android" as +the bootstrap option. + +Once you're set up and have a GeckoView build from the above, please +continue with the following steps. + +1. Clone the repository and initial setup +----------------------------------------- + +.. code-block:: shell + + git clone https://github.com/mozilla-mobile/firefox-android + cd firefox-android/fenix + echo dependencySubstitutions.geckoviewTopsrcdir=/path/to/mozilla-central > local.properties + +replace `/path/to/mozilla-central` with the location of your mozilla-central/mozilla-unified source tree. + +2. Build +-------- + +.. code-block:: shell + + export JAVA_HOME=$HOME/.mozbuild/jdk/jdk-17.0.6+10 + export ANDROID_HOME=$HOME/.mozbuild/android-sdk-<os_name> + ./gradlew clean app:assembleDebug + +`<os_name>` is either `linux`, `macosx` or `windows` depending on the OS you're building from. + + +For more details, check out the `more complete documentation <https://github.com/mozilla-mobile/firefox-android/tree/main/fenix>`_. + +3. Run +------ + +From the gecko working directory: + +.. code-block:: shell + + ./mach android-emulator + + +From the firefox-android working directory: + +.. code-block:: shell + + ./gradlew :app:installFenixDebug + "$ANDROID_HOME/platform-tools/adb" shell am start -n org.mozilla.fenix.debug/org.mozilla.fenix.debug.App diff --git a/mobile/android/docs/geckoview/assets/DisableInstantRun.png b/mobile/android/docs/geckoview/assets/DisableInstantRun.png Binary files differnew file mode 100644 index 0000000000..e666f4c575 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/DisableInstantRun.png diff --git a/mobile/android/docs/geckoview/assets/GeckoViewStructure.png b/mobile/android/docs/geckoview/assets/GeckoViewStructure.png Binary files differnew file mode 100644 index 0000000000..a2ace94c32 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/GeckoViewStructure.png diff --git a/mobile/android/docs/geckoview/assets/LogInBugzilla.png b/mobile/android/docs/geckoview/assets/LogInBugzilla.png Binary files differnew file mode 100644 index 0000000000..ad18c58e30 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/LogInBugzilla.png diff --git a/mobile/android/docs/geckoview/assets/LogInOrRegister.png b/mobile/android/docs/geckoview/assets/LogInOrRegister.png Binary files differnew file mode 100644 index 0000000000..134ad28111 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/LogInOrRegister.png diff --git a/mobile/android/docs/geckoview/assets/LogInPhab.png b/mobile/android/docs/geckoview/assets/LogInPhab.png Binary files differnew file mode 100644 index 0000000000..68c6c5a60e --- /dev/null +++ b/mobile/android/docs/geckoview/assets/LogInPhab.png diff --git a/mobile/android/docs/geckoview/assets/api-diagram.png b/mobile/android/docs/geckoview/assets/api-diagram.png Binary files differnew file mode 100644 index 0000000000..da7d5acbdf --- /dev/null +++ b/mobile/android/docs/geckoview/assets/api-diagram.png diff --git a/mobile/android/docs/geckoview/assets/code-layers.png b/mobile/android/docs/geckoview/assets/code-layers.png Binary files differnew file mode 100644 index 0000000000..0ff9e27d5c --- /dev/null +++ b/mobile/android/docs/geckoview/assets/code-layers.png diff --git a/mobile/android/docs/geckoview/assets/css/geckoview.css b/mobile/android/docs/geckoview/assets/css/geckoview.css new file mode 100644 index 0000000000..e9564a3bc3 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/css/geckoview.css @@ -0,0 +1,10 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* There is some code in just-the-docs that completely breaks styling for code + * blocks, this is an attempt to fix that. */ +code { + font-weight: inherit; + font-size: 80%; +} diff --git a/mobile/android/docs/geckoview/assets/geckoview_icon_1color-black.png b/mobile/android/docs/geckoview/assets/geckoview_icon_1color-black.png Binary files differnew file mode 100644 index 0000000000..ab9e49d25c --- /dev/null +++ b/mobile/android/docs/geckoview/assets/geckoview_icon_1color-black.png diff --git a/mobile/android/docs/geckoview/assets/geckoview_icon_1color-green.png b/mobile/android/docs/geckoview/assets/geckoview_icon_1color-green.png Binary files differnew file mode 100644 index 0000000000..3f6738e6b9 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/geckoview_icon_1color-green.png diff --git a/mobile/android/docs/geckoview/assets/geckoview_icon_fullcolor-green.png b/mobile/android/docs/geckoview/assets/geckoview_icon_fullcolor-green.png Binary files differnew file mode 100644 index 0000000000..162891e993 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/geckoview_icon_fullcolor-green.png diff --git a/mobile/android/docs/geckoview/assets/js/search-data.json b/mobile/android/docs/geckoview/assets/js/search-data.json new file mode 100644 index 0000000000..50a4b9f489 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/js/search-data.json @@ -0,0 +1,12 @@ +--- +--- +{ + {% for page in site.html_pages %}"{{ forloop.index0 }}": { + "id": "{{ forloop.index0 }}", + "title": "{{ page.title | xml_escape }}", + "content": "{{ page.content | markdownify | strip_html | xml_escape | remove: 'Table of contents' | strip_newlines | replace: '\', ' ' }}", + "url": "{{ page.url | absolute_url | xml_escape }}", + "relUrl": "{{ page.url | xml_escape }}" + }{% if forloop.last %}{% else %}, + {% endif %}{% endfor %} +} diff --git a/mobile/android/docs/geckoview/assets/pageload-diagram.png b/mobile/android/docs/geckoview/assets/pageload-diagram.png Binary files differnew file mode 100644 index 0000000000..a1a15ea95a --- /dev/null +++ b/mobile/android/docs/geckoview/assets/pageload-diagram.png diff --git a/mobile/android/docs/geckoview/assets/view-runtime-session.png b/mobile/android/docs/geckoview/assets/view-runtime-session.png Binary files differnew file mode 100644 index 0000000000..17a3245d23 --- /dev/null +++ b/mobile/android/docs/geckoview/assets/view-runtime-session.png diff --git a/mobile/android/docs/geckoview/consumer/automation.rst b/mobile/android/docs/geckoview/consumer/automation.rst new file mode 100644 index 0000000000..2fd596e51b --- /dev/null +++ b/mobile/android/docs/geckoview/consumer/automation.rst @@ -0,0 +1,124 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +Configuring GeckoView for Automation +#################################### +How to set environment variables, Gecko arguments, and Gecko preferences for automation and debugging. + +.. contents:: :local: + +Configuring GeckoView +===================================== + +GeckoView and the underlying Gecko engine have many, many options, switches, and toggles "under the hood". Automation (and to a lesser extent, debugging) can require configuring the Gecko engine to allow (or disallow) specific actions or features. + +Some such actions and features are controlled by the `GeckoRuntimeSettings <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoRuntimeSettings.html>`_ instance you configure in your consuming project. For example, remote debugging web content via the Firefox Developer Tools is configured by `GeckoRuntimeSettings.Builder#remoteDebuggingEnabled <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoRuntimeSettings.Builder.html#remoteDebuggingEnabled(boolean)>`_ + +Not all actions and features have GeckoView API interfaces. Generally, actions and features that do not have GeckoView API interfaces are not intended for broad usage. Configuration for these types of things is controlled by: + +- environment variables in GeckoView's runtime environment +- command line arguments to the Gecko process +- internal Gecko preferences + +Automation-specific configuration is generally in this category. + +Running GeckoView with environment variables +------------------------------------------------ + +After a successful ``./mach build``, ``./mach run --setenv`` can be used to run GeckoView with +the given environment variables. + +For example, to enable extended logging for ``JSComponentLoader``, run ``./mach +run --setenv MOZ_LOG=JSComponentLoader:5``. + +Reading configuration from a file +------------------------------------------------ + +When GeckoView is embedded into a debugabble application (i.e., when your manifest includes ``android:debuggable="true"``), by default GeckoView reads configuration from a file named ``/data/local/tmp/$PACKAGE-geckoview-config.yaml``. For example, if your Android package name is ``com.yourcompany.yourapp``, GeckoView will read configuration from:: + + /data/local/tmp/com.yourcompany.yourapp-geckoview-config.yaml + + +Configuration file format +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The configuration file format is `YAML <https://yaml.org>`_. The following keys are recognized: + +- ``env`` is a map from string environment variable name to string value to set in GeckoView's runtime environment +- ``args`` is a list of string command line arguments to pass to the Gecko process +- ``prefs`` is a map from string Gecko preference name to boolean, string, or integer value to set in the Gecko profile + +.. code-block:: yaml + + # Contents of /data/local/tmp/com.yourcompany.yourapp-geckoview-config.yaml + + env: + MOZ_LOG: nsHttp:5 + + args: + - --marionette + - --profile + - "/path/to/gecko-profile" + + prefs: + foo.bar.boolean: true + foo.bar.string: "string" + foo.bar.int: 500 + + +Verifying configuration from a file +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When configuration from a file is read, GeckoView logs to ``adb logcat``, like: :: + + GeckoRuntime I Adding debug configuration from: /data/local/tmp/org.mozilla.geckoview_example-geckoview-config.yaml + GeckoDebugConfig D Adding environment variables from debug config: {MOZ_LOG=nsHttp:5} + GeckoDebugConfig D Adding arguments from debug config: [--marionette] + GeckoDebugConfig D Adding prefs from debug config: {foo.bar.baz=true} + + +When a configuration file is found but cannot be parsed, an error is logged and the file is ignored entirely. When a configuration file is not found, nothing is logged. + +Controlling configuration from a file +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +By default, GeckoView provides a secure web rendering engine. Custom configuration can compromise security in many ways: by storing sensitive data in insecure locations on the device, by trusting websites with incorrect security configurations, by not validating HTTP Public Key Pinning configurations; the list goes on. + +**You should only allow such configuration if your end-user opts-in to the configuration!** + +GeckoView will always read configuration from a file if the consuming Android package is set as the current Android "debug app" (see ``set-debug-app`` and ``clear-debug-app`` in the `adb documentation <https://developer.android.com/studio/command-line/adb>`_). An Android package can be set as the "debug app" without regard to the ``android:debuggable`` flag. There can only be one "debug app" set at a time. To disable the "debug app" check, `disable reading configuration from a file entirely <#disabling-reading-configuration-from-a-file-entirely>`_. Setting an Android package as the "debug app" requires privileged shell access to the device (generally via ``adb shell am ...``, which is only possible on devices which have ADB debugging enabled) and therefore it is safe to act on the "debug app" flag. + +To enable reading configuration from a file: :: + + adb shell am set-debug-app --persistent com.yourcompany.yourapp + + +To disable reading configuration from a file: :: + + adb shell am clear-debug-app + +Enabling reading configuration from a file unconditionally +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Some applications (for example, web browsers) may want to allow configuration for automation unconditionally, i.e., even when the application is not debuggable, like release builds that have ``android:debuggable="false"``. In such cases, you can use `GeckoRuntimeSettings.Builder#configFilePath`_ to force GeckoView to read configuration from the given file path, like: + +.. code-block:: java + + new GeckoRuntimeSettings.Builder() + .configFilePath("/your/app/specific/location") + .build(); + +Disabling reading configuration from a file entirely +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To force GeckoView to never read configuration from a file, even when the embedding application is debuggable, invoke `GeckoRuntimeSettings.Builder#configFilePath`_ with an empty path, like: + +.. code-block:: java + + new GeckoRuntimeSettings.Builder() + .configFilePath("") + .build(); + +The empty path is recognized and no file I/O is performed. + + +.. _GeckoRuntimeSettings.Builder#configFilePath: https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoRuntimeSettings.Builder.html#configFilePath(java.lang.String) diff --git a/mobile/android/docs/geckoview/consumer/geckoview-quick-start.rst b/mobile/android/docs/geckoview/consumer/geckoview-quick-start.rst new file mode 100644 index 0000000000..6a529a0dfa --- /dev/null +++ b/mobile/android/docs/geckoview/consumer/geckoview-quick-start.rst @@ -0,0 +1,117 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +Getting Started with GeckoView +###################################### + +How to use GeckoView in your Android app. + +*Building a browser? Check out* `Android Components <https://mozilla-mobile.github.io/firefox-android/>`_, *our collection of ready-to-use support libraries!* + +The following article is a brief guide to embedding GeckoView in an app. For a more in depth tutorial on getting started with GeckoView please read the article we have published on `raywenderlich.com <https://www.raywenderlich.com/1381698-android-tutorial-for-geckoview-getting-started>`_. + +.. contents:: :local: + +Configure Gradle +================= + +You need to add or edit four stanzas inside your module's ``build.gradle`` file. + +**1. Set the GeckoView version** + +*Like Firefox, GeckoView has three release channels: Stable, Beta, and Nightly. Browse the* `Maven Repository <https://maven.mozilla.org/?prefix=maven2/org/mozilla/geckoview/>`_ *to see currently available builds.* + +.. code-block:: groovy + + ext { + geckoviewChannel = <channel> + geckoviewVersion = <version> + } + + +**2. Add Mozilla's Maven repository** + +.. code-block:: groovy + + repositories { + maven { + url "https://maven.mozilla.org/maven2/" + } + } + + +**3. Java 17 required support** + +As GeckoView uses some Java 17 APIs, it requires these compatibility flags: + +.. code-block:: groovy + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + +**4. Add GeckoView Implementations** + +.. code-block:: groovy + + dependencies { + // ... + implementation "org.mozilla.geckoview:geckoview-${geckoviewChannel}:${geckoviewVersion}" + } + +Add GeckoView to a Layout +========================== + +Inside a layout ``.xml`` file, add the following: + +.. code-block:: xml + + <org.mozilla.geckoview.GeckoView + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/geckoview" + android:layout_width="fill_parent" + android:layout_height="fill_parent" /> + +Initialize GeckoView in an Activity +==================================== + +**1. Import the GeckoView classes inside an Activity:** + +.. code-block:: java + + import org.mozilla.geckoview.GeckoRuntime; + import org.mozilla.geckoview.GeckoSession; + import org.mozilla.geckoview.GeckoView; + + +**2. Create a ``static`` member variable to store the ``GeckoRuntime`` instance.** + +.. code-block:: java + + private static GeckoRuntime sRuntime; + +**3. In that activity's** ``onCreate`` **function, add the following:** + +.. code-block:: java + + GeckoView view = findViewById(R.id.geckoview); + GeckoSession session = new GeckoSession(); + + // Workaround for Bug 1758212 + session.setContentDelegate(new GeckoSession.ContentDelegate() {}); + + if (sRuntime == null) { + // GeckoRuntime can only be initialized once per process + sRuntime = GeckoRuntime.create(this); + } + + session.open(sRuntime); + view.setSession(session); + session.loadUri("about:buildconfig"); // Or any other URL... + +You're done! +============== + +Your application should now load and display a webpage inside of GeckoView. + +To learn more about GeckoView's capabilities, review GeckoView's `JavaDoc <https://mozilla.github.io/geckoview/javadoc/mozilla-central/>`_ or the `reference application <https://searchfox.org/mozilla-central/source/mobile/android/geckoview_example>`_. diff --git a/mobile/android/docs/geckoview/consumer/index.rst b/mobile/android/docs/geckoview/consumer/index.rst new file mode 100644 index 0000000000..793ead2386 --- /dev/null +++ b/mobile/android/docs/geckoview/consumer/index.rst @@ -0,0 +1,23 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +=============== +Using GeckoView +=============== + +.. toctree:: + :maxdepth: 1 + :glob: + :hidden: + + * + +- `GeckoView Quick Start Guide <geckoview-quick-start.html>`__: Get + GeckoView up and running inside your application. +- `Interacting with Web Content <web-extensions.html>`__: Writing Web + Extensions, running content scripts and interacting with Javascript + running in a web page. +- `Working with Site Permissions <permissions.html>`__: Handling and + responding to requests from websites for permissions, such as + geolocation, storage, media etc. +- `Configuring GeckoView for Automation <automation.html>`__: Get GeckoView + set up on your automation system. diff --git a/mobile/android/docs/geckoview/consumer/permissions.rst b/mobile/android/docs/geckoview/consumer/permissions.rst new file mode 100644 index 0000000000..5dbab9b6b6 --- /dev/null +++ b/mobile/android/docs/geckoview/consumer/permissions.rst @@ -0,0 +1,287 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +============================= +Working with Site Permissions +============================= + +When a website wants to access certain services on a user’s device, it +will send out a permissions request. This document will explain how to +use GeckoView to receive those requests, and respond to them by granting +or denying those permissions. + +.. contents:: :local: + +The Permission Delegate +----------------------- + +The way an app interacts with site permissions in GeckoView is through +the +`PermissionDelegate <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html>`_. +There are three broad categories of permission that the +``PermissionDelegate`` handles, Android Permissions, Content Permissions +and Media Permissions. All site permissions handled by GeckoView fall +into one of these three categories. + +To get notified about permission requests, you need to implement the +``PermissionDelegate`` interface: + +.. code:: java + + private class ExamplePermissionDelegate implements GeckoSession.PermissionDelegate { + @Override + public void onAndroidPermissionsRequest(final GeckoSession session, + final String[] permissions, + final Callback callback) { } + + @Override + public void onContentPermissionRequest(final GeckoSession session, + final String uri, + final int type, final Callback callback) { } + + @Override + public void onMediaPermissionRequest(final GeckoSession session, + final String uri, + final MediaSource[] video, + final MediaSource[] audio, + final MediaCallback callback) { } + } + +You will then need to register the delegate with your +`GeckoSession <https://mozilla.github.io/geckoview/https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.html>`_ +instance. + +.. code:: java + + public class GeckoViewActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + ... + + final ExamplePermissionDelegate permission = new ExamplePermissionDelegate(); + session.setPermissionDelegate(permission); + + ... + } + } + +Android Permissions +~~~~~~~~~~~~~~~~~~~ + +Android permissions are requested whenever a site wants access to a +device’s navigation or input capabilities. + +The user will often need to grant these Android permissions to the app +alongside granting the Content or Media site permissions. + +When you receive an +`onAndroidPermissionsRequest <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#onAndroidPermissionsRequest(org.mozilla.geckoview.GeckoSession,java.lang.String[],org.mozilla.geckoview.GeckoSession.PermissionDelegate.Callback)>`_ +call, you will also receive the ``GeckoSession`` the request was sent +from, an array containing the permissions that are being requested, and +a +`Callback`_ +to respond to the request. It is then up to the app to request those +permissions from the device, which can be done using +`requestPermissions <https://developer.android.com/reference/android/app/Activity#requestPermissions(java.lang.String%5B%5D,%2520int)>`_. + +Possible ``permissions`` values are: +`ACCESS_COARSE_LOCATION <https://developer.android.com/reference/android/Manifest.permission.html#ACCESS_COARSE_LOCATION>`_, +`ACCESS_FINE_LOCATION <https://developer.android.com/reference/android/Manifest.permission.html#ACCESS_FINE_LOCATION>`_, +`CAMERA <https://developer.android.com/reference/android/Manifest.permission.html#CAMERA>`_ +or +`RECORD_AUDIO <https://developer.android.com/reference/android/Manifest.permission.html#RECORD_AUDIO>`_. + +.. code:: java + + private class ExamplePermissionDelegate implements GeckoSession.PermissionDelegate { + private Callback mCallback; + + public void onRequestPermissionsResult(final String[] permissions, + final int[] grantResults) { + if (mCallback == null) { return; } + + final Callback cb = mCallback; + mCallback = null; + for (final int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + // At least one permission was not granted. + cb.reject(); + return; + } + } + cb.grant(); + } + + @Override + public void onAndroidPermissionsRequest(final GeckoSession session, + final String[] permissions, + final Callback callback) { + mCallback = callback; + requestPermissions(permissions, androidPermissionRequestCode); + } + } + + public class GeckoViewActivity extends AppCompatActivity { + @Override + public void onRequestPermissionsResult(final int requestCode, + final String[] permissions, + final int[] grantResults) { + if (requestCode == REQUEST_PERMISSIONS || + requestCode == REQUEST_WRITE_EXTERNAL_STORAGE) { + final ExamplePermissionDelegate permission = (ExamplePermissionDelegate) + getCurrentSession().getPermissionDelegate(); + permission.onRequestPermissionsResult(permissions, grantResults); + } else { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + } + +Content Permissions +~~~~~~~~~~~~~~~~~~~ + +Content permissions are requested whenever a site wants access to +content that is stored on the device. The content permissions that can +be requested through GeckoView are: +`Geolocation <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_GEOLOCATION>`_, +`Site Notifications <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_DESKTOP_NOTIFICATION>`_, +`Persistent Storage <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE>`_, +`XR <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_XR>`_, +`Autoplay Inaudible <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_INAUDIBLE>`_, +`Autoplay Audible <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_AUDIBLE>`_, +and +`DRM Media access <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_MEDIA_KEY_SYSTEM_ACCESS>`_. +Additionally, `tracking protection exceptions <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#PERMISSION_TRACKING>`_ +are treated as a type of content permission. + +When you receive an +`onContentPermissionRequest <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission)>`_ +call, you will also receive the ``GeckoSession`` the request was sent +from, and all relevant information about the permission being requested +stored in a `ContentPermission <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.ContentPermission.html>`_. +It is then up to the app to present UI to the user asking for the +permissions, and to notify GeckoView of the response via the returned +``GeckoResult``. + +Once a permission has been set in this fashion, GeckoView will persist it +across sessions until it is cleared or modified. When a page is loaded, +the active permissions associated with it (both allowed and denied) will +be reported in `onLocationChange <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)>`_ +as a list of ``ContentPermission`` objects; additionally, one may check all stored +content permissions by calling `getAllPermissions <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/StorageController.html#getAllPermissions()>`_ +and the content permissions associated with a given URI by calling +`getPermissions <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/StorageController.html#getPermissions(java.lang.String,java.lang.String)>`_. +In order to modify an existing permission, you will need the associated +``ContentPermission`` (which can be retrieved from any of the above methods); +then, call `setPermission <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/StorageController.html#setPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int)>`_ +with the desired new value, or `VALUE_PROMPT <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.ContentPermission.html#VALUE_PROMPT>`_ +if you wish to unset the permission and let the site request it again in the future. + +Media Permissions +~~~~~~~~~~~~~~~~~ + +Media permissions are requested whenever a site wants access to play or +record media from the device’s camera and microphone. + +When you receive an +`onMediaPermissionRequest <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.html#onMediaPermissionRequest(org.mozilla.geckoview.GeckoSession,java.lang.String,org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource[],org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaSource[],org.mozilla.geckoview.GeckoSession.PermissionDelegate.MediaCallback)>`_ +call, you will also receive the ``GeckoSession`` the request was sent +from, the URI of the site that requested the permission, as a String, +the list of video devices available, if requesting video, the list of +audio devices available, if requesting audio, and a +`MediaCallback <https://searchfox.org/mozilla-central/source/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java#686>`_ +to respond to the request. + +It is up to the app to present UI to the user asking for the +permissions, and to notify GeckoView of the response via the +``MediaCallback``. + +*Please note, 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 when listed. +It is the responsibility of consumers to ensure that media permission +requests are not displayed in this case.* + +.. code:: java + + private class ExamplePermissionDelegate implements GeckoSession.PermissionDelegate { + @Override + public void onMediaPermissionRequest(final GeckoSession session, + final String uri, + final MediaSource[] video, + final MediaSource[] audio, + final MediaCallback callback) { + // Reject permission if Android permission has been previously denied. + if ((audio != null + && ContextCompat.checkSelfPermission(GeckoViewActivity.this, + Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) + || (video != null + && ContextCompat.checkSelfPermission(GeckoViewActivity.this, + Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED)) { + callback.reject(); + return; + } + + final String host = Uri.parse(uri).getAuthority(); + final String title; + if (audio == null) { + title = getString(R.string.request_video, host); + } else if (video == null) { + title = getString(R.string.request_audio, host); + } else { + title = getString(R.string.request_media, host); + } + + // Get the media device name from the `MediaDevice` + String[] videoNames = normalizeMediaName(video); + String[] audioNames = normalizeMediaName(audio); + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + + // Create drop down boxes to allow users to select which device to grant permission to + final LinearLayout container = addStandardLayout(builder, title, null); + final Spinner videoSpinner; + if (video != null) { + videoSpinner = addMediaSpinner(builder.getContext(), container, video, videoNames); // create spinner and add to alert UI + } else { + videoSpinner = null; + } + + final Spinner audioSpinner; + if (audio != null) { + audioSpinner = addMediaSpinner(builder.getContext(), container, audio, audioNames); // create spinner and add to alert UI + } else { + audioSpinner = null; + } + + builder.setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + // gather selected media devices and grant access + final MediaSource video = (videoSpinner != null) + ? (MediaSource) videoSpinner.getSelectedItem() : null; + final MediaSource audio = (audioSpinner != null) + ? (MediaSource) audioSpinner.getSelectedItem() : null; + callback.grant(video, audio); + } + }); + + final AlertDialog dialog = builder.create(); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(final DialogInterface dialog) { + callback.reject(); + } + }); + dialog.show(); + } + } + +To see the ``PermissionsDelegate`` in action, you can find the full +example implementation in the `GeckoView example +app <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.MediaCallback.html>`_. + +.. _Callback: https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.Callback.html diff --git a/mobile/android/docs/geckoview/consumer/web-extensions.rst b/mobile/android/docs/geckoview/consumer/web-extensions.rst new file mode 100644 index 0000000000..ef4e75348f --- /dev/null +++ b/mobile/android/docs/geckoview/consumer/web-extensions.rst @@ -0,0 +1,403 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +============================ +Interacting with Web content +============================ + +Interacting with Web content and WebExtensions +============================================== + +GeckoView allows embedder applications to register and run +`WebExtensions <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions>`_ +in a GeckoView instance. Extensions are the preferred way to interact +with Web content. + +.. contents:: :local: + +Running extensions in GeckoView +------------------------------- + +Extensions bundled with applications can be provided in a folder in the +``/assets`` section of the APK. Like ordinary extensions, every +extension bundled with GeckoView requires a +`manifest.json <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json>`_ +file. + +To locate files bundled with the APK, GeckoView provides a shorthand +``resource://android/`` that points to the root of the APK. + +E.g. ``resource://android/assets/messaging/`` will point to the +``/assets/messaging/`` folder present in the APK. + +Note: Every installed extension will need an +`id <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_specific_settings>`_ +and +`version <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version>`_ +specified in the ``manifest.json`` file. + +To install a bundled extension in GeckoView, simply call +`WebExtensionController.installBuiltIn <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtensionController.html#installBuiltIn(java.lang.String)>`_. + +.. code:: java + + runtime.getWebExtensionController() + .installBuiltIn("resource://android/assets/messaging/") + +Note that the lifetime of the extension is not tied with the lifetime of +the +`GeckoRuntime <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoRuntime.html>`_ +instance. The extension persists even when your app is restarted. +Installing at every start up is fine, but it could be slow. To avoid +installing multiple times you can use ``WebExtensionRuntime.ensureBuiltIn``, +which will only install if the extension is not installed yet. + +.. code:: java + + runtime.getWebExtensionController() + .ensureBuiltIn("resource://android/assets/messaging/", "messaging@example.com") + .accept( + extension -> Log.i("MessageDelegate", "Extension installed: " + extension), + e -> Log.e("MessageDelegate", "Error registering WebExtension", e) + ); + +Communicating with Web Content +------------------------------ + +GeckoView allows bidirectional communication with Web pages through +extensions. + +When using GeckoView, `native +messaging <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging#Exchanging_messages>`_ +can be used for communicating to and from the browser. + +- `runtime.sendNativeMessage <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/sendNativeMessage>`_ + for one-off messages. +- `runtime.connectNative <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/connectNative>`_ + for connection-based messaging. + +Note: these APIs are only available when the ``geckoViewAddons`` +`permission <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions>`_ +is present in the ``manifest.json`` file of the extension. + +One-off messages +~~~~~~~~~~~~~~~~ + +The easiest way to send messages from a `content +script <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts>`_ +or a `background +script <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Anatomy_of_a_WebExtension#Background_scripts>`_ +is using +`runtime.sendNativeMessage <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/sendNativeMessage>`_. + +Note: Ordinarily, native extensions would use a `native +manifest <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging#App_manifest>`_ +to define what native app identifier to use. For GeckoView this is *not* +needed, the ``nativeApp`` parameter in ``setMessageDelegate`` will be +use to determine what native app string is used. + +Messaging Example +~~~~~~~~~~~~~~~~~ + +To receive messages from the background script, call +`setMessageDelegate <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.html#setMessageDelegate(org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)>`_ +on the +`WebExtension <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.html>`_ +object. + +.. code:: java + + extension.setMessageDelegate(messageDelegate, "browser"); + +`SessionController.setMessageDelegate <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.SessionController.html#setMessageDelegate(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String)>`_ +allows the app to receive messages from content scripts. + +.. code:: java + + session.getWebExtensionController() + .setMessageDelegate(extension, messageDelegate, "browser"); + +Note: the ``"browser"`` parameter in the code above determines what +native app id the extension can use to send native messages. + +Note: extension can only send messages from content scripts if +explicitly authorized by the app by adding +``nativeMessagingFromContent`` in the manifest.json file, e.g. + +.. code:: json + + "permissions": [ + "nativeMessaging", + "nativeMessagingFromContent", + "geckoViewAddons" + ] + +Example +~~~~~~~ + +Let’s set up an activity that registers an extension located in the +``/assets/messaging/`` folder of the APK. This activity will set up a +`MessageDelegate <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.MessageDelegate.html>`_ +that will be used to communicate with Web Content. + +You can find the full example here: +`MessagingExample <https://searchfox.org/mozilla-central/source/mobile/android/examples/messaging_example>`_. + +Activity.java +^^^^^^^^^^^^^ + +.. code:: java + + WebExtension.MessageDelegate messageDelegate = new WebExtension.MessageDelegate() { + @Nullable + public GeckoResult<Object> onMessage(final @NonNull String nativeApp, + final @NonNull Object message, + final @NonNull WebExtension.MessageSender sender) { + // The sender object contains information about the session that + // originated this message and can be used to validate that the message + // has been sent from the expected location. + + // Be careful when handling the type of message as it depends on what + // type of object was sent from the WebExtension script. + if (message instanceof JSONObject) { + // Do something with message + } + return null; + } + }; + + // Let's make sure the extension is installed + runtime.getWebExtensionController() + .ensureBuiltIn(EXTENSION_LOCATION, "messaging@example.com").accept( + // Set delegate that will receive messages coming from this extension. + extension -> session.getWebExtensionController() + .setMessageDelegate(extension, messageDelegate, "browser"), + // Something bad happened, let's log an error + e -> Log.e("MessageDelegate", "Error registering extension", e) + ); + + +Now add the ``geckoViewAddons``, ``nativeMessaging`` and +``nativeMessagingFromContent`` permissions to your ``manifest.json`` +file. + +/assets/messaging/manifest.json +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: json + + { + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Example messaging web extension.", + "browser_specific_settings": { + "gecko": { + "id": "messaging@example.com" + } + }, + "content_scripts": [ + { + "matches": ["*://*.twitter.com/*"], + "js": ["messaging.js"] + } + ], + "permissions": [ + "nativeMessaging", + "nativeMessagingFromContent", + "geckoViewAddons" + ] + } + +And finally, write a content script that will send a message to the app +when a certain event occurs. For example, you could send a message +whenever a `WPA +manifest <https://developer.mozilla.org/en-US/docs/Web/Manifest>`_ is +found on the page. Note that our ``nativeApp`` identifier used for +``sendNativeMessage`` is the same as the one used in the +``setMessageDelegate`` call in `Activity.java <#activityjava>`_. + +/assets/messaging/messaging.js +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: JavaScript + + let manifest = document.querySelector("head > link[rel=manifest]"); + if (manifest) { + fetch(manifest.href) + .then(response => response.json()) + .then(json => { + let message = {type: "WPAManifest", manifest: json}; + browser.runtime.sendNativeMessage("browser", message); + }); + } + +You can handle this message in the ``onMessage`` method in the +``messageDelegate`` `above <#activityjava>`_. + +.. code:: java + + @Nullable + public GeckoResult<Object> onMessage(final @NonNull String nativeApp, + final @NonNull Object message, + final @NonNull WebExtension.MessageSender sender) { + if (message instanceof JSONObject) { + JSONObject json = (JSONObject) message; + try { + if (json.has("type") && "WPAManifest".equals(json.getString("type"))) { + JSONObject manifest = json.getJSONObject("manifest"); + Log.d("MessageDelegate", "Found WPA manifest: " + manifest); + } + } catch (JSONException ex) { + Log.e("MessageDelegate", "Invalid manifest", ex); + } + } + return null; + } + +Note that, in the case of content scripts, ``sender.session`` will be a +reference to the ``GeckoSession`` instance from which the message +originated. For background scripts, ``sender.session`` will always be +``null``. + +Also note that the type of ``message`` will depend on what was sent from +the extension. + +The type of ``message`` will be ``JSONObject`` when the extension sends +a javascript object, but could also be a primitive type if the extension +sends one, e.g. for + +.. code:: javascript + + runtime.browser.sendNativeMessage("browser", "Hello World!"); + +the type of ``message`` will be ``java.util.String``. + +Connection-based messaging +-------------------------- + +For more complex scenarios or for when you want to send messages *from* +the app to the extension, +`runtime.connectNative <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/connectNative>`_ +is the appropriate API to use. + +``connectNative`` returns a +`runtime.Port <https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port>`_ +that can be used to send messages to the app. On the app side, +implementing +`MessageDelegate#onConnect <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.MessageDelegate.html#onConnect(org.mozilla.geckoview.WebExtension.Port)>`_ +will allow you to receive a +`Port <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.Port.html>`_ +object that can be used to receive and send messages to the extension. + +The following example can be found +`here <https://searchfox.org/mozilla-central/source/mobile/android/examples/port_messaging_example>`_. + +For this example, the extension side will do the following: + +- open a port on the background script using ``connectNative`` +- listen on the port and log to console every message received +- send a message immediately after opening the port. + +/assets/messaging/background.js +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: JavaScript + + // Establish connection with app + let port = browser.runtime.connectNative("browser"); + port.onMessage.addListener(response => { + // Let's just echo the message back + port.postMessage(`Received: ${JSON.stringify(response)}`); + }); + port.postMessage("Hello from WebExtension!"); + +On the app side, following the `above <#activityjava>`_ example, +``onConnect`` will be storing the ``Port`` object in a member variable +and then using it when needed. + +.. code:: java + + private WebExtension.Port mPort; + + @Override + protected void onCreate(Bundle savedInstanceState) { + // ... initialize GeckoView + + // This delegate will handle all communications from and to a specific Port + // object + WebExtension.PortDelegate portDelegate = new WebExtension.PortDelegate() { + public WebExtension.Port port = null; + + public void onPortMessage(final @NonNull Object message, + final @NonNull WebExtension.Port port) { + // This method will be called every time a message is sent from the + // extension through this port. For now, let's just log a + // message. + Log.d("PortDelegate", "Received message from WebExtension: " + + message); + } + + public void onDisconnect(final @NonNull WebExtension.Port port) { + // After this method is called, this port is not usable anymore. + if (port == mPort) { + mPort = null; + } + } + }; + + // This delegate will handle requests to open a port coming from the + // extension + WebExtension.MessageDelegate messageDelegate = new WebExtension.MessageDelegate() { + @Nullable + public void onConnect(final @NonNull WebExtension.Port port) { + // Let's store the Port object in a member variable so it can be + // used later to exchange messages with the WebExtension. + mPort = port; + + // Registering the delegate will allow us to receive messages sent + // through this port. + mPort.setDelegate(portDelegate); + } + }; + + runtime.getWebExtensionController() + .ensureBuiltIn("resource://android/assets/messaging/", "messaging@example.com") + .accept( + // Register message delegate for background script + extension -> extension.setMessageDelegate(messageDelegate, "browser"), + e -> Log.e("MessageDelegate", "Error registering WebExtension", e) + ); + + // ... other + } + +For example, let’s send a message to the extension every time the user +long presses on a key on the virtual keyboard, e.g. on the back button. + +.. code:: java + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + if (mPort == null) { + // No extension registered yet, let's ignore this message + return false; + } + + JSONObject message = new JSONObject(); + try { + message.put("keyCode", keyCode); + message.put("event", KeyEvent.keyCodeToString(event.getKeyCode())); + } catch (JSONException ex) { + throw new RuntimeException(ex); + } + + mPort.postMessage(message); + return true; + } + +This allows bidirectional communication between the app and the +extension. + +.. _GeckoRuntime: https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoRuntime.html +.. _runtime.sendNativeMessage: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/sendNativeMessage +.. _WebExtension: https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.html diff --git a/mobile/android/docs/geckoview/contributor/apilint.rst b/mobile/android/docs/geckoview/contributor/apilint.rst new file mode 100644 index 0000000000..9d9e315896 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/apilint.rst @@ -0,0 +1,85 @@ +apilint release process +~~~~~~~~~~~~~~~~~~~~~~~ + +To release a new version of `apilint <https://github.com/mozilla-mobile/gradle-apilint>`_, do the following: + +- Create a commit titled "Branch X.Y" and modify the files ``apilint/build.gradle`` and ``apilint/Config.java`` accordingly. See for example `Branch 0.5 <https://github.com/mozilla-mobile/gradle-apilint/commit/93a79ffddb8587ad018be67a361eb2a6ae777c63>`_. Note that it's not necessary to modify ``apilint/Config.java`` if there aren't any ``apidoc`` changes. + +- Create a git tag with the branch version + +.. code:: bash + + $ git tag X.Y + +- Run tests locally by running + +.. code:: bash + + $ ./gradlew build + + +- Publish new version to local repository + +.. code:: bash + + $ ./gradlew publishToMavenLocal + +- Modify ``mozilla-central`` locally to test ``apilint`` with the new version, add ``mavenLocal()`` to every ``repositories {}`` block inside the root ``build.gradle``, e.g. + + +.. code:: diff + + diff --git a/build.gradle b/build.gradle + index 813ba09aa3d4b..753fdb8d958a6 100644 + --- a/build.gradle + +++ b/build.gradle + @@ -60,6 +60,7 @@ allprojects { + } + + repositories { + + mavenLocal() + gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORIES.each { repository -> + maven { + url repository + @@ -100,6 +101,7 @@ buildDir "${topobjdir}/gradle/build" + + buildscript { + repositories { + + mavenLocal() + gradle.mozconfig.substs.GRADLE_MAVEN_REPOSITORIES.each { repository -> + maven { + url repository + @@ -113,7 +115,7 @@ buildscript { + ext.kotlin_version = '1.5.31' + + dependencies { + - classpath 'org.mozilla.apilint:apilint:0.5.2' + + classpath 'org.mozilla.apilint:apilint:0.X.Y' + classpath 'com.android.tools.build:gradle:7.0.3' + classpath 'com.getkeepsafe.dexcount:dexcount-gradle-plugin:0.8.2' + classpath 'org.apache.commons:commons-exec:1.3' + +* Test integration running ``api-lint``, this should always pass with no ``api.txt`` modifications needed (there could be exceptions, but should be intentional). + +.. code:: bash + + $ ./mach lint -l android-api-lint + +- Push the tag to the remote repository (note, the branch commit is `not` pushed to the main branch). + +.. code:: bash + + $ git push -u origin X.Y + +- Wait until github automation finishes successfully. +- (optional, if there are any ``apidoc`` changes) ask the Releng team to publish a new `apidoc` version, the bundle will be present under the github artifacts, e.g. see ``maven.zip`` in `releases/tag/0.5 <https://github.com/mozilla-mobile/gradle-apilint/releases/tag/0.5>`_. See also `Bug 1727585 <https://bugzilla.mozilla.org/show_bug.cgi?id=1727585>`_. + +- Add the ``plugins.gradle.org`` keys to your ``.gradle`` folder, see `publishing_gradle_plugins.html <https://docs.gradle.org/current/userguide/publishing_gradle_plugins.html>`_. + +- Publish plugin by running + +.. code:: bash + + $ ./gradlew apilint:publishPlugins + +- Finally, update ``mozilla-central`` to use the new version, e.g. see `this patch <https://hg.mozilla.org/mozilla-central/rev/0f746422db0e9fc6b70488bdb7114f08973191a0>`_. diff --git a/mobile/android/docs/geckoview/contributor/contributing-to-mc.rst b/mobile/android/docs/geckoview/contributor/contributing-to-mc.rst new file mode 100644 index 0000000000..ee4f5be877 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/contributing-to-mc.rst @@ -0,0 +1,188 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +================================= +Mozilla Central Contributor Guide +================================= + +Table of contents +================= + +.. contents:: :local: + +Submitting a patch to Firefox using Git. +======================================== + +This guide will take you through submitting and updating a patch to +``mozilla-central`` as a git user. You need to already be `set up to use +git to contribute to mozilla-central <mc-quick-start.html>`_. + +Performing a bug fix +-------------------- + +All of the open bugs for issues in Firefox can be found in +`Bugzilla <https://bugzilla.mozilla.org>`_. If you know the component +that you wish to contribute to you can use Bugzilla to search for issues +in that project. If you are unsure which component you are interested +in, you can search the `Good First +Bugs <https://bugzilla.mozilla.org/buglist.cgi?quicksearch=good-first-bug>`_ +list to find something you want to work on. + +- Once you have your bug, assign it to yourself in Bugzilla. +- Update your local copy of the firefox codebase to match the current + version on the servers to ensure you are working with the most up to + date code. + +.. code:: bash + + git remote update + +- Create a new feature branch tracking either Central or Inbound. + +.. code:: bash + + git checkout -b bugxxxxxxx [inbound|central]/default + +- Work on your bug, checking into git according to your preferred + workflow. *Try to ensure that each individual commit compiles and + passes all of the tests for your component. This will make it easier + to land if you use ``moz-phab`` to submit (details later in this + post).* + +It may be helpful to have Mozilla commit access, at least level 1. There +are three levels of commit access that give increasing levels of access +to the repositories. + +Level 1: Try/User access. You will need this level of access commit to +the try server. + +Level 2: General access. This will give you full commit +access to any mercurial or SVN repository not requiring level 3 access. + +Level 3: Core access. You will need this level to commit directly to any +of the core repositories (Firefox/Thunderbird/Fennec). + +If you wish to apply for commit access, please follow the guide found in +the `Mozilla Commit Access +Policy <https://www.mozilla.org/en-US/about/governance/policies/commit/access-policy/>`_. + +Submitting a patch that touches C/C++ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your patch makes changes to any C or C++ code and your editor does +not have ``clang-format`` support, you should run the clang-format +linter before submitting your patch to ensure that your code is properly +formatted. + +.. code:: bash + + mach clang-format -p path/to/file.cpp + +Note that ``./mach bootstrap`` will offer to set up a commit hook that +will automatically do this for you. + +Submitting to ``try`` with Level 1 commit access. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you only have Level 1 access, you will still need to submit your +patch through phabricator, but you can test it on the try server first. + +- Use ``./mach try fuzzy`` to select jobs to run and push to try. + +Submitting a patch via Phabricator. +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To commit anything to the repository, you will need to set up moz-phab +and Phabricator. If you are using ``git-cinnabar`` then you will need to +use git enabled versions of these tools. + +Set up Phabricator +^^^^^^^^^^^^^^^^^^ + +- In a browser, visit Mozilla’s Phabricator instance at + https://phabricator.services.mozilla.com/. + +- Click “Log In” at the top of the page + + .. figure:: ../assets/LogInPhab.png + :alt: Log in to Phabricator + + alt text + +- Click the “Log In or Register” button on the next page. This will + take you to Bugzilla to log in or register a new account. + + .. figure:: ../assets/LogInOrRegister.png + :alt: Log in or register a Phabiricator account + + alt text + +- Sign in with your Bugzilla credentials, or create a new account. + + .. figure:: ../assets/LogInBugzilla.png + :alt: Log in with Bugzilla + + alt text + +- You will be redirected back to Phabricator, where you will have to + create a new Phabricator account. + + .. raw:: html + + <Screenshot Needed> + +- Fill in/amend any fields on the form and click “Register Account”. + + .. raw:: html + + <Screenshot Needed> + +- You now have a Phabricator account and can submit and review patches. + +Installing ``moz-phab`` +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: bash + + pip install MozPhab [--user] + +Submitting a patch using ``moz-phab``. +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Ensure you are on the branch where you have commits that you want to + submit. + +.. code:: bash + + git checkout your-branch + +- Check the revision numbers for the commits you want to submit + +.. code:: bash + + git log + +- Run ``moz-phab``. Specifying a start commit will submit all commits + from that commit. Specifying an end commit will submit all commits up + to that commit. If no positional arguments are provided, the range is + determined to be starting with the first non-public, non-obsolete + changeset (for Mercurial) and ending with the currently checked-out + changeset. + +.. code:: bash + + moz-phab submit [start_rev] [end_rev] + +- You will receive a Phabricator link for each commit in the set. + +Updating a patch +~~~~~~~~~~~~~~~~ + +- Often you will need to make amendments to a patch after it has been + submitted to address review comments. To do this, add your commits to + the base branch of your fix as normal. + +For ``moz-phab`` run in the same way as the initial submission with the +same arguments, that is, specifying the full original range of commits. +Note that, while inserting and amending commits should work fine, +reordering commits is not yet supported, and deleting commits will leave +the associated revisions open, which should be abandoned manually diff --git a/mobile/android/docs/geckoview/contributor/for-gecko-engineers.rst b/mobile/android/docs/geckoview/contributor/for-gecko-engineers.rst new file mode 100644 index 0000000000..4517d950b4 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/for-gecko-engineers.rst @@ -0,0 +1,176 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +============================= +GeckoView For Gecko Engineers +============================= + +Table of contents +================= + +.. contents:: :local: + +Introduction +------------ + +Who this guide is for: As the title suggests, the target audience of +this guide is existing Gecko engineers who need to be able to build and +(locally) test GeckoView. If you aren’t already familiar with building +Firefox on a desktop platform, you’ll likely be better served by reading +`our general introduction <geckoview-quick-start.html>`_. This guide may +also be helpful if you find you’ve written a patch that requires +changing GeckoView’s public API, see `Landing a Patch <#landing-a-patch>`_. + +Who this guide is not for: As mentioned above, if you are not already +familiar with building Firefox for desktop, you’d likely be better +served by our general bootstrapping guide. If you are looking to +contribute to front-end development of one of Mozilla’s Android +browsers, you’re likely better off starting with their codebase and +returning here only if actual GeckoView changes are needed. See, for +example, `Fenix’s GitHub <https://github.com/mozilla-mobile/firefox-android/tree/main/fenix>`_. + +What to do if this guide contains bugs or leads you astray: The quickest +way to get a response is to ask generally on #gv on Mozilla Slack; +#mobile on Mozilla IRC may also work for the time being, albeit likely +with slower response times. If you believe the guide needs updating, it +would also be good to file a ticket to request that. + +Configuring the build system +---------------------------- + +First, a quick note: This guide was written on MacOS 10.14; it should +translate quite closely to other supported versions of MacOS and to +Linux. Building GeckoView on Windows is not officially supported at the +moment. To begin with, re-run ``./mach bootstrap``; it will present you +with options for the version of Firefox/GV that you want to build. +Currently, option ``3`` is +``GeckoView/Firefox for Android Artifact Mode`` and ``4`` is +``GeckoView/Firefox for Android``; if you’re here, you want one of +these. The brief and approximately correct breakdown of ``Artifact`` vs +regular builds for GeckoView is that ``Artifact`` builds will not allow +you to work on native code, only on JS or Java. Once you’ve selected +your build type, ``bootstrap`` should do its usual thing and grab +whatever dependencies are necessary. You may need to agree to some +licenses along the way. Once ``bootstrap`` has successfully completed, +it will spit out a recommended ``mozconfig``. + +Mozconfig and Building +---------------------- + +If you’ve followed from the previous section, ``./mach bootstrap`` +printed out a recommended ``mozconfig`` that looks something like this: + +:: + + # Build GeckoView/Firefox for Android: + ac_add_options --enable-project=mobile/android + + # Targeting the following architecture. + # For regular phones, no --target is needed. + # For x86 emulators (and x86 devices, which are uncommon): + # ac_add_options --target=i686 + # For newer phones. + # ac_add_options --target=aarch64 + # For x86_64 emulators (and x86_64 devices, which are even less common): + # ac_add_options --target=x86_64 + +As written, this defaults to building for a 32-bit ARM architecture, +which is probably not what you want. If you intend to work on an actual +device, you almost certainly want a 64-bit ARM build, as it is supported +by virtually all modern ARM phones/tablets and is the only ARM build we +ship on the Google Play Store. To go this route, uncomment the +``ac_add_options --target=aarch64`` line in the ``mozconfig``. On the +other hand, x86-64 emulated devices are widely used by the GeckoView +team and are used extensively on ``try``; if you intend to use an +emulator, uncomment the ``ac_add_options --target=x86_64`` line in the +``mozconfig``. Don’t worry about installing an emulator at the moment, +that will be covered shortly. It’s worth noting here that other +``mozconfig`` options will generally work as you’d expect. Additionally, +if you plan on debugging native code on Android, you should include the +``mozconfig`` changes mentioned `in our native debugging guide <native-debugging.html>`_. Now, using +that ``mozconfig`` with any modifications you’ve made, simply +``./mach build``. If all goes well, you will have successfully built +GeckoView. + +Installing, Running, and Using in Fenix/AC +------------------------------------------ + +An (x86-64) emulator is the most common and developer-friendly way of +contributing to GeckoView in most cases. If you’re going to go this +route, simply run ``./mach android-emulator`` — by default, this will +install and launch an x86-64 Android emulator running the same Android +7.0 image that is used on ``try``. If you need a different emulator +image you can run ``./mach android-emulator --help`` for information on +what Android images are available via ``mach``. You can also install an +emulator image via Android Studio. In cases where an emulator may not +suffice (eg graphics or performance testing), or if you’d simply prefer +not to use an emulator, you can opt to use an actual phone instead. To +do so, you’ll need to enable ``USB Debugging`` on your phone if you +haven’t already. On most modern Android devices, you can do this by +opening ``Settings``, going to ``About phone``, and tapping +``Build number`` seven times. You should get a notification informing +you that you’ve unlocked developer options. Now return to ``Settings``, +go to ``Developer options``, and enable USB debugging. + +GeckoView Example App +~~~~~~~~~~~~~~~~~~~~~ + +Now that you’ve connected a phone or setup an emulator, the simplest way +to test GeckoView is to launch the GeckoView Example app by running +``./mach run`` (or install it with ``./mach install`` and run it +yourself). This is a simplistic GV-based browser that lives in the tree; +in many cases, it is sufficient to test and debug Gecko changes, and is +by far the simplest way of doing so. It supports remote debugging by +default — simply open Remote Debugging on your desktop browser and the +connected device/emulator should show up when the example app is open. +You can also use the example app for native debugging, follow the +`native debugging guide <native-debugging.html>`_. + +GeckoView JUnit Tests +~~~~~~~~~~~~~~~~~~~~~ + +Once you’ve successfully built GV, you can run tests from the GeckoView +JUnit test suite with ``./mach geckoview-junit``. For further examples +(eg running individual tests, repeating tests, etc.), consult the `quick +start guide <geckoview-quick-start.html#running-tests-locally>`_. + +Fenix and other GV-based Apps +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you are working on something for which the GeckoView Example app is +not sufficient for some reason, you may need to `use your local build of +GeckoView in one of Mozilla’s GV-based apps like Fenix <geckoview-quick-start.html#include-geckoview-as-a-dependency>`_. + +Debugging +--------- + +Remote Debugging +~~~~~~~~~~~~~~~~ + +To recap a bit of the above, in the GeckoView Example app, remote +debugging is enabled by default, and your device should show up in your +desktop browser’s Remote Debugging window with no special effort. For +Fenix, you can enable remote debugging by opening the three-dot menu and +toggling ``Remote debugging via USB`` under ``Developer tools``; other +Mozilla GV-based browsers have similar options. + +Native Debugging +~~~~~~~~~~~~~~~~ + +To perform native debugging on any GV app will require you to install +Android Studio and follow instructions `here <native-debugging.html>`_. + +Landing a Patch +--------------- + +In most cases, there shouldn’t be anything out of the ordinary to deal +with when landing a patch that affects GeckoView; make sure you include +Android in your ``try`` runs and you should be good. However, if you +need to alter the GeckoView public API in any way — essentially anything +that’s exposed as ``public`` in GeckoView Java files — then you’ll find +that you need to run the API linter and update the change log. To do +this, first run ``./mach lint --linter android-api-lint`` — if you have +indeed changed the public API, this will give you a ``gradle`` command +to run that will give further instructions. GeckoView API changes +require two reviews from GeckoView team members; you can open it up to +the team in general by adding ``#geckoview-reviewers`` as a reviewer on +Phabricator. diff --git a/mobile/android/docs/geckoview/contributor/geckoview-architecture.rst b/mobile/android/docs/geckoview/contributor/geckoview-architecture.rst new file mode 100644 index 0000000000..7ef34e11d5 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/geckoview-architecture.rst @@ -0,0 +1,826 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +===================== +Architecture overview +===================== + +.. contents:: Table of Contents + :depth: 2 + :local: + +Introduction +============ + +*Gecko* is a Web engine developed by Mozilla and used to power Firefox on +various platforms. A Web engine is roughly comprised of a JavaScript engine, a +Rendering engine, HTML parser, a Network stack, various media encoders, a +Graphics engine, a Layout engine and more. + +Code that is part of a browser itself is usually referred to as "chrome" code +(from which the popular Chrome browser takes its name) as opposed to code part +of a Web site, which is usually referred to "content" code or content Web page. + +*GeckoView* is an Android library that can be used to embed Gecko into Android +apps. Android apps that embed Gecko this way are usually referred to by +"embedders" or simply "apps". + +GeckoView powers all currently active Mozilla browsers on Android, like Firefox +for Android and Firefox Focus. + +API +=== + +The following sections describe parts of the GeckoView API that are public and +exposed to embedders. + + |api-diagram| + +Overall tenets +-------------- + +GeckoView is an opinionated library that contains a minimal UI and makes no +assumption about the type of app that is being used by. Its main consumers +inside Mozilla are browsers, so a lot of features of GeckoView are geared +towards browsers, but there is no assumption that the embedder is actually a +browser (e.g. there is no concept of "tab" in GeckoView). + +The GeckoView API tries to retain as little data as possible, delegating most +data storage to apps. Notable exceptions to this rule are: permissions, +extensions and cookies. + +View, Runtime and Session +------------------------- + + |view-runtime-session| + +There are three main classes in the GeckoView API: + +- ``GeckoRuntime`` represents an instance of Gecko running in an app. Normally, + apps have only one instance of the runtime which lives for as long as the app + is alive. Any object in the API that is not specific to a *session* + (more to this later) is usually reachable from the runtime. +- ``GeckoSession`` represents a web site *instance*. You can think of it as a + *tab* in a browser or a Web view in an app. Any object related to the + specific session will be reachable from this object. Normally, embedders + would have many instances of ``GeckoSession`` representing each tab that is + currently open. Internally, a session is represented as a "window" with one + single tab in it. +- ``GeckoView`` is an Android ``View`` that embedders can use to paint a + ``GeckoSession`` in the app. Normally, only ``GeckoSession`` s associated to + a ``GeckoView`` are actually *alive*, i.e. can receive events, fire timers, + etc. + +Delegates +--------- + +Because GeckoView has no UI elements and doesn't store a lot of data, it needs +a way to *delegate* behavior when Web sites need functionality that requires +these features. + +To do that, GeckoView exposes Java interfaces to the embedders, called +Delegates. Delegates are normally associated to either the runtime, when they +don't refer to a specific session, or a session, when they are +session-specific. + +The most important delegates are: + +- ``Autocomplete.StorageDelegate`` Which is used by embedders to implement + autocomplete functionality for logins, addresses and credit cards. +- ``ContentDelegate`` Which receives events from the content Web page like + "open a new window", "on fullscreen request", "this tab crashed" etc. +- ``HistoryDelegate`` Which receives events about new or modified history + entries. GeckoView itself does not store history so the app is required to + listen to history events and store them permanently. +- ``NavigationDelegate`` Informs the embedder about navigation events and + requests. +- ``PermissionDelegate`` Used to prompt the user for permissions like + geolocation, notifications, etc. +- ``PromptDelegate`` Implements content-side prompts like alert(), confirm(), + basic HTTP auth, etc. +- ``MediaSession.Delegate`` Informs the embedder about media elements currently + active on the page and allows the embedder to pause, resume, receive playback + state etc. +- ``WebExtension.MessageDelegate`` Used by the embedder to exchange messages + with built-in extensions. See also `Interacting with Web Content <../consumer/web-extensions.html>`_. + + +.. _GeckoDisplay: + +GeckoDisplay +------------ + +GeckoView can paint to either a ``SurfaceView`` or a ``TextureView``. + +- ``SufaceView`` is what most apps will use and it's the default, it provides a + barebone wrapper around a GL surface where GeckoView can paint on. + SurfaceView is not part of normal Android compositing, which means that + Android is not able to paint (partially) on top of a SurfaceView or apply + transformations and animations to it. +- ``TextureView`` offers a surface which can be transformed and animated but + it's slower and requires more memory because it's `triple-buffered + <https://en.wikipedia.org/wiki/Multiple_buffering#Triple_buffering>`_ + (which is necessary to offer animations). + +Most apps will use the ``GeckoView`` class to paint the web page. The +``GeckoView`` class is an Android ``View`` which takes part in the Android view +hierarchy. + +Android recycles the ``GeckoView`` whenever the app is not visible, releasing +the associated ``SurfaceView`` or ``TextureView``. This triggers a few actions +on the Gecko side: + +- The GL Surface is released, and Gecko is notified in + `SyncPauseCompositor <https://searchfox.org/mozilla-central/rev/ead7da2d9c5400bc7034ff3f06a030531bd7e5b9/widget/android/nsWindow.cpp#1114>`_. +- The ``<browser>`` associated to the ``GeckoSession`` is `set to inactive <https://searchfox.org/mozilla-central/rev/ead7da2d9c5400bc7034ff3f06a030531bd7e5b9/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java#553>`_, + which essentially freezes the JavaScript engine. + +Apps that do not use ``GeckoView``, because e.g. they cannot use +``SurfaceView``, need to manage the active state manually and call +``GeckoSession.setActive`` whenever the session is not being painted on the +screen. + +Thread safety +------------- + +Apps will inevitably have to deal with the Android UI in a significant way. +Most of the Android UI toolkit operates on the UI thread, and requires +consumers to execute method calls on it. The Android UI thread runs an event +loop that can be used to schedule tasks on it from other threads. + +Gecko, on the other hand, has its own main thread where a lot of the front-end +interactions happen, and many methods inside Gecko expect to be called on the +main thread. + +To not overburden the App with unnecessary multi-threaded code, GeckoView will +always bridge the two "main threads" and redirect method calls as appropriate. +Most GeckoView delegate calls will thus happen on the Android UI thread and +most APIs are expected to be called on the UI thread as well. + +This can sometimes create unexpected performance considerations, as illustrated +in later sections. + +GeckoResult +----------- + +An ubiquitous tool in the GeckoView API is ``GeckoResult``. GeckoResult is a +promise-like class that can be used by apps and by Gecko to return values +asynchronously in a thread-safe way. Internally, ``GeckoResult`` will keep +track of what thread it was created on, and will execute callbacks on the same +thread using the thread's ``Handler``. + +When used in Gecko, ``GeckoResult`` can be converted to ``MozPromise`` using +``MozPromise::FromGeckoResult``. + +Page load +--------- + + |pageload-diagram| + +GeckoView offers several entry points that can be used to react to the various +stages of a page load. The interactions can be tricky and surprising so we will +go over them in details in this section. + +For each page load, the following delegate calls will be issued: +``onLoadRequest``, ``onPageStart``, ``onLocationChange``, +``onProgressChange``, ``onSecurityChange``, ``onSessionStateChange``, +``onCanGoBack``, ``onCanGoForward``, ``onLoadError``, ``onPageStop``. + +Most of the method calls are self-explanatory and offer the App a chance to +update the UI in response to a change in the page load state. The more +interesting delegate calls will be described below. + +onPageStart and onPageStop +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +``onPageStart`` and ``onPageStop`` are guaranteed to appear in pairs and in +order, and denote the beginning and the end of a page load. In between a start +and stop event, multiple ``onLoadRequest`` and ``onLocationChange`` call can be +executed, denoting redirects. + +onLoadRequest +~~~~~~~~~~~~~ + +``onLoadRequest``, which is perhaps the most important, can be used by the App +to intercept page loads. The App can either *deny* the load, which will stop +the page from loading, and handle it internally, or *allow* the +load, which will load the page in Gecko. ``onLoadRequest`` is called for all +page loads, regardless of whether they were initiated by the app itself, by Web +content, or as a result of a redirect. + +When the page load originates in Web content, Gecko has to synchronously +wait for the Android UI thread to schedule the call to ``onLoadRequest`` and +for the App to respond. This normally takes a negligible amount of time, but +when the Android UI thread is busy, e.g. because the App is being painted for +the first time, the delay can be substantial. This is an area of GeckoView that +we are actively trying to improve. + +onLoadError +~~~~~~~~~~~ + +``onLoadError`` is called whenever the page does not load correctly, e.g. +because of a network error or a misconfigured HTTPS server. The App can return +a URL to a local HTML file that will be used as error page internally by Gecko. + +onLocationChange +~~~~~~~~~~~~~~~~ + +``onLocationChange`` is called whenever Gecko commits to a navigation and the +URL can safely displayed in the URL bar. + +onSessionStateChange +~~~~~~~~~~~~~~~~~~~~ + +``onSessionStateChange`` is called whenever any piece of the session state +changes, e.g. form content, scrolling position, zoom value, etc. Changes are +batched to avoid calling this API too frequently. + +Apps can use ``onSessionStateChange`` to store the serialized state to +disk to support restoring the session at a later time. + +Third-party root certificates +----------------------------- + +Gecko maintains its own Certificate Authority store and does not use the +platform's CA store. GeckoView follows the same policy and will not, by +default, read Android's CA store to determine root certificates. + +However, GeckoView provides a way to import all third-party CA roots added to +the Android CA store by setting the `enterpriseRootsEnabled +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoRuntimeSettings.Builder.html#enterpriseRootsEnabled(boolean)>`_ +runtime setting to ``true``, this feature is implemented in `EnterpriseRoots +<https://searchfox.org/mozilla-central/rev/26a6a38fb515dbab0bb459c40ec4b877477eefef/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java>`_ + +There is not currently any API for an app to manually specify additional CA +roots, although this might change with `Bug 1522162 +<https://bugzilla.mozilla.org/show_bug.cgi?id=1522162>`_. + +Lite and Omni builds +--------------------- + +A variation of the default GeckoView build, dubbed `Omni` in the codebase, +provides additional libraries that can be helpful when building a browser app. +Currently, the `Glean +<https://docs.telemetry.mozilla.org/concepts/glean/glean.html>`_ library is +included in the ``geckoview-omni`` package. The default build ``geckoview``, +which does not contain such libraries, is similarly dubbed `Lite` in the +codebase. + +The additional libraries in the Omni package are directly built into Gecko's +main ``.so`` file, ``libxul.so``. These libraries are then declared in the +``.module`` package inside the ``maven`` repository, e.g. see the ``.module`` +file for `geckoview-omni +<https://maven.mozilla.org/maven2/org/mozilla/geckoview/geckoview-omni/102.0.20220623063721/geckoview-omni-102.0.20220623063721.module>`_: + +.. code-block:: json + + "capabilities": [ + { + "group": "org.mozilla.geckoview", + "name": "geckoview-omni", + "version": "102.0.20220623063721" + }, + { + "group": "org.mozilla.telemetry", + "name": "glean-native", + "version": "44.1.1" + } + ] + +Notice the ``org.mozilla.telemetry:glean-native`` capability is declared +alongside ``org.mozilla.geckoview``. + +The main Glean library then depends on ``glean-native`` which is either +provided in a standalone package (for apps that do not include GeckoView) or by +the GeckoView capability above. + +In Treeherder, the Lite build is denoted with ``Lite``, while the Omni builds +don't have extra denominations as they are the default build, so e.g. for +``x86_64`` the platorm names would be: + +- ``Android 7.0 x86-64`` for the Omni build +- ``Android 7.0 x86-64 Lite`` for the Lite build + +Extensions +---------- + +Extensions can be installed using ``WebExtensionController::install`` and +``WebExtensionController::installBuiltIn``, which asynchronously returns a +``WebExtension`` object that can be used to set delegates for +extension-specific behavior. + +The ``WebExtension`` object is immutable, and will be replaced every time a +property changes. For instance, to disable an extension, apps can use the +``disable`` method, which will return an updated version of the +``WebExtension`` object. + +Internally, all ``WebExtension`` objects representing one extension share the +same delegates, which are stored in ``WebExtensionController``. + +Given the extensive sprawling amount of data associated to extensions, +extension installation persists across restarts. Existing extensions can be +listed using ``WebExtensionController::list``. + +In addition to ordinary WebExtension APIs, GeckoView allows ``builtIn`` +extensions to communicate to the app via native messaging. Apps can register +themselves as native apps and extensions will be able to communicate to the app +using ``connectNative`` and ``sendNativeMessage``. Further information can be +found `here <../consumer/web-extensions.html>`__. + +Internals +========= + +The following sections describe how Gecko and GeckoView are implemented. These +parts of GeckoView are not normally exposed to embedders. + +Process Model +------------- + +Internally, Gecko uses a multi-process architecture, most of the chrome code +runs in the *main* process, while content code runs in *child* processes also +called *content* processes. There are additional types of specialized processes +like the *socket* process, which runs parts of the networking code, the *gpu* +process which executes GPU commands, the *extension* process which runs most +extension content code, etc. + +We intentionally do not expose our process model to embedders. + +To learn more about the multi-process architecture see `Fission for GeckoView +engineers <https://gist.github.com/agi/c900f3e473ff681158c0c907e34780e4>`_. + +The majority of the GeckoView Java code runs on the main process, with a thin +glue layer on the child processes, mostly contained in ``GeckoThread``. + +Process priority on Android +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +On Android, each process is assigned a given priority. When the device is +running low on memory, or when the system wants to conserve resources, e.g. +when the screen has been off for a long period of time, or the battery is low, +Android will sort all processes in reverse priority order and kill, using a +``SIGKILL`` event, enough processes until the given free memory and resource +threshold is reached. + +Processes that are necessary to the function of the device get the highest +priority, followed by apps that are currently visible and focused on the +screen, then apps that are visible (but not on focus), background processes and +so on. + +Processes that do not have a UI associated to it, e.g. background services, +will normally have the lowest priority, and thus will be killed most +frequently. + +To increase the priority of a service, an app can ``bind`` to it. There are +three possible ``bind`` priority values + +- ``BIND_IMPORTANT``: The process will be *as important* as the process binding + to it +- default priority: The process will have lower priority than the process + binding to it, but still higher priority than a background service +- ``BIND_WAIVE_PRIORITY``: The bind will be ignored for priority + considerations. + +It's important to note that the priority of each service is only relative to +the priority of the app binding to it. If the app is not visible, the app +itself and all services attached to it, regardless of binding, will get +background priority (i.e. the lowest possible priority). + +Process management +~~~~~~~~~~~~~~~~~~ + +Each Gecko process corresponds to an Android ``service`` instance, which has to +be declared in GeckoView's ``AndroidManifest.xml``. + +For example, this is the definition of the ``media`` process: + +.. code-block:: xml + + <service + android:name="org.mozilla.gecko.media.MediaManager" + android:enabled="true" + android:exported="false" + android:isolatedProcess="false" + android:process=":media"> + +Process creation is controlled by Gecko which interfaces to Android using +``GeckoProcessManager``, which translates Gecko's priority to Android's +``bind`` values. + +Because all priorities are waived when the app is in the background, it's not +infrequent that Android kills some of GeckoView's services, while still leaving +the main process alive. + +It is therefore very important that Gecko is able to recover from process +disappearing at any moment at runtime. + +Priority Hint +~~~~~~~~~~~~~ + +Internally, GeckoView ties the lifetime of the ``Surface`` associated to a +``GeckoSession`` and the process priority of the process where the session +lives. + +The underlying assumption is that a session that is not visible doesn't have a +surface associated to it and it's not being used by the user so it shouldn't +receive high priority status. + +The way this is implemented is `by setting +<https://searchfox.org/mozilla-central/rev/5b2d2863bd315f232a3f769f76e0eb16cdca7cb0/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java#114,123>`_ +the ``active`` property on the ``browser`` object to ``false``, which causes +Gecko to de-prioritize the process, assuming that no other windows in the same +process have ``active=true``. See also `GeckoDisplay`_. + +However, there are use cases where just looking at the surface is not enough. +For instance, when the user opens the settings menu, the currently selected tab +becomes invisible, but the user will still expect the browser to retain that +tab state with a higher priority than all the other tabs. Similarly, when the +browser is put in the background, the surface associated to the current tab +gets destroyed, but the current tab is still more important than the other +tabs, but because it doesn't have a surface associated to it, we have no way to +differentiate it from all the other tabs. + +To solve the above problem, we expose an API for consumers to *boost* a session +priority, `setPriorityHint +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.html#setPriorityHint(int)>`_. +The priority hint is taken into consideration when calculating the +priority of a process. Any process that contains either an active session or a +session with the priority hint `is boosted +<https://searchfox.org/mozilla-central/rev/5b2d2863bd315f232a3f769f76e0eb16cdca7cb0/dom/ipc/BrowserParent.cpp#3593>`_ +to the highest priority. + +Shutdown +-------- + +Android does not provide apps with a notification whenever the app is shutting +down. As explained in the section above, apps will simply be killed whenever +the system needs to reclaim resources. This means that Gecko on Android will +never shutdown cleanly, and that shutdown actions will never execute. + +.. _principals: + +Principals +---------- + +In Gecko, a *website* loaded in a session is represented by an abstraction +called `principal +<https://searchfox.org/mozilla-central/rev/5b2d2863bd315f232a3f769f76e0eb16cdca7cb0/caps/nsIPrincipal.idl>`_. +Principals contain information that is used to determine what permissions have +been granted to the website instance, what APIs are available to it, which +container the page is loaded in, is the page in private browsing or not, etc. + +Principals are used throughout the Gecko codebase, GeckoView, however, does not +expose the concept to the API. This is intentional, as exposing it would +potentially expose the app to various security sensitive concepts, which would +violate the "secure" requirement for the GeckoView API. + +The absence of principals from the API is, e.g., why GeckoView does not offer a +way to set permissions given a URL string, as permissions are internally stored +by principal. See also `Setting Permissions`_. + +To learn more about principals see `this talk by Bobby Holley +<https://www.youtube.com/watch?v=28FPetl5Fl4>`_. + +Window model +------------ + +Internally, Gecko has the concept of *window* and *tab*. Given that GeckoView +doesn't have the concept of tab (since it might be used to build something that +is *not* a browser) we hide Gecko tabs from the GeckoView API. + +Each ``GeckoSession`` corresponds to a Gecko ``window`` object with exactly one +``tab`` in it. Because of this you might see ``window`` and ``session`` used +interchangeably in the code. + +Internally, Gecko uses ``window`` s for other things other than +``GeckoSession``, so we have to sometime be careful about knowing which windows +belong to GeckoView and which don't. For example, the background extension page +is implemented as a ``window`` object that doesn't paint to a surface. + +EventDispatcher +--------------- + +The GeckoView codebase is written in C++, JavaScript and Java, it runs across +processes and often deals with asynchronous and garbage-collected code with +complex lifetime dependencies. To make all of this work together, GeckoView +uses a cross-language event-driven architecture. + +The main orchestrator of this event-driven architecture is ``EventDispatcher``. +Each language has an implementation of ``EventDispatcher`` that can be used to +fire events that are reachable from any language. + +Each window (i.e. each session) has its own ``EventDispatcher`` instance, which +is also present on the content process. There is also a global +``EventDispatcher`` that is used to send and receive events that are not +related to a specific session. + +Events can have data associated to it, which is represented as a +``GeckoBundle`` (essentially a ``String``-keyed variant map) on the Java and +C++ side, and a plain object on the JavaScript side. Data is automatically +converted back and forth by ``EventDispatcher``. + +In Java, events are fired in the same thread where the listener was registered, +which allows us to ensure that events are received in a consistent order and +data is kept consistent, so that we by and large don't have to worry about +multi-threaded issues. + +JNI +--- + +GeckoView code uses the Java Native Interface or JNI to communicate between +Java and C++ directly. Our JNI exports are generated from the Java source code +whenever the ``@WrapForJNI`` annotation is present. For non-GeckoView code, the +list of classes for which we generate imports is defined at +``widget/android/bindings``. + +The lifetime of JNI objects depends on their native implementation: + +- If the class implements ``mozilla::SupportsWeakPtr``, the Java object will + store a ``WeakPtr`` to the native object and will not own the lifetime of the + object. +- If the class implements ``AddRef`` and ``Release`` from ``nsISupports``, the + Java object will store a ``RefPtr`` to the native object and will hold a + strong reference until the Java object releases the object using + ``DisposeNative``. +- If neither cases apply, the Java object will store a C++ pointer to the + native object. + +Calling Runtime delegates from native code +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Runtime delegates can be reached directly using the ``GeckoRuntime`` singleton. +A common pattern is to expose a ``@WrapForJNI`` method on ``GeckoRuntime`` that +will call the delegate, that than can be used on the native side. E.g. + +.. code:: java + + @WrapForJNI + private void featureCall() { + ThreadUtils.runOnUiThread(() -> { + if (mFeatureDelegate != null) { + mFeatureDelegate.feature(); + } + }); + } + +And then, on the native side: + +.. code:: cpp + + java::GeckoRuntime::LocalRef runtime = java::GeckoRuntime::GetInstance(); + if (runtime != nullptr) { + runtime->FeatureCall(); + } + +Session delegates +~~~~~~~~~~~~~~~~~ + +``GeckoSession`` delegates require a little more care, as there's a copy of a +delegate for each ``window``. Normally, a method on ``android::nsWindow`` is +added which allows Gecko code to call it. A reference to ``nsWindow`` can be +obtained from a ``nsIWidget`` using ``nsWindow::From``: + +.. code:: cpp + + RefPtr<nsWindow> window = nsWindow::From(widget); + window->SessionDelegateFeature(); + +The ``nsWindow`` implementation can then forward the call to +``GeckoViewSupport``, which is the JNI native side of ``GeckoSession.Window``. + +.. code:: cpp + + void nsWindow::SessionDelegateFeature() { + auto acc(mGeckoViewSupport.Access()); + if (!acc) { + return; + } + acc->SessionDelegateFeature(aResponse); + } + +Which can in turn forward the call to the Java side using the JNI stubs. + +.. code:: cpp + + auto GeckoViewSupport::SessionDelegateFeature() { + GeckoSession::Window::LocalRef window(mGeckoViewWindow); + if (!window) { + return; + } + window->SessionDelegateFeature(); + } + +And finally, the Java implementation calls the session delegate. + +.. code:: java + + @WrapForJNI + private void sessionDelegateFeature() { + final GeckoSession session = mOwner.get(); + if (session == null) { + return; + } + ThreadUtils.postToUiThread(() -> { + final FeatureDelegate delegate = session.getFeatureDelegate(); + if (delegate == null) { + return; + } + delegate.feature(); + }); + } + +.. _permissions: + +Permissions +----------- + +There are two separate but related permission concepts in GeckoView: `Content` +permissions and `Android` permissions. See also the related `consumer doc +<../consumer/permissions.html>`_ on permissions. + +Content permissions +~~~~~~~~~~~~~~~~~~~ + +Content permissions are granted to individual web sites (more precisely, +`principals`_) and are managed internally using ``nsIPermissionManager``. +Content permissions are used by Gecko to keep track which website is allowed to +access a group of Web APIs or functionality. The Web has the concept of +permissions, but not all Gecko permissions map to Web-exposed permissions. + +For instance, the ``Notification`` permission, which allows websites to fire +notifications to the user, is exposed to the Web through +`Notification.requestPermission +<https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission>`_, +while the `autoplay` permission, which allows websites to play video and audio +without user interaction, is not exposed to the Web and websites have no way to +set or request this permission. + +GeckoView retains content permission data, which is an explicit violation of +the design principle of not storing data. This is done because storing +permissions is very complex, making a mistake when dealing with permissions +often ends up being a security vulnerability, and because permissions depend on +concepts that are not exposed to the GeckoView API like `principals`_. + +Android permissions +~~~~~~~~~~~~~~~~~~~ + +Consumers of GeckoView are Android apps and therefore they have to receive +permission to use certain features on behalf of websites. + +For instance, when a website requests Geolocation permission for the first +time, the app needs to request the corresponding Geolocation Android permission +in order to receive position data. + +You can read more about Android permissions on `this doc +<https://developer.android.com/guide/topics/permissions/overview>`_. + + +Implementation +~~~~~~~~~~~~~~ + +The main entry point from Gecko is ``nsIContentPermissionPrompt.prompt``, which +is handled in the `Permission module +<https://searchfox.org/mozilla-central/rev/256f84391cf5d4e3a4d66afbbcd744a5bec48956/mobile/android/components/geckoview/GeckoViewPermission.jsm#21>`_ +in the same process where the request is originated. + +The permission module calls the child actor `GeckoViewPermission +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/actors/GeckoViewPermissionChild.jsm#47>`_ +which issues a `GeckoView:ContentPermission +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/actors/GeckoViewPermissionChild.jsm#75>`_ +request to the Java front-end as needed. + +Media permissions are requested using a global observer, and therefore are +handled in a `Process actor +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/actors/GeckoViewPermissionProcessChild.jsm#41>`_, +media permissions requests have enough information to redirect the request to +the corresponding window child actor, with the exception of requests that are +not associated with a window, which are redirected to the `current active +window +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/actors/GeckoViewPermissionProcessParent.jsm#28-35>`_. + +Setting permissions +~~~~~~~~~~~~~~~~~~~ + +Permissions are stored in a map between a `principal <#principals>`_ and a list +of permission (key, value) pairs. To prevent security vulnerabilities, GeckoView +does not provide a way to set permissions given an arbitrary URL and requires +consumers to get hold of the `ContentPermission +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PermissionDelegate.ContentPermission.html>`_ +object. The ContentPermission object is returned in `onLocationChange +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List)>`_ +upon navigation, making it unlikely to have confusion bugs whereby the +permission is given to the wrong website. + +Internally, some permissions are only present when a certain override is set, +e.g. Tracking Protection override permissions are only present when the page +has been given a TP override. Because the only way to set the value of a +permission is to get hold of the ``ContentPermission`` object, `we manually insert +<https://searchfox.org/mozilla-central/rev/5b2d2863bd315f232a3f769f76e0eb16cdca7cb0/mobile/android/modules/geckoview/GeckoViewNavigation.jsm#605-625>`_ +a `trackingprotection` permission on every page load. + +Autofill Support +---------------- + +GeckoView supports third-party autofill providers through Android's `autofill framework <https://developer.android.com/guide/topics/text/autofill>`_. Internally, this support is referred to as `autofill`. + +Document tree +~~~~~~~~~~~~~ + +The autofill Java front-end is located in the `Autofill class +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java#37>`_. +GeckoView maintains a virtual tree structure of the current document for each +``GeckoSession``. + +The virtual tree structure is composed of `Node +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java#593>`_ +objects which are immutable. Data associated to a node, including mutable data +like the current value, is stored in a separate `NodeData +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java#171>`_ +class. Only HTML nodes that are relevant to autofilling are referenced in the +virtual structure and each node is associated to a root node, e.g. the root +``<form>`` element. All root nodes are children of the autofill `mRoot +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java#210>`_ +node, hence making the overall structure a tree rather than a collection of +trees. Note that the root node is the only node in the virtual structure that +does not correspond to an actual element on the page. + +Internally, nodes are assigned a unique ``UUID`` string, which is used to match +nodes between the Java front-end and the data stored in GeckoView's chrome +Javascript. The autofill framework itself requires integer IDs for nodes, so we +store a mapping between UUIDs and integer IDs in the associated ``NodeData`` +object. The integer IDs are used only externally, while internally only the +UUIDs are used. The reason why we use a separate ID structure from the autofill +framework is that this allows us to `generate UUIDs +<https://searchfox.org/mozilla-central/rev/7e34cb7a0094a2f325a0c9db720cec0a2f2aca4f/mobile/android/actors/GeckoViewAutoFillChild.jsm#217-220>`_ +directly in the isolated content processes avoiding an IPC roundtrip to the +main process. + +Each ``Node`` object is associated to an ``EventCallback`` object which is +invoked whenever the node is autofilled by the autofill framework. + +Detecting autofillable nodes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +GeckoView scans every web page for password ``<input>`` elements whenever the +``pageshow`` event `fires +<https://searchfox.org/mozilla-central/rev/9dc5ffe42635b602d4ddfc9a4b8ea0befc94975a/mobile/android/actors/GeckoViewAutoFillChild.jsm#74-78>`_. + +It also uses ``DOMFormHasPassword`` and ``DOMInputPasswordAdded`` to detect +whenever a password element is added to the DOM after the ``pageshow`` event. + +Prefs +----- + +`Preferences </modules/libpref/index.html>`_ (or prefs) are used throughout +Gecko to configure the browser, enable custom features, etc. + +GeckoView does not directly expose prefs to Apps. A limited set configuration +options is exposed through ``GeckoRuntimeSettings``. + +``GeckoRuntimeSettings`` can be easily mapped to a Gecko ``pref`` using +``Pref``, e.g. + +.. code:: java + + /* package */ final Pref<Boolean> mPrefExample = + new Pref<Boolean>("example.pref", false); + +The value of the pref can then be read internally using ``mPrefExample.get`` +and written to using ``mPrefExample.commit``. + +Front-end and back-end +---------------------- + + |code-layers| + +Gecko and GeckoView code can be divided in five layers: + +- **Java API** the outermost code layer that is publicly accessible to + GeckoView embedders. +- **Java Front-End** All the Java code that supports the API and talks directly + to the Android APIs and to the JavaScript and C++ front-ends. +- **JavaScript Front-End** The main interface to the Gecko back-end (or Gecko + proper) in GeckoView is JavaScript, we use this layer to call into Gecko and + other utilities provided by Gecko, code lives in ``mobile/android`` +- **C++ Front-End** A smaller part of GeckoView is written in C++ and interacts + with Gecko directly, most of this code is lives in ``widget/android``. +- **C++/Rust Back-End** This is often referred to as "platform", includes all + core parts of Gecko and is usually accessed to in GeckoView from the C++ + front-end or the JavaScript front-end. + +Modules and Actors +------------------ + +GeckoView's JavaScript Front-End is largely divided into units called modules +and actors. For each feature, each window will have an instance of a Module, a +parent-side Actor and (potentially many) content-side Actor instances. For a +detailed description of this see `here <https://gist.github.com/agi/c900f3e473ff681158c0c907e34780e4#actors>`__. + +Testing infrastructure +---------------------- + +For a detailed description of our testing infrastructure see `GeckoView junit +Test Framework <junit.html>`_. + +.. |api-diagram| image:: ../assets/api-diagram.png +.. |view-runtime-session| image:: ../assets/view-runtime-session.png +.. |pageload-diagram| image:: ../assets/pageload-diagram.png +.. |code-layers| image:: ../assets/code-layers.png diff --git a/mobile/android/docs/geckoview/contributor/geckoview-quick-start.rst b/mobile/android/docs/geckoview/contributor/geckoview-quick-start.rst new file mode 100644 index 0000000000..140dd62784 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/geckoview-quick-start.rst @@ -0,0 +1,341 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +.. _geckoview-contributor-guide: + +================= +Contributor Guide +================= + +Table of contents +================= + +.. contents:: :local: + +GeckoView Contributor Quick Start Guide +======================================= + +This is a guide for developers who want to contribute to the GeckoView +project. If you want to get started using GeckoView in your app then you +should refer to the +`wiki <https://wiki.mozilla.org/Mobile/GeckoView#Get_Started>`_. + +Get set up with Mozilla Central +------------------------------- + +The GeckoView codebase is part of the main Firefox tree and can be found +in ``mozilla-central``. You will need to get set up as a contributor to +Firefox in order to contribute to GeckoView. To get set up with +``mozilla-central``, follow the `Quick Start Guide for Git +Users <mc-quick-start.html>`_, or the `Contributing to the Mozilla code +base <https://firefox-source-docs.mozilla.org/setup/contributing_code.html>`_ +guide and `Firefox Contributors’ Quick Reference +<https://firefox-source-docs.mozilla.org/contributing/contribution_quickref.html>`_ +for Mercurial users. + +Once you have a copy of ``mozilla-central``, you will need to build +GeckoView. + +Bootstrap Gecko +--------------- + +Bootstrap configures everything for GeckoView and Fennec (Firefox for Android) development. + +- Ensure you have ``mozilla-central`` checked out. If this is the first + time you are doing this, it may take some time. + +.. code:: bash + + git checkout central/default + +If you are on a mac, you will need to have the Xcode build tools +installed. You can do this by either `installing +Xcode <https://developer.apple.com/xcode/>`__ or installing only the +tools from the command line by running ``xcode-select --install`` and +following the on screen instructions. + +You will need to ``bootstrap`` for GeckoView/Firefox for Android. The easiest way is to run the following command: + +.. code:: bash + + ./mach --no-interactive bootstrap --application-choice="GeckoView/Firefox for Android" + +.. note:: + + - The ``--no-interactive`` argument will make ``bootstrap`` run start to finish without requiring any input from you. It will automatically accept any license agreements. + - The ``--application-choice="GeckoView/Firefox for Android"`` argument is needed when using ``--no-interactive`` so that "bootstrapping" is done for the correct application (instead of the default). + + If you want to make all the selections yourself and/or read through the license agreements, you can simply run: + + .. code:: bash + + ./mach bootstrap + + Select ``4. GeckoView/Firefox for Android`` when prompted and respond to any subsequent prompts as they appear. + +Once ``./mach bootstrap`` is complete, it will automatically write +the configuration into a new ``mozconfig`` file. If you already +have a ``mozconfig``, ``mach`` will instead output new configuration +that you should append to your existing file. + +Build from the command line +--------------------------- + +In order to pick up the configuration changes we just made we need to +build from the command line. This will update generated sources, compile +native code, and produce GeckoView AARs and example and test APKs. + +.. code:: bash + + ./mach build + +Build Using Android Studio +-------------------------- + +- Install `Android + Studio <https://developer.android.com/studio/install>`_. +- Choose File->Open from the toolbar +- Navigate to the root of your ``mozilla-central`` source directory and + click “Open” +- Click yes if it asks if you want to use the gradle wrapper. + + - If the gradle sync does not automatically start, select File > + Sync Project with Gradle Files. + +- Wait for the project to index and gradle to sync. Once synced, the + workspace will reconfigure to display the different projects. + + - annotations contains custom Java annotations used inside GeckoView + - app contains geckoview build settings and omnijar. omnijar contains + the parts of Gecko and GeckoView that are not written in Java or Kotlin + - geckoview is the GeckoView project. Here is all the Java files + related to GeckoView + - geckoview_example is an example browser built using GeckoView. + + |alt text 1| + +Now you’re set up and ready to go. + +**Important: at this time, building from Android Studio or directly from +Gradle does not (re-)compile native code, including C++ and Rust.** This +means you will need to run ``mach build`` yourself to pick up changes to +native code. `Bug +1509539 <https://bugzilla.mozilla.org/show_bug.cgi?id=1509539>`_ tracks +making Android Studio and Gradle do this automatically. + +If you want set up code formatting for Kotlin, please reference +`IntelliJ IDEA configuration +<https://pinterest.github.io/ktlint/rules/configuration-intellij-idea/>`_. + +Custom mozconfig with Android Studio +------------------------------------ + +Out of the box, Android Studio will use the default mozconfig file, normally +located at ``mozconfig`` in the root directory of your ``mozilla-central`` +checkout. + +To make Android Studio use a mozconfig in a custom location, you can add the +following to your ``local.properties``: + +:: + + mozilla-central.mozconfig=relative/path/to/mozconfig + +Note that, when running mach from the command line, this value will be ignored, +and the mozconfig from the mach environment will be used instead. + +To override the mozconfig used by mach, you can use the `MOZCONFIG` environment +variable, for example: + +:: + + MOZCONFIG=debug.mozconfig ./mach build + +Performing a bug fix +-------------------- + +One you have got GeckoView building and running, you will want to start +contributing. There is a general guide to `Performing a Bug Fix for Git +Developers <contributing-to-mc.html>`_ for you to follow. To contribute to +GeckoView specifically, you will need the following additional +information. + +Running tests and linter locally +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To ensure that your patch does not break existing functionality in +GeckoView, you can run the junit test suite with the following command + +:: + + ./mach geckoview-junit + +This command also allows you to run individual tests or test classes, +e.g. + +:: + + ./mach geckoview-junit org.mozilla.geckoview.test.NavigationDelegateTest + ./mach geckoview-junit org.mozilla.geckoview.test.NavigationDelegateTest#loadUnknownHost + +If your patch makes a GeckoView JavaScript module, you should run ESLint +as well: + +:: + + ./mach lint -l eslint mobile/android/modules/geckoview/ + +To see information on other options, simply run +``./mach geckoview-junit --help``; of particular note for dealing with +intermittent test failures are ``--repeat N`` and +``--run-until-failure``, both of which do exactly what you’d expect. + +Updating the changelog and API documentation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If the patch that you want to submit changes the public API for +GeckoView, you must ensure that the API documentation is kept up to +date. To check whether your patch has altered the API, run the following +command. + +.. code:: bash + + ./mach lint --linter android-api-lint + +The output of this command will inform you if any changes you have made +break the existing API. Review the changes and follow the instructions +it provides. + +If the linter asks you to update the changelog, please ensure that you +follow the correct format for changelog entries. Under the heading for +the next release version, add a new entry for the changes that you are +making to the API, along with links to any relevant files, and bug +number e.g. + +:: + + - Added [`GeckoRuntimeSettings.Builder#aboutConfigEnabled`][71.12] to control whether or + not `about:config` should be available. + ([bug 1540065]({{bugzilla}}1540065)) + + [71.12]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#aboutConfigEnabled(boolean) + +Submitting to the ``try`` server +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +It is advisable to run your tests before submitting your patch. You can +do this using Mozilla’s ``try`` server. To submit a GeckoView patch to +``try`` before submitting it for review, type: + +.. code:: bash + + ./mach try auto + +This will automatically select tests to run from our suite. If your patch +passes on ``try`` you can be (fairly) confident that it will land successfully +after review. + +Tagging a reviewer +~~~~~~~~~~~~~~~~~~ + +When submitting a patch to Phabricator, if you know who you want to +review your patch, put their Phabricator handle against the +``reviewers`` field. + +If you don’t know who to tag for a review in the Phabricator submission +message, leave the field blank and, after submission, follow the link to +the patch in Phabricator and scroll to the bottom of the screen until +you see the comment box. + +- Select the ``Add Action`` drop down and pick the ``Change Reviewers`` option. +- In the presented box, add ``geckoview-reviewers``. Selecting this group as the reviewer will notify all the members of the GeckoView team there is a patch to review. +- Click ``Submit`` to submit the reviewer change request. + +Include GeckoView as a dependency +--------------------------------- + +If you want to include a development version of GeckoView as a +dependency inside another app, you must link to a local copy. There are +several ways to achieve this, but the preferred way is to use Gradle’s +*dependency substitution* mechanism, for which there is first-class +support in ``mozilla-central`` and a pattern throughout Mozilla’s +GeckoView-consuming ecosystem. + +The good news is that ``mach build`` produces everything you need, so +that after the configuration below, you should find that the following +commands rebuild your local GeckoView and then consume your local +version in the downstream project. + +.. code:: sh + + cd /path/to/mozilla-central && ./mach build + cd /path/to/project && ./gradlew assembleDebug + +**Be sure that your ``mozconfig`` specifies the correct ``--target`` +argument for your target device.** Many projects use “ABI splitting” to +include only the target device’s native code libraries in APKs deployed +to the device. On x86-64 and aarch64 devices, this can result in +GeckoView failing to find any libraries, because valid x86 and ARM +libraries were not included in a deployed APK. Avoid this by setting +``--target`` to the exact ABI that your device supports. + +Dependency substiting your local GeckoView into a Mozilla project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Most GeckoView-consuming projects produced by Mozilla support dependency +substitution via ``local.properties``. These projects include: + +- `Fenix <https://github.com/mozilla-mobile/firefox-android/tree/main/fenix>`_ +- `reference-browser <https://github.com/mozilla-mobile/reference-browser>`_ +- `android-components <https://github.com/mozilla-mobile/firefox-android/tree/main/android-components>`_ +- `Firefox Reality <https://github.com/MozillaReality/FirefoxReality>`_ + +Simply edit (or create) the file ``local.properties`` in the project +root and include a line like: + +.. code:: properties + + dependencySubstitutions.geckoviewTopsrcdir=/path/to/mozilla-central + +The default object directory – the one that a plain ``mach build`` +discovers – will be used. You can optionally specify a particular object +directory with an additional line like: + +.. code:: properties + + dependencySubstitutions.geckoviewTopobjdir=/path/to/object-directory + +With these lines, the GeckoView-consuming project should use the +GeckoView AAR produced by ``mach build`` in your local +``mozilla-central``. + +**Remember to remove the lines in ``local.properties`` when you want to +return to using the published GeckoView builds!** + +Dependency substituting your local GeckoView into a non-Mozilla project +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In projects that don’t have first-class support for dependency +substitution already, you can do the substitution yourself. See the +documentation in +`substitue-local-geckoview.gradle <https://hg.mozilla.org/mozilla-central/file/tip/substitute-local-geckoview.gradle>`_, +but roughly: in each Gradle project that consumes GeckoView, i.e., in +each ``build.gradle`` with a +``dependencies { ... 'org.mozilla.geckoview:geckoview-...' }`` block, +include lines like: + +.. code:: groovy + + ext.topsrcdir = "/path/to/mozilla-central" + ext.topobjdir = "/path/to/object-directory" // Optional. + apply from: "${topsrcdir}/substitute-local-geckoview.gradle" + +**Remember to remove the lines from all ``build.gradle`` files when you +want to return to using the published GeckoView builds!** + +Next Steps +---------- + +- Get started with `Native Debugging <native-debugging.html>`_ + +.. |alt text| image:: ../assets/DisableInstantRun.png +.. |alt text 1| image:: ../assets/GeckoViewStructure.png diff --git a/mobile/android/docs/geckoview/contributor/index.rst b/mobile/android/docs/geckoview/contributor/index.rst new file mode 100644 index 0000000000..f38b1c5677 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/index.rst @@ -0,0 +1,29 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +========================= +Contributing to GeckoView +========================= + +.. toctree:: + :maxdepth: 1 + :glob: + :hidden: + + * + +- `Contributor Quick Start Guide <geckoview-quick-start.html>`_: + Get GeckoView set up for development. +- `GeckoView for Gecko Engineers <for-gecko-engineers.html>`_: A + quick-start guide for those already familiar with contributing to + Firefox development. +- `Mozilla Central Quick Start Guide <mc-quick-start.html>`_: Get Mozilla + Central set up for development. +- `Mozilla Central Contributor Guide <contributing-to-mc.html>`_: Get + started as a contributor to Mozilla Central. +- `Guide to Native Debugging in Android Studio <native-debugging.html>`_: + Set up Android Studio for debugging native code. +- `Architecture overview <geckoview-architecture.html>`_: An overview of + GeckoView's architecture. +- `Junit Test Framework <junit.html>`_: An overview of GeckoView's custom + Junit code. +- `apilint <apilint.html>`_: GeckoView's linter for the API. diff --git a/mobile/android/docs/geckoview/contributor/junit.rst b/mobile/android/docs/geckoview/contributor/junit.rst new file mode 100644 index 0000000000..bf8cfd4615 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/junit.rst @@ -0,0 +1,373 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +==================== +Junit Test Framework +==================== + +GeckoView has `a lot +<https://searchfox.org/mozilla-central/rev/36904ac58d2528fc59f640db57cc9429103368d3/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java>`_ +of `custom +<https://searchfox.org/mozilla-central/source/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support>`_ +code that is used to run junit tests. This document is an overview of what this +code does and how it works. + +.. contents:: Table of Contents + :depth: 2 + :local: + +Introduction +============ + +`GeckoView <https://geckoview.dev>`_ is an Android Library that can be used to +embed Gecko, the Web Engine behind Firefox, in applications. It is the +foundation for Firefox on Android, and it is intended to be used to build Web +Browsers, but can also be used to build other types of apps that need to +display Web content. + +GeckoView itself has no UI elements besides the Web View and uses Java +interfaces called "delegates" to let embedders (i.e. apps that use GeckoView) +implement UI behavior. + +For example, when a Web page's JavaScript code calls ``alert('Hello')`` the +embedder will receive a call to the `onAlertPrompt +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PromptDelegate.html#onAlertPrompt-org.mozilla.geckoview.GeckoSession-org.mozilla.geckoview.GeckoSession.PromptDelegate.AlertPrompt->`_ +method of the `PromptDelegate +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoSession.PromptDelegate.html>`_ +interface with all the information needed to display the prompt. + +As most delegate methods deal with UI elements, GeckoView will execute them on +the UI thread for the embedder's convenience. + +GeckoResult +----------- + +One thing that is important to understand for what follows is `GeckoResult +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/GeckoResult.html>`_. +``GeckoResult`` is a promise-like object that is used throughout the GeckoView +API, it allows embedders to asynchronously respond to delegate calls and +GeckoView to return results asynchronously. This is especially important for +GeckoView as it never provides synchronous access to Gecko as a design +principle. + +For example, when installing a WebExtension in GeckoView, the resulting +`WebExtension +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.html>`_ +object is returned in a ``GeckoResult``, which is completed when the extension +is fully installed: + +.. code:: java + + public GeckoResult<WebExtension> install(...) + +To simplify memory safety, ``GeckoResult`` will always `execute callbacks +<https://searchfox.org/mozilla-central/rev/36904ac58d2528fc59f640db57cc9429103368d3/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java#740-744>`_ +in the same thread where it was created, turning asynchronous code into +single-threaded javascript-style code. This is currently `implemented +<https://searchfox.org/mozilla-central/rev/36904ac58d2528fc59f640db57cc9429103368d3/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java#285>`_ +using the Android Looper for the thread, which restricts ``GeckoResult`` to +threads that have a looper, like the Android UI thread. + +Testing overview +---------------- + +Given that GeckoView is effectively a translation layer between Gecko and the +embedder, it's mostly tested through integration tests. The vast majority of +the GeckoView tests are of the form: + +- Load simple test web page +- Interact with the web page through a privileged JavaScript test API +- Verify that the right delegates are called with the right inputs + +and most of the test framework is built around making sure that these +interactions are easy to write and verify. + +Tests in GeckoView can be run using the ``mach`` interface, which is used by +most Gecko tests. E.g. to run the `loadUnknownHost +<https://searchfox.org/mozilla-central/rev/36904ac58d2528fc59f640db57cc9429103368d3/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt#186-196>`_ +test in ``NavigationDelegateTest`` you would type on your terminal: + +.. code:: shell + + ./mach geckoview-junit org.mozilla.geckoview.test.NavigationDelegateTest#loadUnknownHost + +Another way to run GeckoView tests is through the `Android Studio IDE +<https://developer.android.com/studio>`_. By running tests this way, however, +some parts of the test framework are not initialized, and thus some tests +behave differently or fail, as will be explained later. + +Testing envelope +---------------- + +Being a library, GeckoView has a natural, stable, testing envelope, namely the +GeckoView API. The vast majority of GeckoView tests only use +publicly-accessible APIs to verify the behavior of the API. + +Whenever the API is not enough to properly test behavior, the testing framework +offers targeted "privileged" testing APIs. + +Using a restricted, stable testing envelope has proven over the years to be an +effective way of writing consistent tests that don't break upon refactoring. + +Testing Environment +------------------- + +When run through ``mach``, the GeckoView junit tests run in a similar +environment as mochitests (a type of Web regression tests used in Gecko). They +have access to the mochitest web server at `example.com`, and inherit most of +the testing prefs and profile. + +Note the environment will not be the same as mochitests when the test is run +through Android Studio, the prefs will be inherited from the default GeckoView +prefs (i.e. the same prefs that would be enabled in a consumer's build of +GeckoView) and the mochitest web server will not be available. + +Tests account for this using the `isAutomation +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java#36-38>`_ +check, which essentially checks whether the test is running under ``mach`` or +via Android Studio. + +Unlike most other junit tests in the wild, GeckoView tests run in the UI +thread. This is done so that the GeckoResult objects are created on the right +thread. Without this, every test would most likely include a lot of blocks that +run code in the UI thread, adding significant boilerplate. + +Running tests on the UI thread is achieved by registering a custom ``TestRule`` +called `GeckoSessionTestRule +<https://searchfox.org/mozilla-central/rev/36904ac58d2528fc59f640db57cc9429103368d3/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt#186-196>`_, +which, among other things, `overrides the evaluate +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1307,1312>`_ +method and wraps everything into a ``instrumentation.runOnMainSync`` call. + +Verifying delegates +=================== + +As mentioned earlier, verifying that a delegate call happens is one of the most +common assertions that a GeckoView test makes. To facilitate that, +``GeckoSessionTestRule`` offers several ``delegate*`` utilities like: + +.. code:: java + + sessionRule.delegateUntilTestEnd(...) + sessionRule.delegateDuringNextWait(...) + sessionRule.waitUntilCalled(...) + sessionRule.forCallbacksDuringWait(...) + +These all take an arbitrary delegate object (which may include multiple +delegate implementations) and handle installing and cleaning up the delegate as +needed. + +Another set of facilities that ``GeckoSessionTestRule`` offers allow tests to +synchronously ``wait*`` for events, e.g. + +.. code:: java + + sessionRule.waitForJS(...) + sessionRule.waitForResult(...) + sessionRule.waitForPageStop(...) + +These facilities work together with the ``delegate*`` facilities by marking the +``NextWait`` or the ``DuringWait`` events. + +As an example, a test could load a page using ``session.loadUri``, wait until +the page has finished loading using ``waitForPageStop`` and then verify that +the expected delegate was called using ``forCallbacksDuringWait``. + +Note that the ``DuringWait`` here always refers to the last time a ``wait*`` +method was called and finished executing. + +The next sections will go into how this works and how it's implemented. + +Tracking delegate calls +----------------------- + +One thing you might have noticed in the above section is that +``forCallbacksDuringWait`` moves "backward" in time by replaying the delegates +called that happened while the wait was being executed. +``GeckoSessionTestRule`` achieves this by `injecting a proxy object +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1137>`_ +into every delegate, and `proxying every call +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1091-1092>`_ +to the current delegate according to the ``delegate`` test calls. + +The proxy delegate `is built +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1105-1106>`_ +using the Java reflection's ``Proxy.newProxyInstance`` method and receives `a +callback +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1030-1031>`_ +every time a method on the delegate is being executed. + +``GeckoSessionTestRule`` maintains a list of `"default" delegates +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#743-752>`_ +used in GeckoView, and will `use reflection +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#585>`_ +to match the object passed into the ``delegate*`` calls to the proxy delegates. + +For example, when calling + +.. code:: java + + sessionRule.delegateUntilTestEnd(object : NavigationDelegate, ProgressDelegate {}) + +``GeckoSessionTestRule`` will know to redirect all ``NavigationDelegate`` and +``ProgressDelegate`` calls to the object passed in ``delegateUntilTestEnd``. + +Replaying delegate calls +------------------------ + +Some delegate methods require output data to be passed in by the embedder, and +this requires extra care when going "backward in time" by replaying the +delegate's call. + +For example, whenever a page loads, GeckoView will call +``GeckoResult<AllowOrDeny> onLoadRequest(...)`` to know if the load can +continue or not. When replaying delegates, however, we don't know what the +value of ``onLoadRequest`` will be (or if the test is going to install a +delegate for it, either!). + +What ``GeckoSessionTestRule`` does, instead, is to `return the default value +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1092>`_ +for the delegate method, and ignore the replayed delegate method return value. +This can be a little confusing for test writers, for example this code `will +not` stop the page from loading: + +.. code:: java + + session.loadUri("https://www.mozilla.org") + sessionRule.waitForPageStop() + sessionRule.forCallbacksDuringWait(object : NavigationDelegate { + override fun onLoadRequest(session: GeckoSession, request: LoadRequest) : + GeckoResult<AllowOrDeny>? { + // this value is ignored + return GeckoResult.deny() + } + }) + +as the page has already loaded by the time the ``forCallbacksDuringWait`` call is +executed. + +Tracking Waits +-------------- + +To track when a ``wait`` occurs and to know when to replay delegate calls, +``GeckoSessionTestRule`` `stores +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1075>`_ +the list of delegate calls in a ``List<CallRecord>`` object, where +``CallRecord`` is a class that has enough information to replay a delegate +call. The test rule will track the `start and end index +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1619>`_ +of the last wait's delegate calls and `replay it +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1697-1724>`_ +when ``forCallbacksDuringWait`` is called. + +To wait until a delegate call happens, the test rule will first `examine +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1585>`_ +the already executed delegate calls using the call record list described above. +If none of the calls match, then it will `wait for new calls +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1589>`_ +to happen, using ``UiThreadUtils.waitForCondition``. + +``waitForCondition`` is also used to implement other type of ``wait*`` methods +like ``waitForResult``, which waits until a ``GeckoResult`` is executed. + +``waitForCondition`` runs on the UI thread, and it synchronously waits for an +event to occur. The events it waits for normally execute on the UI thread as +well, so it `injects itself +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java#145,153>`_ +in the Android event loop, checking for the condition after every event has +executed. If no more events remain in the queue, `it posts a delayed 100ms +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/UiThreadUtils.java#136-141>`_ +task to avoid clogging the event loop. + +Executing Javascript +==================== + +As you might have noticed from an earlier section, the test rule allows tests +to run arbitrary JavaScript code using ``waitForJS``. The GeckoView API, +however, doesn't offer such an API. + +The way ``waitForJS`` and ``evaluateJS`` are implemented will be the focus of +this section. + +How embedders run javascript +---------------------------- + +The only supported way of accessing a web page for embedders is to `write a +built-in WebExtension +<https://firefox-source-docs.mozilla.org/mobile/android/geckoview/consumer/web-extensions.html>`_ +and install it. This was done intentionally to avoid having to rewrite a lot of +the Web-Content-related APIs that the WebExtension API offers. + +GeckoView extends the WebExtension API to allow embedders to communicate to the +extension by `overloading +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm#221>`_ +the native messaging API (which is not normally implemented on mobile). +Embedders can register themselves as a `native app +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.MessageDelegate.html>`_ +and the built-in extension will be able to `exchange messages +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.Port.html#postMessage-org.json.JSONObject->`_ +and `open ports +<https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/WebExtension.MessageDelegate.html#onConnect-org.mozilla.geckoview.WebExtension.Port->`_ +with the embedder. + +This is still a controversial topic among smaller embedders, especially solo +developers, and we have discussed internally the possibility to expose a +simpler API to run one-off javascript snippets, similar to what Chromium's +WebView offers, but nothing has been developed so far. + +The test runner extension +------------------------- + +To run arbitrary javascript in GeckoView, the test runner installs a `support +extension +<https://searchfox.org/mozilla-central/source/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support>`_. + +The test framework then `establishes +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1827>`_ +a port for the background script, used to run code in the main process, and a +port for every window, to be able to run javascript on test web pages. + +When ``evaluateJS`` is called, the test framework will send `a message +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1912>`_ +to the extension which then `calls eval +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js#21>`_ +on it and returns the `JSON`-stringified version of the result `back +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1952-1956>`_ +to the test framework. + +The test framework also supports promises with `evaluatePromiseJS +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1888>`_. +It works similarly to ``evaluateJS`` but instead of returning the stringified +value, it `sets +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1879>`_ +the return value of the ``eval`` call into the ``this`` object, keyed by a +randomly-generated UUID. + +.. code:: java + + this[uuid] = eval(...) + +``evaluatePromiseJS`` then returns an ``ExtensionPromise`` Java object which +has a ``getValue`` method on it, which will essentially execute `await +this[uuid] +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#1883-1885>`_ +to get the value from the promise when needed. + +Beyond executing javascript +--------------------------- + +A natural way of breaking the boundaries of the GeckoView API is to run a +so-called "experiment extension". Experiment extensions have access to the full +Gecko front-end, which is written in JavaScript, and don't have limits on what +they can do. Experiment extensions are essentially what old add-ons used to be +in Firefox, very powerful and very dangerous. + +The test runner uses experiments to offer `privileged APIs +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js>`_ +to tests like ``setPref`` or ``getLinkColor`` (which is not normally available +to websites for privacy concerns). + +Each privileged API is exposed as an `ordinary Java API +<https://searchfox.org/mozilla-central/rev/95d8478112eecdd0ee249a941788e03f47df240b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java#2101>`_ +and the test framework doesn't offer a way to run arbitrary chrome code to +discourage developers from relying too much on implementation-dependent +privileged code. diff --git a/mobile/android/docs/geckoview/contributor/mc-quick-start.rst b/mobile/android/docs/geckoview/contributor/mc-quick-start.rst new file mode 100644 index 0000000000..bac9dc3ce5 --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/mc-quick-start.rst @@ -0,0 +1,184 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +=========================== +Mozilla Central Quick Start +=========================== + +Table of contents +================= + +.. contents:: :local: + +Firefox Developer Git Quick Start Guide +======================================= + +Getting setup to as a first time Mozilla contributor is hard. There are +plenty of guides out there to help you get started as a contributor, but +many of the new contributor guides out of date often more current ones +are aimed at more experienced contributors. If you want to review these +guides, you can find several linked to from +:ref:`Working on Firefox <Working on Firefox>`. + +This guide will take you through setting up as a contributor to +``mozilla-central``, the Firefox main repository, as a git user. + +Setup +----- + +The first thing you will need is to install Mercurial as this is the VCS +that ``mozilla-central`` uses. + +.. _mac-0: + +Mac +~~~ + +.. _homebrew-0: + +Homebrew +^^^^^^^^ + +.. code:: bash + + brew install mercurial + +macports +^^^^^^^^ + +.. code:: bash + + sudo port install mercurial + +Linux +~~~~~ + +apt +^^^ + +.. code:: bash + + sudo apt-get install mercurial + +Alternatively you can install `Mercurial directly <https://www.mercurial-scm.org/wiki/Download>`_. + +Check that you have successfully installed Mercurial by running: + +.. code:: bash + + hg --version + +If you are an experienced git user and are unfamiliar with Mercurial, +you may want to install ``git-cinnabar``. Cinnabar is a git remote +helper that allows you to interact with Mercurial repos using git +semantics. + +git-cinnabar +------------ + +There is a Homebrew install option for ``git-cinnabar``, but this did +not work for me, nor did the installer option. Using these tools, when I +tried to clone the Mercurial repo it hung and did not complete. I had to +do a manual install before I could use ``git-cinnabar`` successfully to +download a Mercurial repo. If you would like to try either of these +option, however, here they are: + +.. _mac-1: + +Mac +~~~~~ + +.. _homebrew-1: + +Homebrew +^^^^^^^^ + +.. code:: bash + + brew install git-cinnabar + +All Platforms +~~~~~~~~~~~~~ + +Installer +^^^^^^^^^ + +.. code:: bash + + git cinnabar download + +Manual installation +^^^^^^^^^^^^^^^^^^^ + +.. code:: bash + + git clone https://github.com/glandium/git-cinnabar.git && cd git-cinnabar + make + export PATH="$PATH:/somewhere/git-cinnabar" + echo 'export PATH="$PATH:/somewhere/git-cinnabar"' >> ~/.bash_profile + git cinnabar download + +``git-cinnabar``\ ’s creator, `glandium <https://glandium.org/>`_, has +written a number of posts about setting up for Firefox Development with +git. This `post <https://glandium.org/blog/?page_id=3438>`_ is the one +that has formed the basis for this walkthrough. + +In synopsis: + +- initialize an empty git repository + +.. code:: bash + + git init gecko && cd gecko + +- Configure git: + +.. code:: bash + + git config fetch.prune true + git config push.default upstream + +- Add remotes for your repositories. There are several to choose from, + ``central``, ``inbound``, ``beta``, ``release`` etc. but in reality, + if you plan on using Phabricator, which is Firefox’s preferred patch + submission system, you only need to set up ``central``. It might be + advisable to have access to ``inbound`` however, if you want to work + on a version of Firefox that is queued for release. This guide will + be focused on Phabricator. + +.. code:: bash + + git remote add central hg::https://hg.mozilla.org/mozilla-central -t branches/default/tip + git remote add inbound hg::https://hg.mozilla.org/integration/mozilla-inbound -t branches/default/tip + git remote set-url --push central hg::ssh://hg.mozilla.org/mozilla-central + git remote set-url --push inbound hg::ssh://hg.mozilla.org/integration/mozilla-inbound + +- Expose the branch tip to get quick access with some easy names. + +.. code:: bash + + git config remote.central.fetch +refs/heads/branches/default/tip:refs/remotes/central/default + git config remote.inbound.fetch +refs/heads/branches/default/tip:refs/remotes/inbound/default + +- Setup a remote for the try server. The try server is an easy way to + test a patch without actually checking the patch into the core + repository. Your code will go through the same tests as a + ``mozilla-central`` push, and you’ll be able to download builds if + you wish. + +.. code:: bash + + git remote add try hg::https://hg.mozilla.org/try + git config remote.try.skipDefaultUpdate true + git remote set-url --push try hg::ssh://hg.mozilla.org/try + git config remote.try.push +HEAD:refs/heads/branches/default/tip + +- Now update all the remotes. This performs a ``git fetch`` on all the + remotes. Mozilla Central is a *large* repository. Be prepared for + this to take a very long time. + +.. code:: bash + + git remote update + +All that’s left to do now is pick a bug to fix and `submit a +patch <contributing-to-mc.html>`__. diff --git a/mobile/android/docs/geckoview/contributor/native-debugging.rst b/mobile/android/docs/geckoview/contributor/native-debugging.rst new file mode 100644 index 0000000000..774fe35cce --- /dev/null +++ b/mobile/android/docs/geckoview/contributor/native-debugging.rst @@ -0,0 +1,262 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +===================== +Debugging Native Code +===================== + +Table of contents +================= + +.. contents:: :local: + +Debugging Native Code in Android Studio. +======================================== + +If you want to work on the C++ code that powers GeckoView, you will need +to be able to perform native debugging inside Android Studio. This +article will guide you through how to do that. + +If you need to get set up with GeckoView for the first time, follow the +`Quick Start Guide <geckoview-quick-start.html>`_. + +Perform a debug build of Gecko. +------------------------------- + +1. Edit your ``mozconfig`` file and add the following lines. These will + ensure that the build includes debug checks and symbols. + +.. code:: text + + ac_add_options --enable-debug + +2. Ensure that the following lines are commented out in your + ``mozconfig`` if present. ``./mach configure`` will not allow + artifact builds to be enabled when generating a debug build. + +.. code:: text + + # ac_add_options --enable-artifact-builds + +3. To be absolutely sure that Android Studio will pick up your debug + symbols, the first time you perform a debug build it is best to + clobber your ``MOZ_OBJDIR``. Subsequent builds should not need this + step. + +.. code:: bash + + ./mach clobber + +4. Build as usual. Because this is a debug build, and because you have + clobbered your ``MOZ_OBJDIR``, this will take a long time. Subsequent + builds will be incremental and take less time, so go make yourself a + cup of your favourite beverage. + +.. code:: bash + + ./mach build + +Set up lldb to find your symbols +-------------------------------- + +Edit your ``~/.lldbinit`` file (or create one if one does not already +exist) and add the following lines. + +The first line tells LLDB to enable inline breakpoints - Android Studio +will need this if you want to use visual breakpoints. + +The remaining lines tell LLDB where to go to find the symbols for +debugging. + +.. code:: bash + + settings set target.inline-breakpoint-strategy always + settings append target.exec-search-paths <PATH>/objdir-android-opt/toolkit/library/build + settings append target.exec-search-paths <PATH>/objdir-android-opt/mozglue/build + settings append target.exec-search-paths <PATH>/objdir-android-opt/security + +Set up Android Studio to perform native debugging. +================================================== + +1. Edit the configuration that you want to debug by clicking + ``Run -> Edit Configurations...`` and selecting the correct + configuration from the options on the left hand side of the resulting + window. +2. Select the ``Debugger`` tab. +3. Select ``Dual`` from the ``Debug type`` select box. Dual will allow + debugging of both native and Java code in the same session. It is + possible to use ``Native``, but it will only allow for debugging + native code, and it’s frequently necessary to break in the Java code + that configures Gecko and child processes in order to attach + debuggers at the correct times. +4. Under ``Symbol Directories``, add a new path pointing to + ``<PATH>/objdir-android-opt/toolkit/library/build``, the same path + that you entered into your ``.lldbinit`` file. +5. Select ``Apply`` and ``OK`` to close the window. + +Debug Native code in Android Studio +=================================== + +1. The first time you are running a debug session for your app, it’s + best to start from a completely clean build. Click + ``Build -> Rebuild Project`` to clean and rebuild. You can also + choose to remove any existing builds from your emulator to be + completely sure, but this may not be necessary. +2. If using Android Studio visual breakpoints, set your breakpoints in + your native code. +3. Run the app in debug mode as usual. +4. When debugging Fennec or geckoview_example, you will almost + immediately hit a breakpoint in ``ElfLoader.cpp``. This is expected. + If you are not using Android Studio visual breakpoints, you can set + your breakpoints here using the lldb console that is available now + this breakpoint has been hit. To set a breakpoint, select the app tab + (if running Dual, there will also be an ``<app> java`` tab) from the + debug window, and then select the ``lldb`` console tab. Type the + following into the console: + +.. code:: text + + b <file>.cpp:<line number> + +5. Once your breakpoints have been set, click the continue execution + button to move beyond the ``ElfLoader`` breakpoint and your newly set + native breakpoints should be hit. Debug as usual. + +Attaching debuggers to content and other child processes +-------------------------------------------------------- + +Internally, GeckoView has a multi-process architecture. The main Gecko +process lives in the main Android process, but content rendering and +some other functions live in child processes. This balances load, +ensures certain critical security properties, and allows GeckoView to +recover if content processes become unresponsive or crash. However, it’s +generally delicate to debug child processes because they come and go. + +The general approach is to make the Java code in the child process that +you want to debug wait for a Java debugger at startup, and then to +connect such a Java debugger manually from the Android Studio UI. + +`Bug 1522318 <https://bugzilla.mozilla.org/show_bug.cgi?id=1522318>`__ +added environment variables that makes GeckoView wait for Java debuggers +to attach, making this debug process more developer-friendly. See +`Configuring GeckoView for Automation <../consumer/automation.html>`__ +for instructions on how to set environment variables that configure +GeckoView’s runtime environment. + +Making processes wait for a Java debugger +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``set-debug-app`` command will make Android wait for a debugger before +running an app or service. e.g., to make GeckoViewExample wait, run the +following: + +.. code:: shell + + adb shell am set-debug-app -w --persistent org.mozilla.geckoview_example + +The above command works with child processes too, e.g. to make the GPU +process wait for a debugger, run: + +.. code:: shell + + adb shell am set-debug-app -w --persistent org.mozilla.geckoview_example:gpu + + +Attaching a Java debugger to a waiting child process +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is standard: follow the `Android Studio instructions <https://developer.android.com/studio/debug/index.html#attach-debugger>`_. +You must attach a Java debugger, so you almost certainly want to attach +a ``Dual`` debugger and you definitely can’t attach only a ``Native`` +debugger. + +Determining the correct process to attach to is a little tricky because +the mapping from process ID (pid) to process name is not always clear. +Gecko content child processes are suffixed ``:tab`` at this time. + +If you attach ``Dual`` debuggers to both the main process and a content +child process, you will have four (4!) debug tabs to manage in Android +Studio, which is awkward. Android Studio doesn’t appear to configure +attached debuggers in the same way that it configures debuggers +connecting to launched Run Configurations, so you may need to manually +configure search paths – i.e., you may need to invoke the contents of +your ``lldbinit`` file in the appropriate ``lldb`` console by hand, +using an invocation like +``command source /absolute/path/to/topobjdir/lldbinit``. + +Android Studio also doesn’t appear to support targeting breakpoints from +the UI (say, from clicking in a gutter) to specific debug tabs, so you +may also need to set breakpoints in the appropriate ``lldb`` console by +hand. + +Managing more debug tabs may require different approaches. + +Debug Native Memory Allocations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Android Studio includes a `Native Memory Profiler +<https://developer.android.com/studio/profile/memory-profiler#native-memory-profiler>`_ +which works for physical devices running Android 10 and later. In order to +track allocations correctly Gecko must be built with ``jemalloc`` disabled. +Additionally, the native memory profiler appears to only work with ``aarch64`` +builds. The following must therefore be present in your ``mozconfig`` file: + +.. code:: text + + ac_add_options --target=aarch64 + ac_add_options --disable-jemalloc + +The resulting profiles are symbolicated correctly in debug builds, however, you +may prefer to use a release build when profiling. Unfortunately a method to +symbolicate using local symbols from the development machine has not yet been +found, therefore in order for the profile to be symbolicated you must prevent +symbols being stripped during the build process. To do so, add the following to +your ``mozconfig``: + +.. code:: text + + ac_add_options STRIP_FLAGS=--strip-debug + +And the following to ``mobile/android/geckoview/build.gradle``, and additionally +to ``mobile/android/geckoview_example/build.gradle`` if profiling GeckoView +Example, or ``app/build.gradle`` if profiling Fenix, for example. + +.. code:: groovy + + android { + packagingOptions { + doNotStrip "**/*.so" + } + } + +Using Android Studio on Windows +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can now use :ref:`artifact builds <Understanding Artifact Builds>` +mode on `MozillaBuild environment <https://wiki.mozilla.org/MozillaBuild>`_ even if you are +not using WSL. If you want to debug GeckoView using Android Studio on +Windows, you have to set an additional environment variable via the +Control Panel to run the gradle script. The ``mach`` command sets these +variables automatically, but Android Studio cannot. + +If you install MozillaBuild tools to ``C:\mozilla-build`` (default +installation path), you have to set the ``MOZILLABUILD`` environment +variable to recognize MozillaBuild installation path. + +To set environment variable on Windows 10, open the ``Control Panel`` +from ``Windows System``, then select ``System and Security`` - +``System`` - ``Advanced system settings`` - +``Environment Variables ...``. + +To set the ``MOZILLABUILD`` variable, click ``New...`` in +``User variables for``, then ``Variable name:`` is ``MOZILLABUILD`` and +``Variable value:`` is ``C:\mozilla-build``. + +You also have to append some tool paths to the ``Path`` environment +variable. + +To append the variables to PATH, double click ``Path`` in +``User Variables for``, then click ``New``. And append the following +variables to ``Path``. + +- ``%MOZILLABUILD%\msys\bin`` +- ``%MOZILLABUILD%\bin`` diff --git a/mobile/android/docs/geckoview/design/breaking-changes.rst b/mobile/android/docs/geckoview/design/breaking-changes.rst new file mode 100644 index 0000000000..8838a225fa --- /dev/null +++ b/mobile/android/docs/geckoview/design/breaking-changes.rst @@ -0,0 +1,232 @@ +Breaking changes in GeckoView +============================= + +Agi sferro <agi@sferro.dev> + +Abstract +-------- + +This document describes the reasoning behind the GeckoView deprecation policy, +where we are today and where we want to be in the future. + +Background +---------- + +The following sections illustrate how breaking changes are expensive and +frustrating as a consumer of GeckoView, as a Gecko engineer and as an external +consumer, how they take away time from the Fenix team and reduce the average +testing time on Nightly up to 30%. And finally, how breaking changes negate the +very advantages that brought us to the current modularized architecture. + +Introduction +------------ + +GeckoView is a library that provides consumers access to Gecko and is the main +way through which Gecko is consumed on Mozilla’s Android products. + +GeckoView provides Nightly, Beta and Release channels which update with the +same cadence as Firefox Desktop does. + +Firefox for Android (code name Fenix) is developed on a standalone repository +on GitHub and uses GeckoView through Android Components (AC for short), an +Android library also developed on its own standalone repository. + +Fenix also provides Nightly, Beta and Release updates that mirror GeckoView and +Firefox Desktop’s. + +Testing days +------------ + +All Firefox Gecko-based products release a new major version every 4 weeks. +Which means that, on average, a commit that lands on a random day during the +release cycle gets 2 weeks of testing time on the Nightly user base. + +We try to increase the average testing time on Nightly by having a few “soft” +code-freeze days before each Merge day where engineers are not supposed to push +risky changes, but there’s no enforcement and it’s left to each engineer to +decide whether their change is risky or not. + +Each day where the Nightly build is delayed, every change contained in the +current Nightly cycle gets 7% (1 out of 14 days) on average less testing that +it normally would during a build. That is assuming that a problem gets +immediately reported and the report is immediately referred to the right +Engineering team. + +Assuming a 4 days report delay, each day where the Nightly build is delayed, +due to reasons such as breaking changes, reduces the average testing time by +10%. + +Nightly update +-------------- + +Fenix Nightly consumes GeckoView indirectly through Android Components. Each +day, an automated script makes a change in Fenix’s codebase to update AC’s +version. This change is then submitted to Fenix’s CI and, if all tests pass, is +merged to the codebase automatically. + +A new Fenix Nightly build is then generated and automatically published to +Google’s Play Store, from where it gets distributed to all Nightly users on +Android. + +Android Components has a similar automated process which publishes new versions +every day, picking up the new GeckoView nightly build. + +The update process fails from time to time. The cause of the failure largely +falls in one of the following three buckets. + +- An intermittent test failure +- A bug introduced in the latest AC or GeckoView update which causes a test to + fail +- A backward incompatible change has been made in AC or GeckoView that breaks + the build. + +The current mitigation for 1 is to disable or fix tests that fail +intermittently, similarly to what happens in mozilla-central. + +2 and 3 are problems unique to Fenix and AC (as compared to Firefox Desktop) +and are a direct consequence of the multi-package infrastructure of Fenix. + +Build breakages +--------------- + +When the automated Nightly update fails, an engineer on the Fenix team needs to +manually intervene to unblock the build. + +The need for a manual intervention automatically adds a day of Nightly build +delay when the failure occurs outside of business hours, and 2 or 3 days of +delay when the failure happens on a Friday night. + +Therefore, even assuming that a build breakage takes no time to fix, the +average testing time is reduced by 7-30% for each build breakage that occurs. + +In the case where the breakage takes a few days or more to fix, the average +testing time can be reduced to as much as half of what it would be on a +breakage-free Nightly cycle. + +Build breakages put undue burden on the Fenix team, who has to jump on the +breakage and has to drop their current work to avoid losing additional testing +days. + +Reducing breakages +------------------ + +Breakages caused by upstream teams like GeckoView can be divided into 2 groups: + +- Behavior changes that cause test failures downstream +- Breaking changes in the API that cause the build to fail. + +To reduce breakages from group 1, the GeckoView team maintains an extensive set +of integration tests that operate solely on the GeckoView API, and therefore +rarely break because of refactoring. + +For group 2, the GeckoView team instituted a deprecation policy which requires +each backward-incompatible change to keep the old code for 3 releases, allowing +downstream consumers, like Fenix, time to migrate asynchronously to the new +code without breaking the build. + +Functional testing and prototyping +---------------------------------- + +GeckoView offers a test browser app called GeckoViewExample (or GVE) that is +developed in-tree and thus always available to test local changes. + +GVE is the main testing vehicle for Gecko and GeckoView engineers that want to +develop new code, however, there frequently are issues or new features that +cannot be tested on GVE and need to be tested directly on Fenix. + +To test new code in Fenix, the build system offers an easy way to swap +locally-build GeckoView in Fenix. + +The process of testing new Gecko code in Fenix needs to be straightforward, as +it’s often used by platform engineers that are unfamiliar with Android and +Fenix itself, and are not likely to retain knowledge from running code on +Android and would likely need help to do so from the GeckoView or Fenix team. + +Side-effects of build breakages +------------------------------- + +When a breakage lands in mozilla-central and until the breakage is fixed in the +Fenix codebase, a locally built GeckoView is not compatible with the +most-recent tip of Fenix. + +This can be confusing to an engineer that is unfamiliar to Fenix, and can cause +frustration and time lost trying to figure out why upstream code, without +modifications, fails to compile. + +Beyond confusion, an incompatibility on the GeckoView/Fenix combined history +negates the primary advantage of building Fenix in a separate package: +decoupling Gecko from the Android front-end. + +Building older versions from source is also harder, as the set of version +couples (GeckoView, Fenix) that are compatible with each other is not +explicitly documented anywhere. + +External consumers +------------------ + +For apps interested in building a browser for Android, GeckoView provides the +unique combination of being a modern Web engine with a relatively stable API. + +For comparison, alternatives to GeckoView include: + +- WebView, Android’s way of embedding web pages on Android apps. WebView has + has several drawbacks for browser developers, including: + + - having a limited API for building browsers, as it does not expose modern + Web features or browser-specific APIs like bookmarks, passwords, etc; + - not allowing developers to control the underlying Chromium version. WebView + users will get whatever version of WebView is installed on the device. + - On the other hand, using WebView has the advantage of providing a smaller + download package, as the bulk of the engine is already installed on the + device. + +- Fork Chromium, which has the drawback of either having to rewrite the entire + browser front-end or locally patching the Chrome front-end, which involves + frequent changes and updates to be on top of. Using Chromium has the advantage + of providing the most stable, performant and compatible Web Engine on the + market. + +If the cost of updating GeckoView becomes high enough because of frequent API +changes, the advantage of using GeckoView is negated. + +Prior Art +--------- + +Many public libraries offer a deprecation policy similar or better than +GeckoView. For example, Android APIs need to be deprecated for a few releases +before being considered for removal, and completely removed only in exceptional +cases. Google products’ deprecated APIs are supported for a year before being +removed. Ebay requires deprecating an API before removal. + +Status quo +---------- + +Making backward-incompatible changes to the GeckoView API is currently heavily +discouraged and requires approval by the GeckoView team. + +We do, however, have breaking changes from time to time. The last breaking +change was in June 2021, a refactor of the permission API which we didn’t think +was worth executing in a backward compatible way. Before that, the last +breaking change was in September 2020. + +Tracking breaking changes +------------------------- + +Internally, GeckoView tracks the API using apilint. Each change that touches +the API requires an additional GeckoView peer to review the patch and a +description of the change in the changelog. + +Apilint also tracks deprecated APIs and enforces their removal, so that old, +deprecated APIs don’t linger in the codebase for longer than necessary. + +The future +---------- + +The ideal end state for GeckoView would be to not have any more backward +incompatible changes. Our experience is that supporting the old APIs for a +limited time is a small overhead in our development and that the benefits from +having a backward compatible API greatly outweigh the cost. + +We cannot, however, predict all future needs of GeckoView and Firefox as a +whole, so we cannot exclude the possibility of having new breaking changes +going forward. diff --git a/mobile/android/docs/geckoview/design/index.rst b/mobile/android/docs/geckoview/design/index.rst new file mode 100644 index 0000000000..f0e2a2dc84 --- /dev/null +++ b/mobile/android/docs/geckoview/design/index.rst @@ -0,0 +1,19 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +=========== +Design docs +=========== + +.. toctree:: + :maxdepth: 1 + :glob: + :hidden: + + * + +- `Breaking changes <breaking-changes.html>`_ +- `Login Storage <login-storage-api.html>`_ +- `Extension Managing <managing-extensions.html>`_ +- `Priority Hint <priority-hint.html>`_ +- `Save to PDF <save-to-pdf.html>`_ +- `Sharing rust libraries across the Firefox stack <sharing-rust-libraries.html>`_ diff --git a/mobile/android/docs/geckoview/design/login-storage-api.rst b/mobile/android/docs/geckoview/design/login-storage-api.rst new file mode 100644 index 0000000000..3dc4ceac62 --- /dev/null +++ b/mobile/android/docs/geckoview/design/login-storage-api.rst @@ -0,0 +1,207 @@ +GeckoView Login Storage API +=========================== + +Eugen Sawin <esawin@mozilla.com> + +December 20th, 2019 + +Motivation +---------- + +The current GV Autofill API provides all the essential callbacks and meta +information for the implementation of autofill/login app support. It also +manages the fallback to the Android ``AutofillManager``, which delegates +requests to the system-wide autofill service set by the user. + +However, the current GV Autofill API does not leverage the complete range of +Gecko heuristics that handle many autofill/login scenarios. + +The GV Login Storage API is meant to bridge that gap and provide an +intermediate solution for Fenix to enable feature-rich autofill/login support +without duplicating Gecko mechanics. As a storage-level API, it would also +enable easy integration with the existing Firefox Sync AC. + +API Proposal A (deprecated) +--------------------------- + +Unified Login Storage API: session delegate + +.. code:: java + + class LoginStorage { + class Login { + String guid; + // @Fenix: currently called `hostname` in AsyncLoginsStorage. + String origin; + // @Fenix: currently called `formSubmitURL` in AsyncLoginsStorage + String formActionOrigin; + String httpRealm; + String username; + String password; + } + + class Hint { + // @Fenix: Automatically save the login and indicate this to the + // user. + int GENERATED; + // @Fenix: Don’t prompt to save but allow the user to open UI to + // save if they really want. + int PRIVATE_MODE; + // The data looks like it may be some other data (e.g. CC) entered + // in a password field. + // @Fenix: Don’t prompt to save but allow the user to open UI to + // save if they want (e.g. in case the CC number is actually the + // username for a credit card account) + int LOW_CONFIDENCE; + // TBD + } + + interface Delegate { + // Notify that the given login has been used for login. + // @Fenix: call AsyncLoginsStorage.touch(login.guid). + void onLoginUsed(Login login); + + // Request logins for the given domain. + // @Fenix: return AsyncLoginsStorage.getByHostname(domain). + GeckoResult<Login[]> onLoginRequest(String domain); + + // Request to save or update the given login. + // The hint should help determining the appropriate user prompting + // behavior. + // @Fenix: Use the API from application-services/issues/1983 to + // determine whether to show a Save or Update button on the + // doorhanger, taking into account un/pw edits in the doorhanger. + // When the user confirms the save/update, + void onLoginSave(Login login, int hint); + + // TBD (next API iteration): handle autocomplete selection. + // GeckoResult<Login> onLoginSelect(Login[] logins); + } + } + +Extension of ``GeckoSession`` + +.. code:: java + + // Extending existing session class. + class GeckoSession { + // Set the login storage delegate for this session. + void setLoginStorageDelegate(LoginStorage.Delegate delegate); + + LoginStorage.Delegate getLoginStorageDelegate(); + } + +API Proposal B +-------------- + +Split Login Storage API: runtime storage delegate / session prompts +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Split storing and prompting. Fetching and saving of logins is handled by the +runtime delegate, prompting for saving and (in future) autocompletion is +handled by the prompt delegate. + +.. code:: java + + class LoginStorage { + class Login { + String guid; + // @Fenix: currently called `hostname` in AsyncLoginsStorage. + String origin; + // @Fenix: currently called `formSubmitURL` in AsyncLoginsStorage + String formActionOrigin; + String httpRealm; + String username; + String password; + } + + interface Delegate { + // v2 + // Notify that the given login has been used for login. + // @Fenix: call AsyncLoginsStorage.touch(login.guid). + void onLoginUsed(Login login); + + // Request logins for the given domain. + // @Fenix: return AsyncLoginsStorage.getByHostname(domain). + GeckoResult<Login[]> onLoginFetch(String domain); + + // Request to save or update the given login. + void onLoginSave(Login login); + } + } + +Extension of ``GeckoRuntime`` + +.. code:: java + + // Extending existing runtime class. + class GeckoRuntime { + // Set the login storage delegate for this runtime. + void setLoginStorageDelegate(LoginStorage.Delegate delegate); + } + +Extension of ``GeckoSession.PromptDelegate`` + +.. code:: java + + // Extending existing prompt delegate. + class GeckoSession { + interface PromptDelegate { + class LoginStoragePrompt extends BasePrompt { + class Type { + int SAVE; + // TBD: autocomplete selection. + // int SELECT; + } + + class Hint { + // v2 + // @Fenix: Automatically save the login and indicate this + // to the user. + int GENERATED; + // @Fenix: Don’t prompt to save but allow the user to open + // UI to save if they really want. + int PRIVATE_MODE; + // The data looks like it may be some other data (e.g. CC) + // entered in a password field + // @Fenix: Don’t prompt to save but allow the user to open + // UI to save if they want (e.g. in case the CC number is + // actually the username for a credit card account) + int LOW_CONFIDENCE; + // TBD + } + + // Type + int type; + + // Hint + // The hint should help determining the appropriate user + // prompting behavior. + // @Fenix: Use the API from application-services/issues/1983 to + // determine whether to show a Save or Update button on the + // doorhanger, taking into account un/pw edits in the + // doorhanger. When the user confirms the save/update. + int hint; + + // For SAVE, it will hold the login to be stored or updated. + // For SELECT, it will hold the logins for the autocomplete + // selection. + Login[] logins; + + // Confirm SAVE prompt: the login would include a user’s edits + // to what will be saved. + // v2 + // Confirm SELECT (autocomplete) prompt by providing the + // selected login. + PromptResponse confirm(Login login); + + // Dismiss request. + PromptResponse dismiss(); + } + + GeckoResult<PromptResponse> onLoginStoragePrompt( + GeckoSession session, + LoginStoragePrompt prompt + ); + } + } diff --git a/mobile/android/docs/geckoview/design/managing-extensions.rst b/mobile/android/docs/geckoview/design/managing-extensions.rst new file mode 100644 index 0000000000..293590dda6 --- /dev/null +++ b/mobile/android/docs/geckoview/design/managing-extensions.rst @@ -0,0 +1,238 @@ +GeckoView Extension Managing API +================================ + +Agi Sferro <agi@sferro.dev> + +November 19th, 2019 + +Introduction +------------ + +This document describes the API for installing, uninstalling and updating +Extensions with GeckoView. + +Installing an extension provides the extension the ability to run at startup +time, especially useful for e.g. extensions that intercept network requests, +like an ad-blocker or a proxy extension. It also provides additional security +from third-party extensions like signature checking and prompting the user for +permissions. + +For this version of the API we will assume that the extension store is backed +by ``addons.mozilla.org``, and so are the signatures. Running a third-party +extension store is something we might consider in the future but explicitly not +in scope for this document. + +API +--- + +The embedder will be able to install, uninstall, enable, disable and update +extensions using the similarly-named APIs. + +Installing +^^^^^^^^^^ + +Gecko will download the extension pointed by the URI provided in install, parse +the manifest and signature and provide an ``onInstallPrompt`` callback with the +list of permissions requested by the extension and some information about the +extension. + +The embedder will be able to install bundled first-party extensions using +``installBuiltIn``. This method will only accept URIs that start with +``resource://`` and will give additional privileges like being able to use app +messaging and not needing a signature. + +Each permission will have a machine readable name that the embedder will use to +produce user-facing internationalized strings. E.g. “bookmarks” gives access to +bookmarks, “sessions” gives access to recently closed sessions. The full list +of permissions that are currently shown to the UI in Firefox Desktop is +available at: ``toolkit/global/extensionPermissions.ftl`` + +``WebExtension.MetaData`` properties expected to be set to absolute moz-extension urls +(e.g. ``baseUrl`` and ``optionsPageUrl``) are not available yet right after installing +a new extension. Once the extension has been fully started, the delegate method +``WebExtensionController.AddonManagerDelegate.onReady`` will be providing to the +embedder app a new instance of the `MetaData` object where ``baseUrl`` is expected +to be set to a ``"moz-extension://..."`` url (and ``optionsPageUrl`` as well if an +options page was declared in the extension manifest.json file). + +Updating +^^^^^^^^ + +To update an extension, the embedder will be able to call update which will +check if any update is available (using the update_url provided by the +extension, or addons.mozilla.org if no update_url has been provided). The +embedder will receive a GeckoResult that will provide the updated extension +object. This result can also be used to know when the update process is +complete, e.g. the embedder could use it to display a persistent notification +to the user to avoid having the app be killed while updates are in process. + +If the updated extension needs additional permissions, ``GeckoView`` will call +``onUpdatePrompt``. + +Until this callback is resolved (i.e. the embedder’s returned ``GeckoResult`` +is completed), the old addon will be running, only when the prompt is resolved +and the update is applied the new version of the addon starts running and the +``GeckoResult`` returned from update is resolved. + +This callback will provide both the current ``WebExtension`` object and the +updated WebExtension object so that the embedder can show appropriate +information to the user, e.g. the app might decide to remember whether the user +denied the request for a certain version and only prompt the user once the +version string changes. + +As a side effect of updating, Gecko will check its internal blocklist and might +disable extensions that are incompatible with the current version of Gecko or +deemed unsafe. The resulting ``WebExtension`` object will reflect that by +having isEnabled set to false. The embedder will be able to inspect the reason +why the extension was disabled using ``metaData.blockedReason``. + +Gecko will not update any extension or blocklist state without the embedder’s +input. + +Enabling and Disabling +^^^^^^^^^^^^^^^^^^^^^^ + +Embedders will be able to enable and disabling extension using the homonymous +APIs. Calling enable on an extension might not actually enable it if the +extension has been added to the Gecko blocklist. Embedders can check the value +of ``metaData.blockedReason`` to display to the user whether the extension can +actually be enabled or not. The returned WebExtension object will reflect the +updated enablement state in isEnabled. + +Listing +^^^^^^^ + +The embedder is expected to keep a collection of all available extensions using +the result of install and update. To retrieve the extensions that are already +installed the embedder will be able to use ``listInstalled`` which will +asynchronously retrieve the full list of extensions. We recommend calling +``listInstalled`` every time the user is presented with the extension manager +UI to ensure all information is up to date. + +Outline +^^^^^^^ + +.. code:: java + + public class WebExtensionController { + // Start the process of installing an extension, + // the embedder will either get the installed extension + // or an error + GeckoResult<WebExtension> install(String uri); + + // Install a built-in WebExtension with privileged + // permissions, uri must be resource:// + // Privileged WebExtensions have access to experiments + // (i.e. they can run chrome code), don’t need signatures + // and have access to native messaging to the app + GeckoResult<WebExtension> installBuiltIn(String uri) + + GeckoResult<Void> uninstall(WebExtension extension); + + GeckoResult<WebExtension> enable(WebExtension extension); + + GeckoResult<WebExtension> disable(WebExtension extension); + + GeckoResult<List<WebExtension>> listInstalled(); + + // Checks for updates. This method returns a GeckoResult that is + // resolved either with the updated WebExtension object or null + // if the extension does not have pending updates. + GeckoResult<WebExtension> update(WebExtension extension); + + public interface PromptDelegate { + GeckoResult<AllowOrDeny> onInstallPrompt(WebExtension extension); + + GeckoResult<AllowOrDeny> onUpdatePrompt( + WebExtension currentlyInstalled, + WebExtension updatedExtension, + List<String> newPermissions); + + // Called when the extension calls browser.permission.request + GeckoResult<AllowOrDeny> onOptionalPrompt( + WebExtension extension, + List<String> optionalPermissions); + } + + void setPromptDelegate(PromptDelegate promptDelegate); + } + +As part of this document, we will add a ``MetaData`` field to WebExtension +which will contain all the information known about the extension. Note: we will +rename ``ActionIcon`` to Icon to represent its generic use as the +``WebExtension`` icon class. + +.. code:: java + + public class WebExtension { + // Renamed from ActionIcon + static class Icon {} + + final MetaData metadata; + final boolean isBuiltIn; + + final boolean isEnabled; + + public static class SignedStateFlags { + final static int UNKNOWN; + final static int PRELIMINARY; + final static int SIGNED; + final static int SYSTEM; + final static int PRIVILEGED; + } + + // See nsIBlocklistService.idl + public static class BlockedReason { + final static int NOT_BLOCKED; + final static int SOFTBLOCKED; + final static int BLOCKED; + final static int OUTDATED; + final static int VULNERABLE_UPDATE_AVAILABLE; + final static int VULNERABLE_NO_UPDATE; + } + + public class MetaData { + final Icon icon; + final String[] permissions; + final String[] origins; + final String name; + final String description; + final String version; + final String creatorName; + final String creatorUrl; + final String homepageUrl; + final String baseUrl; + final String optionsPageUrl; + final boolean openOptionsPageInTab; + final boolean isRecommended; + final @BlockedReason int blockedReason; + final @SignedState int signedState; + // more if needed + } + } + +Implementation Details +^^^^^^^^^^^^^^^^^^^^^^ + +We will use ``AddonManager`` as a backend for ``WebExtensionController`` and +delegate the prompt to the app using ``PromptDelegate``. We will also merge +``WebExtensionController`` and ``WebExtensionEventDispatcher`` for ease of +implementation. + +Existing APIs +^^^^^^^^^^^^^ + +Some APIs today return a ``WebExtension`` object that might have not been +fetched yet by ``listInstalled``. In these cases, GeckoView will return a stub +``WebExtension`` object in which the metadata field will be null to avoid +waiting for a addon list call. To ensure that the metadata field is populated, +the embedder will need to call ``listInstalled`` at least once during the app +startup. + +Deprecation Path +^^^^^^^^^^^^^^^^ + +The existing ``registerWebExtension`` and ``unregisterWebExtension`` APIs will +be deprecated by ``installBuiltIn`` and ``uninstall``. We will remove the above +APIs 6 releases after the implementation of ``installBuiltIn`` lands and mark +it as deprecated in the API. diff --git a/mobile/android/docs/geckoview/design/priority-hint.rst b/mobile/android/docs/geckoview/design/priority-hint.rst new file mode 100644 index 0000000000..4a915bb7ed --- /dev/null +++ b/mobile/android/docs/geckoview/design/priority-hint.rst @@ -0,0 +1,68 @@ +GeckoView Priority Hint API +=========================== + +Cathy Lu <calu@mozilla.com>, `Bug 1764998 <https://bugzilla.mozilla.org/show_bug.cgi?id=1764998>`_ + +May 2nd, 2022 + +Summary +------- + +This document describes the API for setting a process to high priority by +applying a high priority hint. Instead of deducing the priority based on the +extension’s active priority, this will add an API to set it explicitly. + +Motivation +---------- + +This API will allow Glean metrics to be measured in order to compare +performance and stability metrics for process prioritization on vs off. +Previously, prioritization depended on whether or not a ``GeckoSession`` had a +surface associated with it, which lowered the priority of background tabs and +needed to be reloaded more often. + +Goals +----- + +Apps can set ``priorityHint`` on a ``GeckoSession``. + +Existing Work +------------- + +In `bug 1753700 <https://bugzilla.mozilla.org/show_bug.cgi?id=1753700>`_, we +added an API in dom/ipc to allow ``GeckoViewWebExtension`` to set a specific +``remoteTab``’s boolean ``priorityHint``. This allows tabs that do not have a +surface but are active according to web extension to have high priority. + +Implementation +-------------- + +In ``GeckoSession``, add an API ``setPriorityHint`` that takes an integer as a +parameter. The priority int can be ``PRIORITY_DEFAULT`` or ``PRIORITY_HIGH``. +Specified and active tabs would be ``PRIORITY_HIGH``. The default would be +``PRIORITY_DEFAULT``. The API will dispatch an event +``GeckoView:SetPriorityHint``. + +.. code:: java + + public void setPriorityHint(final @Priority int priorityHint) + +Listeners in ``GeckoViewContent.jsm`` will set +``this.browser.frameLoader.remoteTab.priorityHint`` to the boolean passed in. + +.. code:: java + + case "GeckoView:setPriorityHint": + if (this.browser.isRemoteBrowser) { + let remoteTab = this.browser.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.renderLayers.priorityHint = val; + } + } + break; + +Additional Complexities +----------------------- + +Apps that use this API will need to manually use the API to set the +priorityHint when the tab goes to foreground or background. diff --git a/mobile/android/docs/geckoview/design/save-to-pdf.rst b/mobile/android/docs/geckoview/design/save-to-pdf.rst new file mode 100644 index 0000000000..fc4edc6310 --- /dev/null +++ b/mobile/android/docs/geckoview/design/save-to-pdf.rst @@ -0,0 +1,202 @@ +GeckoView Save to PDF +===================== + +Olivia Hall <ohall@mozilla.com>, Jonathan Almeida <jon@mozilla.com> + +Why +--- + +- The Save to PDF feature was originally available in Fennec and users would + like to see the return of this feature. There are a lot of user requests for + Save to PDF in Fenix. +- We would have more parity with Desktop, and be able to share the same + underlying implementation with them. +- Product is currently evaluating the addition of pdf.js as well; having Save + to PDF would be an added bonus. + +Goals +----- + +- Save the current page to a text-based PDF document. +- Embedders should also be able to call into GeckoView to provide a PDF copy of + the selected GeckoSession. +- Enable the ability to iterate on PDF customizations. + +Non-Goals +--------- + +- We do not want to implement a PDF “preview” of the document prior to the + download. This has open questions: does Product want this, should this be + implemented by the embedder, etc. +- The generated PDF should not match the theme (e.g., light or dark mode) of + the currently displayed page - the PDF will always appear as themeless or as + a plain document. +- No customizable settings. The current API design will not include + customization settings that the embedder can control. This can be worked on + in a follow-up feature request. Our current API design however, would enable + for these particular iterations. + +What +---- + +This work will add a method to ``GeckoSession`` called ``savePdf`` for +embedders to use, which will communicate with a new ``GeckoViewPdf.jsm`` to +create the PDF file. When the document is available, the +``GeckoViewPdfController`` will notify the +``ContentDelegate.onExternalResponse`` with the downloadable document. + +- ``GeckoViewPdf.jsm`` - JavaScript implementation that converts the content to + a PDF and saves the file, also responds to messaging from + ``GeckoViewPdfController``. +- ``GeckoViewPdfController.java`` - The Controller coordinates between the Java + and JS through response messaging and notifies the content delegate when the + PDF is available for use. + +API +--- + +GeckoSession.java +^^^^^^^^^^^^^^^^^ + +.. code:: java + + public class GeckoSession { + public GeckoSession(final @Nullable GeckoSessionSettings settings) { + mPdfController = new PdfController(this); + } + + @UiThread + public void saveAsPdf(PdfSettings settings) { + mPdfController.savePdf(null); + } + } + + +GeckoViewPdf.jsm +^^^^^^^^^^^^^^^^ +.. code:: java + + this.registerListener([ + "GeckoView:SavePdf", + ]); + + async onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:SavePdf": + this.saveToPDF(); + Break; + } + } + } + + async saveToPDF() { + // Reference: https://searchfox.org/mozilla-central/source/remote/cdp/domains/parent/Page.jsm#519 + } + + +GeckoViewPdfController.java +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. code:: java + + class PdfController { + private static final String LOGTAG = "PdfController"; + private final GeckoSession mSession; + + PdfController(final GeckoSession session) { + mSession = session; + } + + private PdfDelegate mDelegate; + private BundleEventListener mEventListener; + + /* package */ + PdfController() { + mEventListener = new EventListener(); + EventDispatcher.getInstance() + .registerUiThreadListener(mEventListener,"GeckoView:PdfSaved"); + } + + @UiThread + public void setDelegate(final @Nullable PdfDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + @UiThread + @Nullable + public PdfDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + return mDelegate; + } + + @UiThread + public void savePdf() { + ThreadUtils.assertOnUiThread(); + mEventDispatcher.dispatch("GeckoView:SavePdf", null); + } + + + 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:PdfSaved": { + final ContentDelegate delegate = mSession.getContentDelegate(); + + if (message.containsKey("pdfPath")) { + InputStream inputStream; /* construct InputStream from local file path */ + WebResponse response = WebResponse.Builder() + .body(inputStream) + // Add other attributes as well. + .build(); + + if (delegate != null) { + delegate.onExternalResponse(mSession, response); + } else { + throw Exception("Needs ContentDelegate for this to work.") + } + } + + break; + } + } + } + } + } + +geckoview.js +^^^^^^^^^^^^ +.. code:: java + + { + name: "GeckoViewPdf", + onInit: { + resource: "resource://gre/modules/GeckoViewPdf.jsm", + } + } + + +Testing +------- + +- Tests for the jsm and java code will be covered by mochitests and junit. +- Make assertions to check that the text and images are in the finished PDF; + the PDF is a non-zero file size. + +Risks +----- + +The API and the code that this work would be using are pretty new, currently +pref'd off in Nightly and could contain implementation bugs. diff --git a/mobile/android/docs/geckoview/design/sharing-rust-libraries.rst b/mobile/android/docs/geckoview/design/sharing-rust-libraries.rst new file mode 100644 index 0000000000..1aa6657b1f --- /dev/null +++ b/mobile/android/docs/geckoview/design/sharing-rust-libraries.rst @@ -0,0 +1,279 @@ +Sharing rust libraries across the Firefox (for Android) stack +============================================================= + +`Agi Sferro <agi@sferro.dev>` + +March 20th, 2021 + +The problem +----------- + +We don’t have a good story for integrating a rust library so that it’s +available to use in Gecko, GeckoView, AC and Fenix and also in a way that rust +can call rust directly avoiding a C FFI layer. + +Goals +----- + +- Being able to integrate a rust library that can be called from Gecko, + GeckoView, AC, Fenix, including having singleton-like instances that are + shared across the stack, per-process. +- The rust library should be able to call and be called by other rust libraries + or rust code in Gecko directly (i.e. without a C FFI layer) +- A build-time assurance that all components in the stack compile against the + same version of the rust library +- Painless, quick and automated updates. Should be able to produce chemspill + updates for the rust library in under 24h with little manual intervention + (besides security checks / code review / QA). +- Support for non-Gecko consumers of the rust library is essential. I.e. + providing a version of Gecko that does not include any of the libraries +- (optional?) Provide an easy way to create bundles of rust libraries depending + on consumers needs. + +Proposal +-------- + +1. Rename libmegazord.so to librustcomponents.so to clarify what the purpose of + this artifact is. +2. Every rust library that wants to be called or wants to call rust code + directly will be included in libxul.so (which contains most of Gecko native + code), and vendored in mozilla-central. This includes, among others, Glean and + Nimbus. +3. libxul.so will expose the necessary FFI symbols for the Kotlin wrappers + needed by the libraries vendored in mozilla-central in step (2). +4. At every nightly / beta / release build of Gecko, we will generate an (or + possibly many) additional librustcomponents.so artifacts that will be published + as an AAR in maven.mozilla.org. This will also publish all the vendored + libraries in mozilla-central to maven, which will have a dependency on the + librustcomponents.so produced as part of this step. Doing this will ensure that + both libxul.so and librustcomponents.so contain the exact same code and can be + swapped freely in the dependency graph. +5. Provide a new GeckoView build with artifactId geckoview-omni which will + depend on all the rust libraries. The existing geckoview will not have such + dependency and will be kept for third-party users of GeckoView. +6. GeckoView will depend on the Kotlin wrappers of all the libraries that + depend on librustcomponents.so built in step (4) in the .pom file. For example + + .. code:: xml + + <dependency> + <groupId>org.mozilla.telemetry</groupId> + <artifactId>glean</artifactId> + <version>33.1.2</version> + <scope>compile</scope> + </dependency> + + It will also exclude the org.mozilla.telemetry.glean dependency to + librustcomponents.so, as the native code is now included in libxul.so as part + of step (2). Presumably Glean will discover where its native code lives by + either trying librustcomponents.so or libxul.so (or some other better methods, + suggestions welcome). + +7. Android Components and Fenix will remove their explicit dependency on Glean, + Nimbus and all other libraries provided by GeckoView, and instead consume the + one provided by GeckoView (this step is optional, note that any version + conflict would cause a build error). + + +The good +-------- + +- We get automated integration with AC for free. When an update for a library + is pushed to mozilla-central, a new nightly build for GeckoView will be + produced which is already consumed by AC automatically (and indirectly into + Fenix). +- Publishing infrastructure to maven is already figured out, and we can reuse + the existing process for GeckoView to publish all the dependencies. +- If a consumer (say AC) uses a mismatched version for a dependency, a + compile-time error will be thrown. +- All consumers of the rust libraries packaged this way are on the same version + (provided they stay up to date with releases) +- Non-Mozilla consumers get first-class visibility into what is packaged into + GeckoView, and can independently discover Glean, Nimbus, etc, since we define + our dependencies in the pom file. +- Gecko Desktop and Gecko Mobile consumer Glean and other libraries in the same + way, removing unnecessary deviation. + +Worth Noting +------------ + +- Uplifts to beta / release versions of Fenix will involve more checks as they + impact Gecko too. + +The Bad +------- + +- Libraries need to be vendored in mozilla-central. Dependencies will follow + the Gecko train which might not be right for them, as some dependencies don’t + really have a nightly vs stable version. - This could change in the future, as + the integration gets deeper and updates to the library become more frequent / + at every commit. +- Locally testing a change in a rust library involves rebuilding all of Gecko. + This is a side effect of statically linking rust libraries to Gecko. +- All rust libraries that are both used by Android and Gecko will need to be + updated together, and we cannot have separate versions on Desktop/Mobile. + Although this can be mitigated by providing flexible dependency on the library + side (e.g. nimbus doesn’t need to depend on a specific version of - Glean and + can accept whatever is in Gecko) +- Code that doesn’t natively live in mozilla-central has double the work to get + into a product - first a release process is needed from the native repo, then + a phabricator process for the vendoring. + +Alternatives Considered +----------------------- + +Telemetry delegate +^^^^^^^^^^^^^^^^^^ + +GeckoView provides a Java Telemetry delegate interface that Glean can implement +on the AC layer to provide Glean functionality to consumers. Glean would offer +a rust wrapper to the Java delegate API to transparently call either the +delegate (when built for mobile) or the Glean instance directly (when built for +Desktop). + +Drawbacks +""""""""" + +- This involves a lot of work on the Glean side to build and maintain the + delegate +- A large section of the Glean API is embedded in the GeckoView API without a + direct dependency +- We don’t expect the telemetry delegate to have other implementations other + than Glean itself, despite the apparent generic nature of the telemetry + delegate +- Glean and GeckoView engineers need to coordinate for every API update, as an + update to the Glean API likely triggers an update to the GV API. +- Gecko Desktop and Gecko Mobile use Glean a meaningfully different way +- Doesn’t solve the dependency problem: even though in theory this would allow + Gecko to work with multiple Glean versions, in practice the GV Telemetry + delegate is going to track Glean so closely that it will inevitably require + pretty specific Glean versions to work. + +Advantages +"""""""""" + +- Explicit code dependency, an uninformed observer can understand how telemetry + is extracted from GeckoView by just looking at the API +- No hard Glean version requirement, AC can be (in theory) built with a + different Glean version than Gecko and things would still work + +Why we decided against +"""""""""""""""""""""" + +The amount of ongoing maintenance work involved on the Glean side far outweighs +the small advantages, namely to not tie AC to a specific Glean version. +Significantly complicates the stack. + +Dynamic Discovery +^^^^^^^^^^^^^^^^^ + +Gecko discovers when it’s being loaded as part of Fenix (or some other +Gecko-powered browser) by calling dlsym on the Glean library. When the +discovery is successful, and the Glean version matches, Gecko will directly use +the Glean provided by Fenix. + +Drawbacks +""""""""" + +- Non standard, non-Mozilla apps will not expect this to work the way it does +- “Magic”: there’s no way to know that the dyscovery is happening (or what + version of Glean is provided with Gecko) unless you know it’s there. +- The standard failure mode is at runtime, as there’s no built-in way to check + that the version provided by Gecko is the same as the one provided by Fenix + at build time. +- Doesn’t solve the synchronization problem: Gecko and Fenix will have to be on + the same Glean version for this to work. +- Gecko Mobile deviates meaningfully from Desktop in the way it uses Glean for + no intrinsic reason + +Advantages +"""""""""" + +- This system is transparent to Consuming apps, e.g. Nimbus can use Glean as + is, with no significant modifications needed. + +Why we decided against +"""""""""""""""""""""" + +- This alternative does not provide substantial benefits over the proposal + outlined in this doc and has significant drawbacks like the runtime failure + case and the non-standard linking process. + +Hybrid Dynamic Discovery +^^^^^^^^^^^^^^^^^^^^^^^^ + +This is a variation of the Dynamic Discovery where Gecko and GeckoView include +Glean directly and consumers get Glean from Gecko dynamically (i.e. they dlsym +libxul.so). + +Drawbacks +""""""""" + +- Glean still needs to build a wrapper for libraries not included in Gecko + (like Nimbus) that want to call Glean directly. + +Advantages +"""""""""" + +- The dependency to Glean is explicit and clear from an uninformed observer + point of view. +- Smaller scope, only Glean would need to be moved to mozilla-central + +Why we decided against +"""""""""""""""""""""" + +Not enough advantages over the proposal, significant ongoing maintenance work +required from the Glean side. + +Open Questions +-------------- + +- How does iOS consume megazord today? Do they have a maven-like dependency + system we can use to publish the iOS megazord? +- How do we deal with licenses in about:license? Application-services has a + build step that extracts rust dependencies and puts them in the pom file +- What would be the process for coordinating a-c breaking changes? +- Would the desire to vendor apply even if this were not Rust code? + +Common Questions +---------------- + +- **How do we make sure GV/AC/Gecko consume the same version of the native + libraries?** The pom dependency in GeckoView ensures that any GeckoView + consumers depend on the same version of a given library, this includes AC and + Fenix. +- **What happens to non-Gecko consumers of megazord?** This plan is transparent + to a non-Gecko consumer of megazord, as they will still consume the native + libraries through the megazord dependency in Glean/Nimbus/etc. With the added + benefit that, if the consumer stays up to date with the megazord dependency, + they will use the same version that Gecko uses. +- **What’s the process to publish an update to the megazord?** When a team + wants to publish an update to the megazord it will need to commit the update + to mozilla-central. A new build will be generated in the next nightly cycle, + producing an updated version of the megazord. My understanding is that current + megazord releases are stable (and don’t have beta/nightly cycles) so for + external consumers, consuming the nightly build could be adequate, and provide + the fastest turnaround on updates. For Gecko consumers the turnaround will be + the same to Firefox Desktop (i.e. roughly 6-8 weeks from commit to release + build). +- **How do we handle security uplifts?** If you have a security release one + rust library you would need to request uplift to beta/release branches + (depending on impact) like all other Gecko changes. The process in itself can + be expedited and have a fast turnaround when needed (below 24h). We have been + using this process for all Gecko changes so I would not expect particular + problems with it. +- **What about OOP cases? E.g. GeckoView as a service?** We briefly discussed + this in the email chain, there are ways we could make that work (e.g. + providing a IPC shim). The details are fuzzy but since we don’t have any + immediate need for such support knowing that it’s doable with a reasonable + amount of work is enough for now. +- **Vendoring in mozilla-central seems excessive.** I agree. This is an + unfortunate requirement stemming from a few assumptions (which could be + challenged! We are choosing not to): + + - Gecko wants to vendor whatever it consumes for rust + - We want rust to call rust directly (without a C FFI layer) + - We want adding new libraries to be a painless experience + + Because of the above, vendoring in mozilla-central seems to be the best if not + the only way to achieve our goals. diff --git a/mobile/android/docs/geckoview/index.rst b/mobile/android/docs/geckoview/index.rst new file mode 100644 index 0000000000..d238ba0a2d --- /dev/null +++ b/mobile/android/docs/geckoview/index.rst @@ -0,0 +1,36 @@ +.. -*- Mode: rst; fill-column: 80; -*- + +GeckoView +========= + +Android offers a built-in WebView, which applications can hook into in order to display web pages within the context of their app. However, Android's WebView is not really intended for building browsers, and hence, many advanced Web APIs are disabled. Furthermore, it is also a moving target: different phones might have different versions of WebView, all of which your app has to support. + +That is where GeckoView comes in. GeckoView is: + +- **Full-featured**: GeckoView is designed to expose the entire power of the Web to applications, and all that through a straightforward API. Think of it as harnessing the full power of Gecko (the engine that powers Firefox), while its API is WebView-like and easy to use. +- **Suited for apps and browsers**: GeckoView is particularly suited for building mobile browsers, but it can be embedded as a web engine component in any kind of app. +- **Self-Contained**: Because GeckoView is a standalone library that you bundle with your application, you can be confident that the code you test is the code that will actually run. +- **Standards Compliant**: Like Firefox, GeckoView offers excellent support for modern Web standards. + +============= +Documentation +============= + +.. toctree:: + :titlesonly: + + consumer/index + contributor/index + design/index + Changelog <https://mozilla.github.io/geckoview/javadoc/mozilla-central/org/mozilla/geckoview/doc-files/CHANGELOG> + API Javadoc <https://mozilla.github.io/geckoview/javadoc/mozilla-central/index.html> + +================= +More information +================= + +* Talk to us on `Matrix <https://chat.mozilla.org/#/room/#geckoview:mozilla.org>`_ +* `GeckoView Wiki <https://wiki.mozilla.org/Mobile/GeckoView>`_ +* `GeckoView Source Code <https://searchfox.org/mozilla-central/source/mobile/android/geckoview>`_ +* `Raise a bug on GeckoView code <https://bugzilla.mozilla.org/enter_bug.cgi?product=GeckoView>`_ +* `Raise a documentation bug <https://github.com/mozilla/geckoview/issues>`_ diff --git a/mobile/android/docs/index.rst b/mobile/android/docs/index.rst new file mode 100644 index 0000000000..232363b934 --- /dev/null +++ b/mobile/android/docs/index.rst @@ -0,0 +1,10 @@ +Fennec Legacy +============= + +This collection of linked pages contains old fennec documentation +which are still useful for other projects + +.. toctree:: + :maxdepth: 1 + + mma diff --git a/mobile/android/docs/mma.rst b/mobile/android/docs/mma.rst new file mode 100644 index 0000000000..6d484f6389 --- /dev/null +++ b/mobile/android/docs/mma.rst @@ -0,0 +1,345 @@ +.. -*- Mode: rst; fill-column: 100; -*- + +====================================== + MMA Mobile Marketing Automation +====================================== + +We want to engage with users more. MMA is the project for this purpose. When a user performs a certain +UI action, he/she will see a prompt and have a chance to interact with it. For example, if a user uses +Firefox 10 times a week, but Firefox is not his default browser, we'll prompt the user the next time +when he launchers our app, and guide him to set us as default browser. + +Mozilla is using a third party framework called "Leanplum" in order to achieve above goal for +Android 56 release. Leanplum is a San Francisco company, founded in 2012. We put their SDK in +our codebase and sync upstream when there's a major update. Please find it in ``mobile/android/thirdparty/com/leanplum``. +The SDK is documented at https://www.leanplum.com/docs/android/ + +There are three major component in Leanplum SDK. +1. Events : Triggers when users perform certain actions. An event will normally trigger a prompt message. +2. Deep Links : Actions that users can perform to interact with the prompt message. +3. Messages : Campaigns that we want to engage with users. Messages is a combination of an Event and a Deep Link. + +Data collection +~~~~~~~~~~~~~~~ + +Who will have Leanplum enabled? +====================================================== + +* We use Switchboard https://wiki.mozilla.org/Firefox/Kinto to filter users to have Leanplum enabled. Currently, for users in the USA + and whose locale is set to English, 10% of that users will have Leanplum enabled. +* If the user has "Health Report" setting enabled. +* If above two are true, when the app starts, and switchboard configure arrived, Firefox for Android will send the + triggers and message interaction history to Leanplum server when available. + + +Where does data sent to the Leanplum backend go? +====================================================== + +The Leanplum SDK is hard-coded to send data to the endpoint https://www.leanplum.com. The endpoint is +defined by ``com.leanplum.internal.Constants.API_HOST_NAME`` at +https://searchfox.org/mozilla-central/rev/c49a70b53f67dd5550eec8a08793805f2aca8d42/mobile/android/thirdparty/com/leanplum/internal/Constants.java#32. + +The user is identified by Leanplum using a random UUID generated by Firefox for Android when Leanplum is initialized for the first time. +This unique identifier is only used by Leanplum and can't be tracked back to any Firefox users. + + +What data is collected and sent to the Leanplum backend? +========================================================== + +The Leanplum SDK collects and sends two messages to the Leanplum backend. The messages have the +following parameters:: + + // Sent every time when an event is triggered + "action" -> "track" // track: an event is tracked. + "event" -> "Launch" // Used when an event is triggered. e.g. E_Saved_Bookmark. + "info" -> "" // Used when an event is triggered. Basic context associated with the event. + "value" -> 0.0 // Used when an event is triggered. Value of that event. + "messageId" -> 5111602214338560 // Used when an event is triggered. The ID of the message. + + // Sent when the app starts + "action" -> "start" // start: Leanplum SDK starts. heartbeat + "userAttributes" -> "{ // A set of key-value pairs used to describe the user. + "Focus Installed" -> true // If Focus for Android is installed. + "Klar Installed" -> true // If Klar for Android is installed. + "Pocket Installed" -> true // If Pocket for Android is installed. + "Signed In Sync" -> true // If the user has signed in to Mozilla account. + "Default Browser" -> true // If the user has set Firefox for Android as default browser. + "Pocket in Top Sites" -> true // If Pocket recommendations for Top Sites home panel are enabled (by default or through user action) + } + "appId" -> "app_6Ao...." // Leanplum App ID. + "clientKey" -> "dev_srwDUNZR...." // Leanplum client access key. + "systemName" -> "Android OS" // Fixed String in SDK. + "locale" -> "zh_TW" // System Locale. + "timezone" -> "Asia/Taipei" // System Timezone. + "versionName" -> "55.0a1" // Firefox for Android version. + "systemVersion" -> "7.1.2" // System version. + "deviceModel" -> "Google Pixel" // System device model. + "timezoneOffsetSeconds" -> "28800" // User timezone offset with PST. + "deviceName" -> "Google Pixel" // System device name. + "region" -> "(detect)" // Not used. We strip play-SERVICES-location so this is will be the default stub value in Leanplum SDK. + "city" -> "(detect)" // Same as above. + "country" -> "(detect)" // Same as above. + "location" -> "(detect)" // Same as above. + "newsfeedMessages" -> " size = 0" // Not used. New Leanplum Inbox message(Leanplum feature) count. + "includeDefaults" -> "false" // Not used. Always false. + + // Other life cycle actions + "action" -> "heartbeat" // heartbeat: every 15 minutes when app is in the foreground + // pauseSession: when app goes to background + // resumeSession: when app goes to foreground + + // Sent for every action + "userId" -> "b13b3c239d01aa7c" // Set by Firefox for Android, we use random uuid so users are anonymous to Leanplum. + "deviceId" -> "b13b3c239d01aa7c" // Same as above. + "sdkVersion" -> "2.2.2-SNAPSHOT" // Leanplum SDK version. + "devMode" -> "true" // If the SDK is in developer mode. For official builds, it's false. + "time" -> "1.497595093902E9" // System time in second. + "token" -> "nksZ5pa0R5iegC7wj...." // Token come from Leanplum backend. + + + "gcmRegistrationId" -> "APA91...." // Send GCM token to Leanplum backend. This happens separately when Leanplum SDK gets initialized. + +Notes on what data is collected +------------------------------- + +User Identifier: +Since Device ID is a random UUID, Leanplum can't map the device to any know Client ID in Firefox for Android nor Advertising ID. + +Events: +Most of the Leanplum events can be mapped to a single combination of Telemetry event (Event+Method+Extra). +Some events are not collected in Mozilla Telemetry. This will be addressed separately in each campaign review. +There are three elements that are used for each event. They are: event name, value(default: 0.0), and info(default: ""). +Default value for event value is 0.0. Default value for event info is empty string. + +List of current Events related data that is sent: + +* When a page could be reader mode and is visible to the user + +.. code-block:: json + + { + "event": "E_Reader_Available" + } + +* Download videos or any other media + +.. code-block:: json + + { + "event" : "E_Download_Media_Saved_Image" + } + +* Save password and login from door hanger + +.. code-block:: json + + { + "event" : "E_Saved_Login_And_Password" + } + +* Save a bookmark from Firefox for Android menu + +.. code-block:: json + + { + "event" : "E_Saved_Bookmark" + } + +* Load the bookmark from home panel + +.. code-block:: json + + { + "event" : "E_Opened_Bookmark" + } + +* Interact with search url area + +.. code-block:: json + + { + "event" : "E_Interact_With_Search_URL_Area" + } + +* Interact with search widget + +.. code-block:: json + + { + "event" : "E_Interact_With_Search_Widget" + } + +* When a screenshot is taken + +.. code-block:: json + + { + "event" : "E_Screenshot" + } + +* Open a new tab + +.. code-block:: json + + { + "event" : "E_Opened_New_Tab" + } + +* App start but Firefox for Android is not set as default browser + +.. code-block:: json + + { + "event" : "E_Launch_But_Not_Default_Browser" + } + +* General app start event + +.. code-block:: json + + { + "event" : "E_Launch_Browser" + } + +* The user just dismissed on-boarding + +.. code-block:: json + + { + "event" : "E_Dismiss_Onboarding" + } + +* Sign in Firefox Account + +.. code-block:: json + + { + "event" : "E_User_Signed_In_To_FxA" + } + +* Firefox Sync finished event + +.. code-block:: json + + { + "event" : "E_User_Finished_Sync" + } + +* The user just resumed the app from background + +.. code-block:: json + + { + "event" : "E_Resumed_From_Background" + } + +* User set Firefox for Android as default browser and resumed the app + +.. code-block:: json + + { + "event" : "E_Changed_Default_To_Fennec" + } + +* User installed the Focus app + +.. code-block:: json + + { + "event" : "E_Just_Installed_Focus" + } + +* User installed the Klar app + +.. code-block:: json + + { + "event" : "E_Just_Installed_Klar" + } + +* User accessed the promo webpage for the Awesomescreen's Firefox promo banner. + +.. code-block:: json + + { + "event" : "E_Opened_Firefox_Promo" + } + +* User dismissed the Awesomescreen's Firefox promo banner. + +.. code-block:: json + + { + "event" : "E_Dismissed_Firefox_Promo" + } + +Deep Links: +Deep links are actions that can point Firefox for Android to open certain pages or load features such as `show bookmark list` or +`open a SUMO page`. When users see a prompt Leanplum message, they can click the button(s) on it. These buttons can +trigger the following deep links: + +* Link to open pages specifically in Firefox for Android (firefox://open?url=) +* Link to Set Default Browser settings (firefox://default_browser) +* Link to specific Add-on page (http://link_to_the_add_on_page) +* Link to sync signup/sign in (firefox://sign_up) +* Link to default search engine settings (firefox://preferences_search) +* Link to “Save as PDF” feature (firefox://save_as_pdf) +* Take user directly to a Sign up for a newsletter (http://link_to_newsletter_page) +* Link to bookmark list (firefox://bookmark_list) +* Link to history list (firefox://history_list) +* Link to main preferences (firefox://preferences) +* Link to privacy preferences (firefox://preferences_privacy) +* Link to notifications preferences (firefox://preferences_notifications) +* Link to accessibility preferences (firefox://preferences_accessibility) +* Link to general setting (firefox://preferences_general) +* Link to home page setting (firefox://preferences_home) + +Messages : +Messages are prompts to the user from Leanplum. Messages can be in-app prompts or push notifications. The interaction of that prompt will be kept and sent to Leanplum backend (such +as "Accept" and "Show"). A messages is a combination of an Event and a Deep Link. The combinations are downloaded from Leanplum +when Leanplum SDK is initialized. When the criteria is met (set in Leanplum backend, could be when an event happens a certain number of times, +and/or targeting certain user attribute ), a prompt message will show up. And there may be buttons for users to click. Those clicks +may trigger deep links. + +We use another Mozilla's Google Cloud Messaging(GCM) sender ID to send push notifications. +These push notifications will look like the notifications that Sync sends out. +Sender ID let GCM knows Mozilla is sending push notifications via Leanplum. +GCM will generate a token at client side. We'll send this GCM token to Leanplum so Leanplum knows whom to send push notifications. +This token is only useful to Mozilla's sender ID so it's anonymized to other parties. +Push Notifications can be triggered by Events, or be sent by Mozilla marketing team manually. + +The list of current messages for Android can be found here: https://wiki.mozilla.org/Leanplum_Contextual_Hints#Android + +Technical notes +~~~~~~~~~~~~~~~ + +Build flags controlling the Leanplum SDK integration +====================================================== + +To test this locally, add lines like: + +export MOZ_ANDROID_MMA=1 +ac_add_options --with-leanplum-sdk-keyfile=/path/to/leanplum-sdk-developer.token + +MOZ_ANDROID_MMA depends on MOZ_ANDROID_GOOGLE_PLAY_SERVICES and MOZ_ANDROID_GCM. +Since Leanplum requires Google Play Services library, those flags are a proxy for it, and enable respectively. + +We want to enable MOZ_ANDROID_MMA in Nightly, but only for +MOZILLA_OFFICIAL builds. Since MOZILLA_OFFICIAL is still defined in +old-configure.in, we can't integrate it in +mobile/android/moz.configure, and therefore we enable using the +automation mozconfigs. + +Technical notes on the Leanplum SDK integration +================================================ + +Just like Adjust, MmaDelegate uses mmaInterface to inject the MmaLeanplumImp and MmaStubImp. +Constants used by Leanplum is in MmaConstants. Services in AndroidManifest are in +``mobile/android/base/MmaAndroidManifest_services.xml.in`` which is also injected by build flag +MOZ_ANDROID_MMA. + +Notes and links +================= + +* Leanplum web page: http://leanplum.com/ +* Leanplum SDK github repo: https://github.com/Leanplum/Leanplum-Android-SDK diff --git a/mobile/android/docs/overview.rst b/mobile/android/docs/overview.rst new file mode 100644 index 0000000000..97f6d19541 --- /dev/null +++ b/mobile/android/docs/overview.rst @@ -0,0 +1,26 @@ +Firefox for Android +=================== + +GeckoView +--------- + +GeckoView is a full-featured webview that can be embedded into Android apps using Gecko as the +rendering engine. + +:ref:`Read more <GeckoView>` + +Android Components +------------------ + +Android components is a collection of components useful for building web browser applications on +Android using GeckoView as the rendering engine. + +`Read more <https://mozac.org/>`_ + +Frontend +-------- + +The frontend for Firefox for Android is built as a native Android UI in Kotlin and makes use of +Android Components and GeckoView. + +:ref:`Read more <Building Firefox for Android>` diff --git a/mobile/android/examples/messaging_example/app/build.gradle b/mobile/android/examples/messaging_example/app/build.gradle new file mode 100644 index 0000000000..1fbc89b457 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/build.gradle @@ -0,0 +1,55 @@ +buildDir "${topobjdir}/gradle/build/mobile/android/examples/messaging_example" + +apply plugin: 'com.android.application' + +apply from: "${topsrcdir}/mobile/android/gradle/product_flavors.gradle" + +android { + buildToolsVersion project.ext.buildToolsVersion + compileSdkVersion project.ext.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + defaultConfig { + applicationId "org.mozilla.geckoview.example.messaging" + targetSdkVersion project.ext.targetSdkVersion + minSdkVersion project.ext.minSdkVersion + versionCode 1 + versionName "1.0" + multiDexEnabled true + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + // By default the android plugins ignores folders that start with `_`, but + // we need those in web extensions. + // See also: + // - https://issuetracker.google.com/issues/36911326 + // - https://stackoverflow.com/questions/9206117/how-to-workaround-autoomitting-fiiles-folders-starting-with-underscore-in + aaptOptions { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + noCompress 'ja' + } + + project.configureProductFlavors.delegate = it + project.configureProductFlavors() + + namespace 'org.mozilla.geckoview.example.messaging' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation "androidx.annotation:annotation:1.6.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.multidex:multidex:2.0.1' + testImplementation 'junit:junit:4.13.2' + // Replace this with implementation "org.mozilla.geckoview:geckoview-${geckoviewChannel}:${geckoviewVersion}" + implementation project(path: ':geckoview') +} diff --git a/mobile/android/examples/messaging_example/app/src/main/AndroidManifest.xml b/mobile/android/examples/messaging_example/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a4bab45e72 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ +<?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"> + + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <application + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:name="androidx.multidex.MultiDexApplication" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/AppTheme"> + <activity + android:exported="true" + android:name=".MainActivity"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> diff --git a/mobile/android/examples/messaging_example/app/src/main/assets/messaging/.eslintrc.js b/mobile/android/examples/messaging_example/app/src/main/assets/messaging/.eslintrc.js new file mode 100644 index 0000000000..c5fda00676 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/assets/messaging/.eslintrc.js @@ -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/. */ + +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, +}; diff --git a/mobile/android/examples/messaging_example/app/src/main/assets/messaging/manifest.json b/mobile/android/examples/messaging_example/app/src/main/assets/messaging/manifest.json new file mode 100644 index 0000000000..28e02ff816 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/assets/messaging/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Example messaging web extension.", + "browser_specific_settings": { + "gecko": { + "id": "messaging@example.com" + } + }, + "content_scripts": [ + { + "matches": ["*://*.twitter.com/*"], + "js": ["messaging.js"] + } + ], + "permissions": [ + "nativeMessaging", + "nativeMessagingFromContent", + "geckoViewAddons" + ] +} diff --git a/mobile/android/examples/messaging_example/app/src/main/assets/messaging/messaging.js b/mobile/android/examples/messaging_example/app/src/main/assets/messaging/messaging.js new file mode 100644 index 0000000000..f092f2096d --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/assets/messaging/messaging.js @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 manifest = document.querySelector("head > link[rel=manifest]"); +if (manifest) { + fetch(manifest.href) + .then(response => response.json()) + .then(json => { + const message = { type: "WPAManifest", manifest: json }; + browser.runtime.sendNativeMessage("browser", message); + }); +} diff --git a/mobile/android/examples/messaging_example/app/src/main/java/org/mozilla/geckoview/example/messaging/MainActivity.java b/mobile/android/examples/messaging_example/app/src/main/java/org/mozilla/geckoview/example/messaging/MainActivity.java new file mode 100644 index 0000000000..1b0b13d0ad --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/java/org/mozilla/geckoview/example/messaging/MainActivity.java @@ -0,0 +1,103 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.example.messaging; + +import android.os.Bundle; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntimeSettings; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoView; +import org.mozilla.geckoview.WebExtension; + +// The apk file for this example can be build using the following mach command: +// +// mach gradle messaging_example:build +// +// Formatting error reported by Spotless as part of the build can be autofixed +// can be fixed using the mach command: +// +// mach gradle messaging_example:spotlessApply +// +// After the gradle task messaging_example:build runs successfully, an apk +// file will be stored in the OBJDIR/gradle/build/mobile/android/examples/messaging_example. +// subdirectories + +public class MainActivity extends AppCompatActivity { + private static GeckoRuntime sRuntime; + + private static final String EXTENSION_LOCATION = "resource://android/assets/messaging/"; + private static final String EXTENSION_ID = "messaging@example.com"; + // If you make changes to the extension you need to update this + private static final String EXTENSION_VERSION = "1.0"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + GeckoView view = findViewById(R.id.geckoview); + GeckoSession session = new GeckoSession(); + + if (sRuntime == null) { + GeckoRuntimeSettings settings = + new GeckoRuntimeSettings.Builder().remoteDebuggingEnabled(true).build(); + sRuntime = GeckoRuntime.create(this, settings); + } + + WebExtension.MessageDelegate messageDelegate = + new WebExtension.MessageDelegate() { + @Nullable + @Override + public GeckoResult<Object> onMessage( + final @NonNull String nativeApp, + final @NonNull Object message, + final @NonNull WebExtension.MessageSender sender) { + if (message instanceof JSONObject) { + JSONObject json = (JSONObject) message; + try { + if (json.has("type") && "WPAManifest".equals(json.getString("type"))) { + JSONObject manifest = json.getJSONObject("manifest"); + Log.d("MessageDelegate", "Found WPA manifest: " + manifest); + } + } catch (JSONException ex) { + Log.e("MessageDelegate", "Invalid manifest", ex); + } + } + return null; + } + }; + + // Let's make sure the extension is installed + sRuntime + .getWebExtensionController() + .ensureBuiltIn(EXTENSION_LOCATION, "messaging@example.com") + .accept( + // Set delegate that will receive messages coming from this extension. + extension -> + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + session + .getWebExtensionController() + .setMessageDelegate(extension, messageDelegate, "browser"); + } + }), + // Something bad happened, let's log an error + e -> Log.e("MessageDelegate", "Error registering extension", e)); + + session.open(sRuntime); + view.setSession(session); + session.loadUri("https://mobile.twitter.com"); + } +} diff --git a/mobile/android/examples/messaging_example/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/android/examples/messaging_example/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..6e4009f3e7 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,38 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillType="evenOdd" + android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" + android:strokeWidth="1" + android:strokeColor="#00000000"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="78.5885" + android:endY="90.9159" + android:startX="48.7653" + android:startY="61.0927" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" + android:strokeWidth="1" + android:strokeColor="#00000000" /> +</vector> diff --git a/mobile/android/examples/messaging_example/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/examples/messaging_example/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..bcab90d6ae --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,174 @@ +<?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/. --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#008577" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> diff --git a/mobile/android/examples/messaging_example/app/src/main/res/layout/activity_main.xml b/mobile/android/examples/messaging_example/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..87e7c0ae72 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ +<?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/. --> + +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".MainActivity"> + + <org.mozilla.geckoview.GeckoView + android:id="@+id/geckoview" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..f39d507313 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher.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/. --> + +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon>
\ No newline at end of file diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..f39d507313 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.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/. --> + +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon>
\ No newline at end of file diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..898f3ed59a --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..dffca3601e --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..64ba76f75e --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..dae5e08234 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..e5ed46597e --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..14ed0af350 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..b0907cac3b --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..d8ae031549 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..2c18de9e66 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..beed3cdd2c --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/mobile/android/examples/messaging_example/app/src/main/res/values/colors.xml b/mobile/android/examples/messaging_example/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..8c84b9a3fc --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ +<?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">#008577</color> + <color name="colorPrimaryDark">#00574B</color> + <color name="colorAccent">#D81B60</color> +</resources> diff --git a/mobile/android/examples/messaging_example/app/src/main/res/values/strings.xml b/mobile/android/examples/messaging_example/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..88982ec38c --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/values/strings.xml @@ -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/. --> + +<resources> + <string name="app_name">MessagingExample</string> +</resources> diff --git a/mobile/android/examples/messaging_example/app/src/main/res/values/styles.xml b/mobile/android/examples/messaging_example/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..436c5ab087 --- /dev/null +++ b/mobile/android/examples/messaging_example/app/src/main/res/values/styles.xml @@ -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/. --> + +<resources> + + <!-- Base application theme. --> + <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> + <!-- Customize your theme here. --> + <item name="colorPrimary">@color/colorPrimary</item> + <item name="colorPrimaryDark">@color/colorPrimaryDark</item> + <item name="colorAccent">@color/colorAccent</item> + </style> + +</resources> diff --git a/mobile/android/examples/port_messaging_example/app/build.gradle b/mobile/android/examples/port_messaging_example/app/build.gradle new file mode 100644 index 0000000000..b9eab053fd --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/build.gradle @@ -0,0 +1,54 @@ +buildDir "${topobjdir}/gradle/build/mobile/android/examples/port_messaging_example" + +apply plugin: 'com.android.application' + +apply from: "${topsrcdir}/mobile/android/gradle/product_flavors.gradle" + +android { + buildToolsVersion project.ext.buildToolsVersion + compileSdkVersion project.ext.compileSdkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + defaultConfig { + applicationId "org.mozilla.geckoview.example.messaging" + targetSdkVersion project.ext.targetSdkVersion + minSdkVersion project.ext.minSdkVersion + versionCode 1 + versionName "1.0" + multiDexEnabled true + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + // By default the android plugins ignores folders that start with `_`, but + // we need those in web extensions. + // See also: + // - https://issuetracker.google.com/issues/36911326 + // - https://stackoverflow.com/questions/9206117/how-to-workaround-autoomitting-fiiles-folders-starting-with-underscore-in + aaptOptions { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + noCompress 'ja' + } + + project.configureProductFlavors.delegate = it + project.configureProductFlavors() + + namespace 'org.mozilla.geckoview.example.messaging' +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation "androidx.annotation:annotation:1.6.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.multidex:multidex:2.0.1' + testImplementation 'junit:junit:4.13.2' + implementation project(path: ':geckoview') +} diff --git a/mobile/android/examples/port_messaging_example/app/src/main/AndroidManifest.xml b/mobile/android/examples/port_messaging_example/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..a4bab45e72 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ +<?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"> + + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <application + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:name="androidx.multidex.MultiDexApplication" + android:roundIcon="@mipmap/ic_launcher_round" + android:supportsRtl="true" + android:theme="@style/AppTheme"> + <activity + android:exported="true" + android:name=".MainActivity"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> diff --git a/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/.eslintrc.js b/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/.eslintrc.js new file mode 100644 index 0000000000..c5fda00676 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/.eslintrc.js @@ -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/. */ + +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, +}; diff --git a/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/background.js b/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/background.js new file mode 100644 index 0000000000..0929ee35a5 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/background.js @@ -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/. */ + +// Establish connection with app +const port = browser.runtime.connectNative("browser"); +port.onMessage.addListener(response => { + // Let's just echo the message back + port.postMessage(`Received: ${JSON.stringify(response)}`); +}); +port.postMessage("Hello from WebExtension!"); diff --git a/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/manifest.json b/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/manifest.json new file mode 100644 index 0000000000..c7deb57a94 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/assets/messaging/manifest.json @@ -0,0 +1,15 @@ +{ + "manifest_version": 2, + "name": "messaging", + "version": "1.0", + "description": "Example messaging web extension.", + "browser_specific_settings": { + "gecko": { + "id": "messaging@example.com" + } + }, + "background": { + "scripts": ["background.js"] + }, + "permissions": ["nativeMessaging", "geckoViewAddons"] +} diff --git a/mobile/android/examples/port_messaging_example/app/src/main/java/org/mozilla/geckoview/example/messaging/MainActivity.java b/mobile/android/examples/port_messaging_example/app/src/main/java/org/mozilla/geckoview/example/messaging/MainActivity.java new file mode 100644 index 0000000000..e2f31eea70 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/java/org/mozilla/geckoview/example/messaging/MainActivity.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.geckoview.example.messaging; + +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntimeSettings; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoView; +import org.mozilla.geckoview.WebExtension; + +// +// The apk file for this example can be build using the following mach command: +// +// mach gradle port_messaging_example:build +// +// Formatting error reported by Spotless as part of the build can be autofixed +// can be fixed using the mach command: +// +// mach gradle port_messaging_example:spotlessApply +// +// After the gradle task messaging_example:build runs successfully, an apk +// file will be stored in the OBJDIR/gradle/build/mobile/android/examples/port_messaging_example. +// subdirectories + +public class MainActivity extends AppCompatActivity { + private static GeckoRuntime sRuntime; + + private WebExtension.Port mPort; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + GeckoView view = findViewById(R.id.geckoview); + GeckoSession session = new GeckoSession(); + + if (sRuntime == null) { + GeckoRuntimeSettings settings = + new GeckoRuntimeSettings.Builder().remoteDebuggingEnabled(true).build(); + sRuntime = GeckoRuntime.create(this, settings); + } + + WebExtension.PortDelegate portDelegate = + new WebExtension.PortDelegate() { + @Override + public void onPortMessage( + final @NonNull Object message, final @NonNull WebExtension.Port port) { + Log.d("PortDelegate", "Received message from extension: " + message); + } + + @Override + public void onDisconnect(final @NonNull WebExtension.Port port) { + // This port is not usable anymore. + if (port == mPort) { + mPort = null; + } + } + }; + + WebExtension.MessageDelegate messageDelegate = + new WebExtension.MessageDelegate() { + @Override + @Nullable + public void onConnect(final @NonNull WebExtension.Port port) { + mPort = port; + mPort.setDelegate(portDelegate); + } + }; + + sRuntime + .getWebExtensionController() + .ensureBuiltIn("resource://android/assets/messaging/", "messaging@example.com") + .accept( + // Register message delegate for background script + extension -> + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + extension.setMessageDelegate(messageDelegate, "browser"); + } + }), + e -> Log.e("MessageDelegate", "Error registering WebExtension", e)); + + session.open(sRuntime); + view.setSession(session); + session.loadUri("https://mobile.twitter.com"); + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + if (mPort == null) { + // No extension registered yet, let's ignore this message + return false; + } + + JSONObject message = new JSONObject(); + try { + message.put("keyCode", keyCode); + message.put("event", KeyEvent.keyCodeToString(event.getKeyCode())); + } catch (JSONException ex) { + throw new RuntimeException(ex); + } + + mPort.postMessage(message); + return true; + } +} diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..6e4009f3e7 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,38 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillType="evenOdd" + android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" + android:strokeWidth="1" + android:strokeColor="#00000000"> + <aapt:attr name="android:fillColor"> + <gradient + android:endX="78.5885" + android:endY="90.9159" + android:startX="48.7653" + android:startY="61.0927" + android:type="linear"> + <item + android:color="#44000000" + android:offset="0.0" /> + <item + android:color="#00000000" + android:offset="1.0" /> + </gradient> + </aapt:attr> + </path> + <path + android:fillColor="#FFFFFF" + android:fillType="nonZero" + android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" + android:strokeWidth="1" + android:strokeColor="#00000000" /> +</vector> diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..bcab90d6ae --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,174 @@ +<?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/. --> + +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="108dp" + android:height="108dp" + android:viewportWidth="108" + android:viewportHeight="108"> + <path + android:fillColor="#008577" + android:pathData="M0,0h108v108h-108z" /> + <path + android:fillColor="#00000000" + android:pathData="M9,0L9,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,0L19,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,0L29,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,0L39,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,0L49,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,0L59,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,0L69,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,0L79,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M89,0L89,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M99,0L99,108" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,9L108,9" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,19L108,19" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,29L108,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,39L108,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,49L108,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,59L108,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,69L108,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,79L108,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,89L108,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M0,99L108,99" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,29L89,29" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,39L89,39" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,49L89,49" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,59L89,59" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,69L89,69" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M19,79L89,79" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M29,19L29,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M39,19L39,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M49,19L49,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M59,19L59,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M69,19L69,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> + <path + android:fillColor="#00000000" + android:pathData="M79,19L79,89" + android:strokeWidth="0.8" + android:strokeColor="#33FFFFFF" /> +</vector> diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/layout/activity_main.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..87e7c0ae72 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ +<?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/. --> + +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".MainActivity"> + + <org.mozilla.geckoview.GeckoView + android:id="@+id/geckoview" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..f39d507313 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher.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/. --> + +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon>
\ No newline at end of file diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..f39d507313 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.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/. --> + +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> + <background android:drawable="@drawable/ic_launcher_background" /> + <foreground android:drawable="@drawable/ic_launcher_foreground" /> +</adaptive-icon>
\ No newline at end of file diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..898f3ed59a --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..dffca3601e --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..64ba76f75e --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..dae5e08234 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..e5ed46597e --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..14ed0af350 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..b0907cac3b --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..d8ae031549 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..2c18de9e66 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..beed3cdd2c --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/values/colors.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..8c84b9a3fc --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ +<?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">#008577</color> + <color name="colorPrimaryDark">#00574B</color> + <color name="colorAccent">#D81B60</color> +</resources> diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/values/strings.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..88982ec38c --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/values/strings.xml @@ -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/. --> + +<resources> + <string name="app_name">MessagingExample</string> +</resources> diff --git a/mobile/android/examples/port_messaging_example/app/src/main/res/values/styles.xml b/mobile/android/examples/port_messaging_example/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..436c5ab087 --- /dev/null +++ b/mobile/android/examples/port_messaging_example/app/src/main/res/values/styles.xml @@ -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/. --> + +<resources> + + <!-- Base application theme. --> + <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar"> + <!-- Customize your theme here. --> + <item name="colorPrimary">@color/colorPrimary</item> + <item name="colorPrimaryDark">@color/colorPrimaryDark</item> + <item name="colorAccent">@color/colorAccent</item> + </style> + +</resources> diff --git a/mobile/android/exoplayer2/build.gradle b/mobile/android/exoplayer2/build.gradle new file mode 100644 index 0000000000..d67995650f --- /dev/null +++ b/mobile/android/exoplayer2/build.gradle @@ -0,0 +1,110 @@ +buildDir "${topobjdir}/gradle/build/mobile/android/exoplayer2" + +apply plugin: 'com.android.library' + +dependencies { + // For exoplayer. + compileOnly "com.google.code.findbugs:jsr305:3.0.2" + compileOnly "org.checkerframework:checker-compat-qual:2.5.0" + compileOnly "org.checkerframework:checker-qual:2.5.0" + compileOnly "org.jetbrains.kotlin:kotlin-annotations-jvm:1.7.10" + + androidTestImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + implementation "androidx.annotation:annotation:1.1.0" +} + +android { + buildToolsVersion project.ext.buildToolsVersion + compileSdkVersion project.ext.compileSdkVersion + + defaultConfig { + targetSdkVersion project.ext.targetSdkVersion + minSdkVersion project.ext.minSdkVersion + + versionCode project.ext.versionCode + versionName project.ext.versionName + } + + sourceSets { + main { + java { + srcDir "${topsrcdir}/mobile/android/exoplayer2/src/main/java" + } + } + } + + namespace 'org.mozilla.geckoview.thirdparty' +} + +apply plugin: 'maven-publish' + +version = getVersionNumber() +group = 'org.mozilla.geckoview' + +android.libraryVariants.all { variant -> + def javadoc = task "javadoc${name.capitalize()}"(type: Javadoc) { + } + task("javadocJar${name.capitalize()}", type: Jar, dependsOn: javadoc) { + archiveClassifier = 'javadoc' + destinationDirectory = javadoc.destinationDir + } + task("sourcesJar${name.capitalize()}", type: Jar) { + archiveClassifier = 'sources' + description = "Generate Javadoc for build variant $name" + destinationDirectory = + file("${topobjdir}/mobile/android/geckoview-exoplayer2/sources/${variant.baseName}") + from files(variant.sourceSets.collect({ it.java.srcDirs }).flatten()) + } +} + +publishing { + publications { + android.libraryVariants.all { variant -> + "${variant.name}"(MavenPublication) { + from components.findByName(variant.name) + + pom { + afterEvaluate { + artifactId = "geckoview-exoplayer2" + project.ext.artifactSuffix + } + + 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" + } + } +} + +sourceCompatibility = JavaVersion.VERSION_1_8 +targetCompatibility = JavaVersion.VERSION_1_8 diff --git a/mobile/android/exoplayer2/src/main/AndroidManifest.xml b/mobile/android/exoplayer2/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..2593e9fcbe --- /dev/null +++ b/mobile/android/exoplayer2/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ +<?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 /> diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/AudioBecomingNoisyManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/AudioBecomingNoisyManager.java new file mode 100644 index 0000000000..c833c448e4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/AudioBecomingNoisyManager.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.os.Handler; + +/* package */ final class AudioBecomingNoisyManager { + + private final Context context; + private final AudioBecomingNoisyReceiver receiver; + private boolean receiverRegistered; + + public interface EventListener { + void onAudioBecomingNoisy(); + } + + public AudioBecomingNoisyManager(Context context, Handler eventHandler, EventListener listener) { + this.context = context.getApplicationContext(); + this.receiver = new AudioBecomingNoisyReceiver(eventHandler, listener); + } + + /** + * Enables the {@link AudioBecomingNoisyManager} which calls {@link + * EventListener#onAudioBecomingNoisy()} upon receiving an intent of {@link + * AudioManager#ACTION_AUDIO_BECOMING_NOISY}. + * + * @param enabled True if the listener should be notified when audio is becoming noisy. + */ + public void setEnabled(boolean enabled) { + if (enabled && !receiverRegistered) { + context.registerReceiver( + receiver, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)); + receiverRegistered = true; + } else if (!enabled && receiverRegistered) { + context.unregisterReceiver(receiver); + receiverRegistered = false; + } + } + + private final class AudioBecomingNoisyReceiver extends BroadcastReceiver implements Runnable { + private final EventListener listener; + private final Handler eventHandler; + + public AudioBecomingNoisyReceiver(Handler eventHandler, EventListener listener) { + this.eventHandler = eventHandler; + this.listener = listener; + } + + @Override + public void onReceive(Context context, Intent intent) { + if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) { + eventHandler.post(this); + } + } + + @Override + public void run() { + if (receiverRegistered) { + listener.onAudioBecomingNoisy(); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/AudioFocusManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/AudioFocusManager.java new file mode 100644 index 0000000000..5806f57a08 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/AudioFocusManager.java @@ -0,0 +1,397 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.content.Context; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.os.Handler; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Manages requesting and responding to changes in audio focus. */ +/* package */ final class AudioFocusManager { + + /** Interface to allow AudioFocusManager to give commands to a player. */ + public interface PlayerControl { + /** + * Called when the volume multiplier on the player should be changed. + * + * @param volumeMultiplier The new volume multiplier. + */ + void setVolumeMultiplier(float volumeMultiplier); + + /** + * Called when a command must be executed on the player. + * + * @param playerCommand The command that must be executed. + */ + void executePlayerCommand(@PlayerCommand int playerCommand); + } + + /** + * Player commands. One of {@link #PLAYER_COMMAND_DO_NOT_PLAY}, {@link + * #PLAYER_COMMAND_WAIT_FOR_CALLBACK} or {@link #PLAYER_COMMAND_PLAY_WHEN_READY}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + PLAYER_COMMAND_DO_NOT_PLAY, + PLAYER_COMMAND_WAIT_FOR_CALLBACK, + PLAYER_COMMAND_PLAY_WHEN_READY, + }) + public @interface PlayerCommand {} + /** Do not play. */ + public static final int PLAYER_COMMAND_DO_NOT_PLAY = -1; + /** Do not play now. Wait for callback to play. */ + public static final int PLAYER_COMMAND_WAIT_FOR_CALLBACK = 0; + /** Play freely. */ + public static final int PLAYER_COMMAND_PLAY_WHEN_READY = 1; + + /** Audio focus state. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AUDIO_FOCUS_STATE_NO_FOCUS, + AUDIO_FOCUS_STATE_HAVE_FOCUS, + AUDIO_FOCUS_STATE_LOSS_TRANSIENT, + AUDIO_FOCUS_STATE_LOSS_TRANSIENT_DUCK + }) + private @interface AudioFocusState {} + /** No audio focus is currently being held. */ + private static final int AUDIO_FOCUS_STATE_NO_FOCUS = 0; + /** The requested audio focus is currently held. */ + private static final int AUDIO_FOCUS_STATE_HAVE_FOCUS = 1; + /** Audio focus has been temporarily lost. */ + private static final int AUDIO_FOCUS_STATE_LOSS_TRANSIENT = 2; + /** Audio focus has been temporarily lost, but playback may continue with reduced volume. */ + private static final int AUDIO_FOCUS_STATE_LOSS_TRANSIENT_DUCK = 3; + + private static final String TAG = "AudioFocusManager"; + + private static final float VOLUME_MULTIPLIER_DUCK = 0.2f; + private static final float VOLUME_MULTIPLIER_DEFAULT = 1.0f; + + private final AudioManager audioManager; + private final AudioFocusListener focusListener; + @Nullable private PlayerControl playerControl; + @Nullable private AudioAttributes audioAttributes; + + @AudioFocusState private int audioFocusState; + @C.AudioFocusGain private int focusGain; + private float volumeMultiplier = VOLUME_MULTIPLIER_DEFAULT; + + private @MonotonicNonNull AudioFocusRequest audioFocusRequest; + private boolean rebuildAudioFocusRequest; + + /** + * Constructs an AudioFocusManager to automatically handle audio focus for a player. + * + * @param context The current context. + * @param eventHandler A {@link Handler} to for the thread on which the player is used. + * @param playerControl A {@link PlayerControl} to handle commands from this instance. + */ + public AudioFocusManager(Context context, Handler eventHandler, PlayerControl playerControl) { + this.audioManager = + (AudioManager) context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + this.playerControl = playerControl; + this.focusListener = new AudioFocusListener(eventHandler); + this.audioFocusState = AUDIO_FOCUS_STATE_NO_FOCUS; + } + + /** Gets the current player volume multiplier. */ + public float getVolumeMultiplier() { + return volumeMultiplier; + } + + /** + * Sets audio attributes that should be used to manage audio focus. + * + * <p>Call {@link #updateAudioFocus(boolean, int)} to update the audio focus based on these + * attributes. + * + * @param audioAttributes The audio attributes or {@code null} if audio focus should not be + * managed automatically. + */ + public void setAudioAttributes(@Nullable AudioAttributes audioAttributes) { + if (!Util.areEqual(this.audioAttributes, audioAttributes)) { + this.audioAttributes = audioAttributes; + focusGain = convertAudioAttributesToFocusGain(audioAttributes); + Assertions.checkArgument( + focusGain == C.AUDIOFOCUS_GAIN || focusGain == C.AUDIOFOCUS_NONE, + "Automatic handling of audio focus is only available for USAGE_MEDIA and USAGE_GAME."); + } + } + + /** + * Called by the player to abandon or request audio focus based on the desired player state. + * + * @param playWhenReady The desired value of playWhenReady. + * @param playbackState The desired playback state. + * @return A {@link PlayerCommand} to execute on the player. + */ + @PlayerCommand + public int updateAudioFocus(boolean playWhenReady, @Player.State int playbackState) { + if (shouldAbandonAudioFocus(playbackState)) { + abandonAudioFocus(); + return playWhenReady ? PLAYER_COMMAND_PLAY_WHEN_READY : PLAYER_COMMAND_DO_NOT_PLAY; + } + return playWhenReady ? requestAudioFocus() : PLAYER_COMMAND_DO_NOT_PLAY; + } + + /** + * Called when the manager is no longer required. Audio focus will be released without making any + * calls to the {@link PlayerControl}. + */ + public void release() { + playerControl = null; + abandonAudioFocus(); + } + + // Internal methods. + + @VisibleForTesting + /* package */ AudioManager.OnAudioFocusChangeListener getFocusListener() { + return focusListener; + } + + private boolean shouldAbandonAudioFocus(@Player.State int playbackState) { + return playbackState == Player.STATE_IDLE || focusGain != C.AUDIOFOCUS_GAIN; + } + + @PlayerCommand + private int requestAudioFocus() { + if (audioFocusState == AUDIO_FOCUS_STATE_HAVE_FOCUS) { + return PLAYER_COMMAND_PLAY_WHEN_READY; + } + int requestResult = Util.SDK_INT >= 26 ? requestAudioFocusV26() : requestAudioFocusDefault(); + if (requestResult == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + setAudioFocusState(AUDIO_FOCUS_STATE_HAVE_FOCUS); + return PLAYER_COMMAND_PLAY_WHEN_READY; + } else { + setAudioFocusState(AUDIO_FOCUS_STATE_NO_FOCUS); + return PLAYER_COMMAND_DO_NOT_PLAY; + } + } + + private void abandonAudioFocus() { + if (audioFocusState == AUDIO_FOCUS_STATE_NO_FOCUS) { + return; + } + if (Util.SDK_INT >= 26) { + abandonAudioFocusV26(); + } else { + abandonAudioFocusDefault(); + } + setAudioFocusState(AUDIO_FOCUS_STATE_NO_FOCUS); + } + + private int requestAudioFocusDefault() { + return audioManager.requestAudioFocus( + focusListener, + Util.getStreamTypeForAudioUsage(Assertions.checkNotNull(audioAttributes).usage), + focusGain); + } + + @RequiresApi(26) + private int requestAudioFocusV26() { + if (audioFocusRequest == null || rebuildAudioFocusRequest) { + AudioFocusRequest.Builder builder = + audioFocusRequest == null + ? new AudioFocusRequest.Builder(focusGain) + : new AudioFocusRequest.Builder(audioFocusRequest); + + boolean willPauseWhenDucked = willPauseWhenDucked(); + audioFocusRequest = + builder + .setAudioAttributes(Assertions.checkNotNull(audioAttributes).getAudioAttributesV21()) + .setWillPauseWhenDucked(willPauseWhenDucked) + .setOnAudioFocusChangeListener(focusListener) + .build(); + + rebuildAudioFocusRequest = false; + } + return audioManager.requestAudioFocus(audioFocusRequest); + } + + private void abandonAudioFocusDefault() { + audioManager.abandonAudioFocus(focusListener); + } + + @RequiresApi(26) + private void abandonAudioFocusV26() { + if (audioFocusRequest != null) { + audioManager.abandonAudioFocusRequest(audioFocusRequest); + } + } + + private boolean willPauseWhenDucked() { + return audioAttributes != null && audioAttributes.contentType == C.CONTENT_TYPE_SPEECH; + } + + /** + * Converts {@link AudioAttributes} to one of the audio focus request. + * + * <p>This follows the class Javadoc of {@link AudioFocusRequest}. + * + * @param audioAttributes The audio attributes associated with this focus request. + * @return The type of audio focus gain that should be requested. + */ + @C.AudioFocusGain + private static int convertAudioAttributesToFocusGain(@Nullable AudioAttributes audioAttributes) { + if (audioAttributes == null) { + // Don't handle audio focus. It may be either video only contents or developers + // want to have more finer grained control. (e.g. adding audio focus listener) + return C.AUDIOFOCUS_NONE; + } + + switch (audioAttributes.usage) { + // USAGE_VOICE_COMMUNICATION_SIGNALLING is for DTMF that may happen multiple times + // during the phone call when AUDIOFOCUS_GAIN_TRANSIENT is requested for that. + // Don't request audio focus here. + case C.USAGE_VOICE_COMMUNICATION_SIGNALLING: + return C.AUDIOFOCUS_NONE; + + // Javadoc says 'AUDIOFOCUS_GAIN: Examples of uses of this focus gain are for music + // playback, for a game or a video player' + case C.USAGE_GAME: + case C.USAGE_MEDIA: + return C.AUDIOFOCUS_GAIN; + + // Special usages: USAGE_UNKNOWN shouldn't be used. Request audio focus to prevent + // multiple media playback happen at the same time. + case C.USAGE_UNKNOWN: + Log.w( + TAG, + "Specify a proper usage in the audio attributes for audio focus" + + " handling. Using AUDIOFOCUS_GAIN by default."); + return C.AUDIOFOCUS_GAIN; + + // Javadoc says 'AUDIOFOCUS_GAIN_TRANSIENT: An example is for playing an alarm, or + // during a VoIP call' + case C.USAGE_ALARM: + case C.USAGE_VOICE_COMMUNICATION: + return C.AUDIOFOCUS_GAIN_TRANSIENT; + + // Javadoc says 'AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK: Examples are when playing + // driving directions or notifications' + case C.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE: + case C.USAGE_ASSISTANCE_SONIFICATION: + case C.USAGE_NOTIFICATION: + case C.USAGE_NOTIFICATION_COMMUNICATION_DELAYED: + case C.USAGE_NOTIFICATION_COMMUNICATION_INSTANT: + case C.USAGE_NOTIFICATION_COMMUNICATION_REQUEST: + case C.USAGE_NOTIFICATION_EVENT: + case C.USAGE_NOTIFICATION_RINGTONE: + return C.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK; + + // Javadoc says 'AUDIOFOCUS_GAIN_EXCLUSIVE: This is typically used if you are doing + // audio recording or speech recognition'. + // Assistant is considered as both recording and notifying developer + case C.USAGE_ASSISTANT: + if (Util.SDK_INT >= 19) { + return C.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE; + } else { + return C.AUDIOFOCUS_GAIN_TRANSIENT; + } + + // Special usages: + case C.USAGE_ASSISTANCE_ACCESSIBILITY: + if (audioAttributes.contentType == C.CONTENT_TYPE_SPEECH) { + // Voice shouldn't be interrupted by other playback. + return C.AUDIOFOCUS_GAIN_TRANSIENT; + } + return C.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK; + default: + Log.w(TAG, "Unidentified audio usage: " + audioAttributes.usage); + return C.AUDIOFOCUS_NONE; + } + } + + private void setAudioFocusState(@AudioFocusState int audioFocusState) { + if (this.audioFocusState == audioFocusState) { + return; + } + this.audioFocusState = audioFocusState; + + float volumeMultiplier = + (audioFocusState == AUDIO_FOCUS_STATE_LOSS_TRANSIENT_DUCK) + ? AudioFocusManager.VOLUME_MULTIPLIER_DUCK + : AudioFocusManager.VOLUME_MULTIPLIER_DEFAULT; + if (this.volumeMultiplier == volumeMultiplier) { + return; + } + this.volumeMultiplier = volumeMultiplier; + if (playerControl != null) { + playerControl.setVolumeMultiplier(volumeMultiplier); + } + } + + private void handlePlatformAudioFocusChange(int focusChange) { + switch (focusChange) { + case AudioManager.AUDIOFOCUS_GAIN: + setAudioFocusState(AUDIO_FOCUS_STATE_HAVE_FOCUS); + executePlayerCommand(PLAYER_COMMAND_PLAY_WHEN_READY); + return; + case AudioManager.AUDIOFOCUS_LOSS: + executePlayerCommand(PLAYER_COMMAND_DO_NOT_PLAY); + abandonAudioFocus(); + return; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + if (focusChange == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT || willPauseWhenDucked()) { + executePlayerCommand(PLAYER_COMMAND_WAIT_FOR_CALLBACK); + setAudioFocusState(AUDIO_FOCUS_STATE_LOSS_TRANSIENT); + } else { + setAudioFocusState(AUDIO_FOCUS_STATE_LOSS_TRANSIENT_DUCK); + } + return; + default: + Log.w(TAG, "Unknown focus change type: " + focusChange); + } + } + + private void executePlayerCommand(@PlayerCommand int playerCommand) { + if (playerControl != null) { + playerControl.executePlayerCommand(playerCommand); + } + } + + // Internal audio focus listener. + + private class AudioFocusListener implements AudioManager.OnAudioFocusChangeListener { + private final Handler eventHandler; + + public AudioFocusListener(Handler eventHandler) { + this.eventHandler = eventHandler; + } + + @Override + public void onAudioFocusChange(int focusChange) { + eventHandler.post(() -> handlePlatformAudioFocusChange(focusChange)); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/BasePlayer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/BasePlayer.java new file mode 100644 index 0000000000..c06361e69b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/BasePlayer.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** Abstract base {@link Player} which implements common implementation independent methods. */ +public abstract class BasePlayer implements Player { + + protected final Timeline.Window window; + + public BasePlayer() { + window = new Timeline.Window(); + } + + @Override + public final boolean isPlaying() { + return getPlaybackState() == Player.STATE_READY + && getPlayWhenReady() + && getPlaybackSuppressionReason() == PLAYBACK_SUPPRESSION_REASON_NONE; + } + + @Override + public final void seekToDefaultPosition() { + seekToDefaultPosition(getCurrentWindowIndex()); + } + + @Override + public final void seekToDefaultPosition(int windowIndex) { + seekTo(windowIndex, /* positionMs= */ C.TIME_UNSET); + } + + @Override + public final void seekTo(long positionMs) { + seekTo(getCurrentWindowIndex(), positionMs); + } + + @Override + public final boolean hasPrevious() { + return getPreviousWindowIndex() != C.INDEX_UNSET; + } + + @Override + public final void previous() { + int previousWindowIndex = getPreviousWindowIndex(); + if (previousWindowIndex != C.INDEX_UNSET) { + seekToDefaultPosition(previousWindowIndex); + } + } + + @Override + public final boolean hasNext() { + return getNextWindowIndex() != C.INDEX_UNSET; + } + + @Override + public final void next() { + int nextWindowIndex = getNextWindowIndex(); + if (nextWindowIndex != C.INDEX_UNSET) { + seekToDefaultPosition(nextWindowIndex); + } + } + + @Override + public final void stop() { + stop(/* reset= */ false); + } + + @Override + public final int getNextWindowIndex() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() + ? C.INDEX_UNSET + : timeline.getNextWindowIndex( + getCurrentWindowIndex(), getRepeatModeForNavigation(), getShuffleModeEnabled()); + } + + @Override + public final int getPreviousWindowIndex() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() + ? C.INDEX_UNSET + : timeline.getPreviousWindowIndex( + getCurrentWindowIndex(), getRepeatModeForNavigation(), getShuffleModeEnabled()); + } + + @Override + @Nullable + public final Object getCurrentTag() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() ? null : timeline.getWindow(getCurrentWindowIndex(), window).tag; + } + + @Override + @Nullable + public final Object getCurrentManifest() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() ? null : timeline.getWindow(getCurrentWindowIndex(), window).manifest; + } + + @Override + public final int getBufferedPercentage() { + long position = getBufferedPosition(); + long duration = getDuration(); + return position == C.TIME_UNSET || duration == C.TIME_UNSET + ? 0 + : duration == 0 ? 100 : Util.constrainValue((int) ((position * 100) / duration), 0, 100); + } + + @Override + public final boolean isCurrentWindowDynamic() { + Timeline timeline = getCurrentTimeline(); + return !timeline.isEmpty() && timeline.getWindow(getCurrentWindowIndex(), window).isDynamic; + } + + @Override + public final boolean isCurrentWindowLive() { + Timeline timeline = getCurrentTimeline(); + return !timeline.isEmpty() && timeline.getWindow(getCurrentWindowIndex(), window).isLive; + } + + @Override + public final boolean isCurrentWindowSeekable() { + Timeline timeline = getCurrentTimeline(); + return !timeline.isEmpty() && timeline.getWindow(getCurrentWindowIndex(), window).isSeekable; + } + + @Override + public final long getContentDuration() { + Timeline timeline = getCurrentTimeline(); + return timeline.isEmpty() + ? C.TIME_UNSET + : timeline.getWindow(getCurrentWindowIndex(), window).getDurationMs(); + } + + @RepeatMode + private int getRepeatModeForNavigation() { + @RepeatMode int repeatMode = getRepeatMode(); + return repeatMode == REPEAT_MODE_ONE ? REPEAT_MODE_OFF : repeatMode; + } + + /** Holds a listener reference. */ + protected static final class ListenerHolder { + + /** + * The listener on which {link #invoke} will execute {@link ListenerInvocation listener + * invocations}. + */ + public final Player.EventListener listener; + + private boolean released; + + public ListenerHolder(Player.EventListener listener) { + this.listener = listener; + } + + /** Prevents any further {@link ListenerInvocation} to be executed on {@link #listener}. */ + public void release() { + released = true; + } + + /** + * Executes the given {@link ListenerInvocation} on {@link #listener}. Does nothing if {@link + * #release} has been called on this instance. + */ + public void invoke(ListenerInvocation listenerInvocation) { + if (!released) { + listenerInvocation.invokeListener(listener); + } + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + return listener.equals(((ListenerHolder) other).listener); + } + + @Override + public int hashCode() { + return listener.hashCode(); + } + } + + /** Parameterized invocation of a {@link Player.EventListener} method. */ + protected interface ListenerInvocation { + + /** Executes the invocation on the given {@link Player.EventListener}. */ + void invokeListener(Player.EventListener listener); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/BaseRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/BaseRenderer.java new file mode 100644 index 0000000000..9c2c244053 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/BaseRenderer.java @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.Looper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MediaClock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * An abstract base class suitable for most {@link Renderer} implementations. + */ +public abstract class BaseRenderer implements Renderer, RendererCapabilities { + + private final int trackType; + private final FormatHolder formatHolder; + + private RendererConfiguration configuration; + private int index; + private int state; + private SampleStream stream; + private Format[] streamFormats; + private long streamOffsetUs; + private long readingPositionUs; + private boolean streamIsFinal; + private boolean throwRendererExceptionIsExecuting; + + /** + * @param trackType The track type that the renderer handles. One of the {@link C} + * {@code TRACK_TYPE_*} constants. + */ + public BaseRenderer(int trackType) { + this.trackType = trackType; + formatHolder = new FormatHolder(); + readingPositionUs = C.TIME_END_OF_SOURCE; + } + + @Override + public final int getTrackType() { + return trackType; + } + + @Override + public final RendererCapabilities getCapabilities() { + return this; + } + + @Override + public final void setIndex(int index) { + this.index = index; + } + + @Override + @Nullable + public MediaClock getMediaClock() { + return null; + } + + @Override + public final int getState() { + return state; + } + + @Override + public final void enable(RendererConfiguration configuration, Format[] formats, + SampleStream stream, long positionUs, boolean joining, long offsetUs) + throws ExoPlaybackException { + Assertions.checkState(state == STATE_DISABLED); + this.configuration = configuration; + state = STATE_ENABLED; + onEnabled(joining); + replaceStream(formats, stream, offsetUs); + onPositionReset(positionUs, joining); + } + + @Override + public final void start() throws ExoPlaybackException { + Assertions.checkState(state == STATE_ENABLED); + state = STATE_STARTED; + onStarted(); + } + + @Override + public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + throws ExoPlaybackException { + Assertions.checkState(!streamIsFinal); + this.stream = stream; + readingPositionUs = offsetUs; + streamFormats = formats; + streamOffsetUs = offsetUs; + onStreamChanged(formats, offsetUs); + } + + @Override + @Nullable + public final SampleStream getStream() { + return stream; + } + + @Override + public final boolean hasReadStreamToEnd() { + return readingPositionUs == C.TIME_END_OF_SOURCE; + } + + @Override + public final long getReadingPositionUs() { + return readingPositionUs; + } + + @Override + public final void setCurrentStreamFinal() { + streamIsFinal = true; + } + + @Override + public final boolean isCurrentStreamFinal() { + return streamIsFinal; + } + + @Override + public final void maybeThrowStreamError() throws IOException { + stream.maybeThrowError(); + } + + @Override + public final void resetPosition(long positionUs) throws ExoPlaybackException { + streamIsFinal = false; + readingPositionUs = positionUs; + onPositionReset(positionUs, false); + } + + @Override + public final void stop() throws ExoPlaybackException { + Assertions.checkState(state == STATE_STARTED); + state = STATE_ENABLED; + onStopped(); + } + + @Override + public final void disable() { + Assertions.checkState(state == STATE_ENABLED); + formatHolder.clear(); + state = STATE_DISABLED; + stream = null; + streamFormats = null; + streamIsFinal = false; + onDisabled(); + } + + @Override + public final void reset() { + Assertions.checkState(state == STATE_DISABLED); + formatHolder.clear(); + onReset(); + } + + // RendererCapabilities implementation. + + @Override + @AdaptiveSupport + public int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException { + return ADAPTIVE_NOT_SUPPORTED; + } + + // PlayerMessage.Target implementation. + + @Override + public void handleMessage(int what, @Nullable Object object) throws ExoPlaybackException { + // Do nothing. + } + + // Methods to be overridden by subclasses. + + /** + * Called when the renderer is enabled. + * <p> + * The default implementation is a no-op. + * + * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @throws ExoPlaybackException If an error occurs. + */ + protected void onEnabled(boolean joining) throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer's stream has changed. This occurs when the renderer is enabled after + * {@link #onEnabled(boolean)} has been called, and also when the stream has been replaced whilst + * the renderer is enabled or started. + * <p> + * The default implementation is a no-op. + * + * @param formats The enabled formats. + * @param offsetUs The offset that will be added to the timestamps of buffers read via + * {@link #readSource(FormatHolder, DecoderInputBuffer, boolean)} so that decoder input + * buffers have monotonically increasing timestamps. + * @throws ExoPlaybackException If an error occurs. + */ + protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the position is reset. This occurs when the renderer is enabled after + * {@link #onStreamChanged(Format[], long)} has been called, and also when a position + * discontinuity is encountered. + * <p> + * After a position reset, the renderer's {@link SampleStream} is guaranteed to provide samples + * starting from a key frame. + * <p> + * The default implementation is a no-op. + * + * @param positionUs The new playback position in microseconds. + * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @throws ExoPlaybackException If an error occurs. + */ + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer is started. + * <p> + * The default implementation is a no-op. + * + * @throws ExoPlaybackException If an error occurs. + */ + protected void onStarted() throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer is stopped. + * <p> + * The default implementation is a no-op. + * + * @throws ExoPlaybackException If an error occurs. + */ + protected void onStopped() throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer is disabled. + * <p> + * The default implementation is a no-op. + */ + protected void onDisabled() { + // Do nothing. + } + + /** + * Called when the renderer is reset. + * + * <p>The default implementation is a no-op. + */ + protected void onReset() { + // Do nothing. + } + + // Methods to be called by subclasses. + + /** Returns a clear {@link FormatHolder}. */ + protected final FormatHolder getFormatHolder() { + formatHolder.clear(); + return formatHolder; + } + + /** Returns the formats of the currently enabled stream. */ + protected final Format[] getStreamFormats() { + return streamFormats; + } + + /** + * Returns the configuration set when the renderer was most recently enabled. + */ + protected final RendererConfiguration getConfiguration() { + return configuration; + } + + /** Returns a {@link DrmSession} ready for assignment, handling resource management. */ + @Nullable + protected final <T extends ExoMediaCrypto> DrmSession<T> getUpdatedSourceDrmSession( + @Nullable Format oldFormat, + Format newFormat, + @Nullable DrmSessionManager<T> drmSessionManager, + @Nullable DrmSession<T> existingSourceSession) + throws ExoPlaybackException { + boolean drmInitDataChanged = + !Util.areEqual(newFormat.drmInitData, oldFormat == null ? null : oldFormat.drmInitData); + if (!drmInitDataChanged) { + return existingSourceSession; + } + @Nullable DrmSession<T> newSourceDrmSession = null; + if (newFormat.drmInitData != null) { + if (drmSessionManager == null) { + throw createRendererException( + new IllegalStateException("Media requires a DrmSessionManager"), newFormat); + } + newSourceDrmSession = + drmSessionManager.acquireSession( + Assertions.checkNotNull(Looper.myLooper()), newFormat.drmInitData); + } + if (existingSourceSession != null) { + existingSourceSession.release(); + } + return newSourceDrmSession; + } + + /** + * Returns the index of the renderer within the player. + */ + protected final int getIndex() { + return index; + } + + /** + * Creates an {@link ExoPlaybackException} of type {@link ExoPlaybackException#TYPE_RENDERER} for + * this renderer. + * + * @param cause The cause of the exception. + * @param format The current format used by the renderer. May be null. + */ + protected final ExoPlaybackException createRendererException( + Exception cause, @Nullable Format format) { + @FormatSupport int formatSupport = RendererCapabilities.FORMAT_HANDLED; + if (format != null && !throwRendererExceptionIsExecuting) { + // Prevent recursive re-entry from subclass supportsFormat implementations. + throwRendererExceptionIsExecuting = true; + try { + formatSupport = RendererCapabilities.getFormatSupport(supportsFormat(format)); + } catch (ExoPlaybackException e) { + // Ignore, we are already failing. + } finally { + throwRendererExceptionIsExecuting = false; + } + } + return ExoPlaybackException.createForRenderer(cause, getIndex(), format, formatSupport); + } + + /** + * Reads from the enabled upstream source. If the upstream source has been read to the end then + * {@link C#RESULT_BUFFER_READ} is only returned if {@link #setCurrentStreamFinal()} has been + * called. {@link C#RESULT_NOTHING_READ} is returned otherwise. + * + * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. + * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the + * end of the stream. If the end of the stream has been reached, the {@link + * C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. + * @param formatRequired Whether the caller requires that the format of the stream be read even if + * it's not changing. A sample will never be read if set to true, however it is still possible + * for the end of stream or nothing to be read. + * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or + * {@link C#RESULT_BUFFER_READ}. + */ + protected final int readSource( + FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { + int result = stream.readData(formatHolder, buffer, formatRequired); + if (result == C.RESULT_BUFFER_READ) { + if (buffer.isEndOfStream()) { + readingPositionUs = C.TIME_END_OF_SOURCE; + return streamIsFinal ? C.RESULT_BUFFER_READ : C.RESULT_NOTHING_READ; + } + buffer.timeUs += streamOffsetUs; + readingPositionUs = Math.max(readingPositionUs, buffer.timeUs); + } else if (result == C.RESULT_FORMAT_READ) { + Format format = formatHolder.format; + if (format.subsampleOffsetUs != Format.OFFSET_SAMPLE_RELATIVE) { + format = format.copyWithSubsampleOffsetUs(format.subsampleOffsetUs + streamOffsetUs); + formatHolder.format = format; + } + } + return result; + } + + /** + * Attempts to skip to the keyframe before the specified position, or to the end of the stream if + * {@code positionUs} is beyond it. + * + * @param positionUs The position in microseconds. + * @return The number of samples that were skipped. + */ + protected int skipSource(long positionUs) { + return stream.skipData(positionUs - streamOffsetUs); + } + + /** + * Returns whether the upstream source is ready. + */ + protected final boolean isSourceReady() { + return hasReadStreamToEnd() ? streamIsFinal : stream.isReady(); + } + + /** + * Returns whether {@code drmSessionManager} supports the specified {@code drmInitData}, or true + * if {@code drmInitData} is null. + * + * @param drmSessionManager The drm session manager. + * @param drmInitData {@link DrmInitData} of the format to check for support. + * @return Whether {@code drmSessionManager} supports the specified {@code drmInitData}, or + * true if {@code drmInitData} is null. + */ + protected static boolean supportsFormatDrm(@Nullable DrmSessionManager<?> drmSessionManager, + @Nullable DrmInitData drmInitData) { + if (drmInitData == null) { + // Content is unencrypted. + return true; + } else if (drmSessionManager == null) { + // Content is encrypted, but no drm session manager is available. + return false; + } + return drmSessionManager.canAcquireSession(drmInitData); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/C.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/C.java new file mode 100644 index 0000000000..673c3d90a8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/C.java @@ -0,0 +1,1160 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.annotation.TargetApi; +import android.content.Context; +import android.media.AudioAttributes; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.view.Surface; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlayerMessage.Target; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AuxEffectInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.SimpleDecoderVideoRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoFrameMetadataListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical.CameraMotionListener; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.UUID; + +/** + * Defines constants used by the library. + */ +@SuppressWarnings("InlinedApi") +public final class C { + + private C() {} + + /** + * Special constant representing a time corresponding to the end of a source. Suitable for use in + * any time base. + */ + public static final long TIME_END_OF_SOURCE = Long.MIN_VALUE; + + /** + * Special constant representing an unset or unknown time or duration. Suitable for use in any + * time base. + */ + public static final long TIME_UNSET = Long.MIN_VALUE + 1; + + /** + * Represents an unset or unknown index. + */ + public static final int INDEX_UNSET = -1; + + /** + * Represents an unset or unknown position. + */ + public static final int POSITION_UNSET = -1; + + /** + * Represents an unset or unknown length. + */ + public static final int LENGTH_UNSET = -1; + + /** Represents an unset or unknown percentage. */ + public static final int PERCENTAGE_UNSET = -1; + + /** The number of milliseconds in one second. */ + public static final long MILLIS_PER_SECOND = 1000L; + + /** The number of microseconds in one second. */ + public static final long MICROS_PER_SECOND = 1000000L; + + /** + * The number of nanoseconds in one second. + */ + public static final long NANOS_PER_SECOND = 1000000000L; + + /** The number of bits per byte. */ + public static final int BITS_PER_BYTE = 8; + + /** The number of bytes per float. */ + public static final int BYTES_PER_FLOAT = 4; + + /** + * The name of the ASCII charset. + */ + public static final String ASCII_NAME = "US-ASCII"; + + /** + * The name of the UTF-8 charset. + */ + public static final String UTF8_NAME = "UTF-8"; + + /** The name of the ISO-8859-1 charset. */ + public static final String ISO88591_NAME = "ISO-8859-1"; + + /** The name of the UTF-16 charset. */ + public static final String UTF16_NAME = "UTF-16"; + + /** The name of the UTF-16 little-endian charset. */ + public static final String UTF16LE_NAME = "UTF-16LE"; + + /** + * The name of the serif font family. + */ + public static final String SERIF_NAME = "serif"; + + /** + * The name of the sans-serif font family. + */ + public static final String SANS_SERIF_NAME = "sans-serif"; + + /** + * Crypto modes for a codec. One of {@link #CRYPTO_MODE_UNENCRYPTED}, {@link #CRYPTO_MODE_AES_CTR} + * or {@link #CRYPTO_MODE_AES_CBC}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({CRYPTO_MODE_UNENCRYPTED, CRYPTO_MODE_AES_CTR, CRYPTO_MODE_AES_CBC}) + public @interface CryptoMode {} + /** + * @see MediaCodec#CRYPTO_MODE_UNENCRYPTED + */ + public static final int CRYPTO_MODE_UNENCRYPTED = MediaCodec.CRYPTO_MODE_UNENCRYPTED; + /** + * @see MediaCodec#CRYPTO_MODE_AES_CTR + */ + public static final int CRYPTO_MODE_AES_CTR = MediaCodec.CRYPTO_MODE_AES_CTR; + /** + * @see MediaCodec#CRYPTO_MODE_AES_CBC + */ + public static final int CRYPTO_MODE_AES_CBC = MediaCodec.CRYPTO_MODE_AES_CBC; + + /** + * Represents an unset {@link android.media.AudioTrack} session identifier. Equal to + * {@link AudioManager#AUDIO_SESSION_ID_GENERATE}. + */ + public static final int AUDIO_SESSION_ID_UNSET = AudioManager.AUDIO_SESSION_ID_GENERATE; + + /** + * Represents an audio encoding, or an invalid or unset value. One of {@link Format#NO_VALUE}, + * {@link #ENCODING_INVALID}, {@link #ENCODING_PCM_8BIT}, {@link #ENCODING_PCM_16BIT}, {@link + * #ENCODING_PCM_16BIT_BIG_ENDIAN}, {@link #ENCODING_PCM_24BIT}, {@link #ENCODING_PCM_32BIT}, + * {@link #ENCODING_PCM_FLOAT}, {@link #ENCODING_MP3}, {@link #ENCODING_AC3}, {@link + * #ENCODING_E_AC3}, {@link #ENCODING_E_AC3_JOC}, {@link #ENCODING_AC4}, {@link #ENCODING_DTS}, + * {@link #ENCODING_DTS_HD} or {@link #ENCODING_DOLBY_TRUEHD}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Format.NO_VALUE, + ENCODING_INVALID, + ENCODING_PCM_8BIT, + ENCODING_PCM_16BIT, + ENCODING_PCM_16BIT_BIG_ENDIAN, + ENCODING_PCM_24BIT, + ENCODING_PCM_32BIT, + ENCODING_PCM_FLOAT, + ENCODING_MP3, + ENCODING_AC3, + ENCODING_E_AC3, + ENCODING_E_AC3_JOC, + ENCODING_AC4, + ENCODING_DTS, + ENCODING_DTS_HD, + ENCODING_DOLBY_TRUEHD + }) + public @interface Encoding {} + + /** + * Represents a PCM audio encoding, or an invalid or unset value. One of {@link Format#NO_VALUE}, + * {@link #ENCODING_INVALID}, {@link #ENCODING_PCM_8BIT}, {@link #ENCODING_PCM_16BIT}, {@link + * #ENCODING_PCM_16BIT_BIG_ENDIAN}, {@link #ENCODING_PCM_24BIT}, {@link #ENCODING_PCM_32BIT}, + * {@link #ENCODING_PCM_FLOAT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Format.NO_VALUE, + ENCODING_INVALID, + ENCODING_PCM_8BIT, + ENCODING_PCM_16BIT, + ENCODING_PCM_16BIT_BIG_ENDIAN, + ENCODING_PCM_24BIT, + ENCODING_PCM_32BIT, + ENCODING_PCM_FLOAT + }) + public @interface PcmEncoding {} + /** @see AudioFormat#ENCODING_INVALID */ + public static final int ENCODING_INVALID = AudioFormat.ENCODING_INVALID; + /** @see AudioFormat#ENCODING_PCM_8BIT */ + public static final int ENCODING_PCM_8BIT = AudioFormat.ENCODING_PCM_8BIT; + /** @see AudioFormat#ENCODING_PCM_16BIT */ + public static final int ENCODING_PCM_16BIT = AudioFormat.ENCODING_PCM_16BIT; + /** Like {@link #ENCODING_PCM_16BIT}, but with the bytes in big endian order. */ + public static final int ENCODING_PCM_16BIT_BIG_ENDIAN = 0x10000000; + /** PCM encoding with 24 bits per sample. */ + public static final int ENCODING_PCM_24BIT = 0x20000000; + /** PCM encoding with 32 bits per sample. */ + public static final int ENCODING_PCM_32BIT = 0x30000000; + /** @see AudioFormat#ENCODING_PCM_FLOAT */ + public static final int ENCODING_PCM_FLOAT = AudioFormat.ENCODING_PCM_FLOAT; + /** @see AudioFormat#ENCODING_MP3 */ + public static final int ENCODING_MP3 = AudioFormat.ENCODING_MP3; + /** @see AudioFormat#ENCODING_AC3 */ + public static final int ENCODING_AC3 = AudioFormat.ENCODING_AC3; + /** @see AudioFormat#ENCODING_E_AC3 */ + public static final int ENCODING_E_AC3 = AudioFormat.ENCODING_E_AC3; + /** @see AudioFormat#ENCODING_E_AC3_JOC */ + public static final int ENCODING_E_AC3_JOC = AudioFormat.ENCODING_E_AC3_JOC; + /** @see AudioFormat#ENCODING_AC4 */ + public static final int ENCODING_AC4 = AudioFormat.ENCODING_AC4; + /** @see AudioFormat#ENCODING_DTS */ + public static final int ENCODING_DTS = AudioFormat.ENCODING_DTS; + /** @see AudioFormat#ENCODING_DTS_HD */ + public static final int ENCODING_DTS_HD = AudioFormat.ENCODING_DTS_HD; + /** @see AudioFormat#ENCODING_DOLBY_TRUEHD */ + public static final int ENCODING_DOLBY_TRUEHD = AudioFormat.ENCODING_DOLBY_TRUEHD; + + /** + * Stream types for an {@link android.media.AudioTrack}. One of {@link #STREAM_TYPE_ALARM}, {@link + * #STREAM_TYPE_DTMF}, {@link #STREAM_TYPE_MUSIC}, {@link #STREAM_TYPE_NOTIFICATION}, {@link + * #STREAM_TYPE_RING}, {@link #STREAM_TYPE_SYSTEM}, {@link #STREAM_TYPE_VOICE_CALL} or {@link + * #STREAM_TYPE_USE_DEFAULT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STREAM_TYPE_ALARM, + STREAM_TYPE_DTMF, + STREAM_TYPE_MUSIC, + STREAM_TYPE_NOTIFICATION, + STREAM_TYPE_RING, + STREAM_TYPE_SYSTEM, + STREAM_TYPE_VOICE_CALL, + STREAM_TYPE_USE_DEFAULT + }) + public @interface StreamType {} + /** + * @see AudioManager#STREAM_ALARM + */ + public static final int STREAM_TYPE_ALARM = AudioManager.STREAM_ALARM; + /** + * @see AudioManager#STREAM_DTMF + */ + public static final int STREAM_TYPE_DTMF = AudioManager.STREAM_DTMF; + /** + * @see AudioManager#STREAM_MUSIC + */ + public static final int STREAM_TYPE_MUSIC = AudioManager.STREAM_MUSIC; + /** + * @see AudioManager#STREAM_NOTIFICATION + */ + public static final int STREAM_TYPE_NOTIFICATION = AudioManager.STREAM_NOTIFICATION; + /** + * @see AudioManager#STREAM_RING + */ + public static final int STREAM_TYPE_RING = AudioManager.STREAM_RING; + /** + * @see AudioManager#STREAM_SYSTEM + */ + public static final int STREAM_TYPE_SYSTEM = AudioManager.STREAM_SYSTEM; + /** + * @see AudioManager#STREAM_VOICE_CALL + */ + public static final int STREAM_TYPE_VOICE_CALL = AudioManager.STREAM_VOICE_CALL; + /** + * @see AudioManager#USE_DEFAULT_STREAM_TYPE + */ + public static final int STREAM_TYPE_USE_DEFAULT = AudioManager.USE_DEFAULT_STREAM_TYPE; + /** + * The default stream type used by audio renderers. + */ + public static final int STREAM_TYPE_DEFAULT = STREAM_TYPE_MUSIC; + + /** + * Content types for {@link org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes}. One of {@link + * #CONTENT_TYPE_MOVIE}, {@link #CONTENT_TYPE_MUSIC}, {@link #CONTENT_TYPE_SONIFICATION}, {@link + * #CONTENT_TYPE_SPEECH} or {@link #CONTENT_TYPE_UNKNOWN}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CONTENT_TYPE_MOVIE, + CONTENT_TYPE_MUSIC, + CONTENT_TYPE_SONIFICATION, + CONTENT_TYPE_SPEECH, + CONTENT_TYPE_UNKNOWN + }) + public @interface AudioContentType {} + /** + * @see android.media.AudioAttributes#CONTENT_TYPE_MOVIE + */ + public static final int CONTENT_TYPE_MOVIE = android.media.AudioAttributes.CONTENT_TYPE_MOVIE; + /** + * @see android.media.AudioAttributes#CONTENT_TYPE_MUSIC + */ + public static final int CONTENT_TYPE_MUSIC = android.media.AudioAttributes.CONTENT_TYPE_MUSIC; + /** + * @see android.media.AudioAttributes#CONTENT_TYPE_SONIFICATION + */ + public static final int CONTENT_TYPE_SONIFICATION = + android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION; + /** + * @see android.media.AudioAttributes#CONTENT_TYPE_SPEECH + */ + public static final int CONTENT_TYPE_SPEECH = + android.media.AudioAttributes.CONTENT_TYPE_SPEECH; + /** + * @see android.media.AudioAttributes#CONTENT_TYPE_UNKNOWN + */ + public static final int CONTENT_TYPE_UNKNOWN = + android.media.AudioAttributes.CONTENT_TYPE_UNKNOWN; + + /** + * Flags for {@link org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes}. Possible flag value is + * {@link #FLAG_AUDIBILITY_ENFORCED}. + * + * <p>Note that {@code FLAG_HW_AV_SYNC} is not available because the player takes care of setting + * the flag when tunneling is enabled via a track selector. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_AUDIBILITY_ENFORCED}) + public @interface AudioFlags {} + /** + * @see android.media.AudioAttributes#FLAG_AUDIBILITY_ENFORCED + */ + public static final int FLAG_AUDIBILITY_ENFORCED = + android.media.AudioAttributes.FLAG_AUDIBILITY_ENFORCED; + + /** + * Usage types for {@link org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes}. One of {@link + * #USAGE_ALARM}, {@link #USAGE_ASSISTANCE_ACCESSIBILITY}, {@link + * #USAGE_ASSISTANCE_NAVIGATION_GUIDANCE}, {@link #USAGE_ASSISTANCE_SONIFICATION}, {@link + * #USAGE_ASSISTANT}, {@link #USAGE_GAME}, {@link #USAGE_MEDIA}, {@link #USAGE_NOTIFICATION}, + * {@link #USAGE_NOTIFICATION_COMMUNICATION_DELAYED}, {@link + * #USAGE_NOTIFICATION_COMMUNICATION_INSTANT}, {@link #USAGE_NOTIFICATION_COMMUNICATION_REQUEST}, + * {@link #USAGE_NOTIFICATION_EVENT}, {@link #USAGE_NOTIFICATION_RINGTONE}, {@link + * #USAGE_UNKNOWN}, {@link #USAGE_VOICE_COMMUNICATION} or {@link + * #USAGE_VOICE_COMMUNICATION_SIGNALLING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + USAGE_ALARM, + USAGE_ASSISTANCE_ACCESSIBILITY, + USAGE_ASSISTANCE_NAVIGATION_GUIDANCE, + USAGE_ASSISTANCE_SONIFICATION, + USAGE_ASSISTANT, + USAGE_GAME, + USAGE_MEDIA, + USAGE_NOTIFICATION, + USAGE_NOTIFICATION_COMMUNICATION_DELAYED, + USAGE_NOTIFICATION_COMMUNICATION_INSTANT, + USAGE_NOTIFICATION_COMMUNICATION_REQUEST, + USAGE_NOTIFICATION_EVENT, + USAGE_NOTIFICATION_RINGTONE, + USAGE_UNKNOWN, + USAGE_VOICE_COMMUNICATION, + USAGE_VOICE_COMMUNICATION_SIGNALLING + }) + public @interface AudioUsage {} + /** + * @see android.media.AudioAttributes#USAGE_ALARM + */ + public static final int USAGE_ALARM = android.media.AudioAttributes.USAGE_ALARM; + /** @see android.media.AudioAttributes#USAGE_ASSISTANCE_ACCESSIBILITY */ + public static final int USAGE_ASSISTANCE_ACCESSIBILITY = + android.media.AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY; + /** + * @see android.media.AudioAttributes#USAGE_ASSISTANCE_NAVIGATION_GUIDANCE + */ + public static final int USAGE_ASSISTANCE_NAVIGATION_GUIDANCE = + android.media.AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE; + /** + * @see android.media.AudioAttributes#USAGE_ASSISTANCE_SONIFICATION + */ + public static final int USAGE_ASSISTANCE_SONIFICATION = + android.media.AudioAttributes.USAGE_ASSISTANCE_SONIFICATION; + /** @see android.media.AudioAttributes#USAGE_ASSISTANT */ + public static final int USAGE_ASSISTANT = android.media.AudioAttributes.USAGE_ASSISTANT; + /** + * @see android.media.AudioAttributes#USAGE_GAME + */ + public static final int USAGE_GAME = android.media.AudioAttributes.USAGE_GAME; + /** + * @see android.media.AudioAttributes#USAGE_MEDIA + */ + public static final int USAGE_MEDIA = android.media.AudioAttributes.USAGE_MEDIA; + /** + * @see android.media.AudioAttributes#USAGE_NOTIFICATION + */ + public static final int USAGE_NOTIFICATION = android.media.AudioAttributes.USAGE_NOTIFICATION; + /** + * @see android.media.AudioAttributes#USAGE_NOTIFICATION_COMMUNICATION_DELAYED + */ + public static final int USAGE_NOTIFICATION_COMMUNICATION_DELAYED = + android.media.AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_DELAYED; + /** + * @see android.media.AudioAttributes#USAGE_NOTIFICATION_COMMUNICATION_INSTANT + */ + public static final int USAGE_NOTIFICATION_COMMUNICATION_INSTANT = + android.media.AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_INSTANT; + /** + * @see android.media.AudioAttributes#USAGE_NOTIFICATION_COMMUNICATION_REQUEST + */ + public static final int USAGE_NOTIFICATION_COMMUNICATION_REQUEST = + android.media.AudioAttributes.USAGE_NOTIFICATION_COMMUNICATION_REQUEST; + /** + * @see android.media.AudioAttributes#USAGE_NOTIFICATION_EVENT + */ + public static final int USAGE_NOTIFICATION_EVENT = + android.media.AudioAttributes.USAGE_NOTIFICATION_EVENT; + /** + * @see android.media.AudioAttributes#USAGE_NOTIFICATION_RINGTONE + */ + public static final int USAGE_NOTIFICATION_RINGTONE = + android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE; + /** + * @see android.media.AudioAttributes#USAGE_UNKNOWN + */ + public static final int USAGE_UNKNOWN = android.media.AudioAttributes.USAGE_UNKNOWN; + /** + * @see android.media.AudioAttributes#USAGE_VOICE_COMMUNICATION + */ + public static final int USAGE_VOICE_COMMUNICATION = + android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION; + /** + * @see android.media.AudioAttributes#USAGE_VOICE_COMMUNICATION_SIGNALLING + */ + public static final int USAGE_VOICE_COMMUNICATION_SIGNALLING = + android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING; + + /** + * Capture policies for {@link org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes}. One of {@link + * #ALLOW_CAPTURE_BY_ALL}, {@link #ALLOW_CAPTURE_BY_NONE} or {@link #ALLOW_CAPTURE_BY_SYSTEM}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ALLOW_CAPTURE_BY_ALL, ALLOW_CAPTURE_BY_NONE, ALLOW_CAPTURE_BY_SYSTEM}) + public @interface AudioAllowedCapturePolicy {} + /** See {@link android.media.AudioAttributes#ALLOW_CAPTURE_BY_ALL}. */ + public static final int ALLOW_CAPTURE_BY_ALL = AudioAttributes.ALLOW_CAPTURE_BY_ALL; + /** See {@link android.media.AudioAttributes#ALLOW_CAPTURE_BY_NONE}. */ + public static final int ALLOW_CAPTURE_BY_NONE = AudioAttributes.ALLOW_CAPTURE_BY_NONE; + /** See {@link android.media.AudioAttributes#ALLOW_CAPTURE_BY_SYSTEM}. */ + public static final int ALLOW_CAPTURE_BY_SYSTEM = AudioAttributes.ALLOW_CAPTURE_BY_SYSTEM; + + /** + * Audio focus types. One of {@link #AUDIOFOCUS_NONE}, {@link #AUDIOFOCUS_GAIN}, {@link + * #AUDIOFOCUS_GAIN_TRANSIENT}, {@link #AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK} or {@link + * #AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AUDIOFOCUS_NONE, + AUDIOFOCUS_GAIN, + AUDIOFOCUS_GAIN_TRANSIENT, + AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, + AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + }) + public @interface AudioFocusGain {} + /** @see AudioManager#AUDIOFOCUS_NONE */ + public static final int AUDIOFOCUS_NONE = AudioManager.AUDIOFOCUS_NONE; + /** @see AudioManager#AUDIOFOCUS_GAIN */ + public static final int AUDIOFOCUS_GAIN = AudioManager.AUDIOFOCUS_GAIN; + /** @see AudioManager#AUDIOFOCUS_GAIN_TRANSIENT */ + public static final int AUDIOFOCUS_GAIN_TRANSIENT = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT; + /** @see AudioManager#AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK */ + public static final int AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK = + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK; + /** @see AudioManager#AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE */ + public static final int AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE = + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE; + + /** + * Flags which can apply to a buffer containing a media sample. Possible flag values are {@link + * #BUFFER_FLAG_KEY_FRAME}, {@link #BUFFER_FLAG_END_OF_STREAM}, {@link #BUFFER_FLAG_LAST_SAMPLE}, + * {@link #BUFFER_FLAG_ENCRYPTED} and {@link #BUFFER_FLAG_DECODE_ONLY}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + BUFFER_FLAG_KEY_FRAME, + BUFFER_FLAG_END_OF_STREAM, + BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA, + BUFFER_FLAG_LAST_SAMPLE, + BUFFER_FLAG_ENCRYPTED, + BUFFER_FLAG_DECODE_ONLY + }) + public @interface BufferFlags {} + /** + * Indicates that a buffer holds a synchronization sample. + */ + public static final int BUFFER_FLAG_KEY_FRAME = MediaCodec.BUFFER_FLAG_KEY_FRAME; + /** + * Flag for empty buffers that signal that the end of the stream was reached. + */ + public static final int BUFFER_FLAG_END_OF_STREAM = MediaCodec.BUFFER_FLAG_END_OF_STREAM; + /** Indicates that a buffer has supplemental data. */ + public static final int BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA = 1 << 28; // 0x10000000 + /** Indicates that a buffer is known to contain the last media sample of the stream. */ + public static final int BUFFER_FLAG_LAST_SAMPLE = 1 << 29; // 0x20000000 + /** Indicates that a buffer is (at least partially) encrypted. */ + public static final int BUFFER_FLAG_ENCRYPTED = 1 << 30; // 0x40000000 + /** Indicates that a buffer should be decoded but not rendered. */ + public static final int BUFFER_FLAG_DECODE_ONLY = 1 << 31; // 0x80000000 + + // LINT.IfChange + /** + * Video decoder output modes. Possible modes are {@link #VIDEO_OUTPUT_MODE_NONE}, {@link + * #VIDEO_OUTPUT_MODE_YUV} and {@link #VIDEO_OUTPUT_MODE_SURFACE_YUV}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {VIDEO_OUTPUT_MODE_NONE, VIDEO_OUTPUT_MODE_YUV, VIDEO_OUTPUT_MODE_SURFACE_YUV}) + public @interface VideoOutputMode {} + /** Video decoder output mode is not set. */ + public static final int VIDEO_OUTPUT_MODE_NONE = -1; + /** Video decoder output mode that outputs raw 4:2:0 YUV planes. */ + public static final int VIDEO_OUTPUT_MODE_YUV = 0; + /** Video decoder output mode that renders 4:2:0 YUV planes directly to a surface. */ + public static final int VIDEO_OUTPUT_MODE_SURFACE_YUV = 1; + // LINT.ThenChange( + // ../../../../../../../../../extensions/av1/src/main/jni/gav1_jni.cc, + // ../../../../../../../../../extensions/vp9/src/main/jni/vpx_jni.cc + // ) + + /** + * Video scaling modes for {@link MediaCodec}-based {@link Renderer}s. One of {@link + * #VIDEO_SCALING_MODE_SCALE_TO_FIT} or {@link #VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {VIDEO_SCALING_MODE_SCALE_TO_FIT, VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING}) + public @interface VideoScalingMode {} + /** + * @see MediaCodec#VIDEO_SCALING_MODE_SCALE_TO_FIT + */ + public static final int VIDEO_SCALING_MODE_SCALE_TO_FIT = + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT; + /** + * @see MediaCodec#VIDEO_SCALING_MODE_SCALE_TO_FIT + */ + public static final int VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING = + MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT_WITH_CROPPING; + /** + * A default video scaling mode for {@link MediaCodec}-based {@link Renderer}s. + */ + public static final int VIDEO_SCALING_MODE_DEFAULT = VIDEO_SCALING_MODE_SCALE_TO_FIT; + + /** + * Track selection flags. Possible flag values are {@link #SELECTION_FLAG_DEFAULT}, {@link + * #SELECTION_FLAG_FORCED} and {@link #SELECTION_FLAG_AUTOSELECT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {SELECTION_FLAG_DEFAULT, SELECTION_FLAG_FORCED, SELECTION_FLAG_AUTOSELECT}) + public @interface SelectionFlags {} + /** + * Indicates that the track should be selected if user preferences do not state otherwise. + */ + public static final int SELECTION_FLAG_DEFAULT = 1; + /** Indicates that the track must be displayed. Only applies to text tracks. */ + public static final int SELECTION_FLAG_FORCED = 1 << 1; // 2 + /** + * Indicates that the player may choose to play the track in absence of an explicit user + * preference. + */ + public static final int SELECTION_FLAG_AUTOSELECT = 1 << 2; // 4 + + /** Represents an undetermined language as an ISO 639-2 language code. */ + public static final String LANGUAGE_UNDETERMINED = "und"; + + /** + * Represents a streaming or other media type. One of {@link #TYPE_DASH}, {@link #TYPE_SS}, {@link + * #TYPE_HLS} or {@link #TYPE_OTHER}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_DASH, TYPE_SS, TYPE_HLS, TYPE_OTHER}) + public @interface ContentType {} + /** + * Value returned by {@link Util#inferContentType(String)} for DASH manifests. + */ + public static final int TYPE_DASH = 0; + /** + * Value returned by {@link Util#inferContentType(String)} for Smooth Streaming manifests. + */ + public static final int TYPE_SS = 1; + /** + * Value returned by {@link Util#inferContentType(String)} for HLS manifests. + */ + public static final int TYPE_HLS = 2; + /** + * Value returned by {@link Util#inferContentType(String)} for files other than DASH, HLS or + * Smooth Streaming manifests. + */ + public static final int TYPE_OTHER = 3; + + /** + * A return value for methods where the end of an input was encountered. + */ + public static final int RESULT_END_OF_INPUT = -1; + /** + * A return value for methods where the length of parsed data exceeds the maximum length allowed. + */ + public static final int RESULT_MAX_LENGTH_EXCEEDED = -2; + /** + * A return value for methods where nothing was read. + */ + public static final int RESULT_NOTHING_READ = -3; + /** + * A return value for methods where a buffer was read. + */ + public static final int RESULT_BUFFER_READ = -4; + /** + * A return value for methods where a format was read. + */ + public static final int RESULT_FORMAT_READ = -5; + + /** A data type constant for data of unknown or unspecified type. */ + public static final int DATA_TYPE_UNKNOWN = 0; + /** A data type constant for media, typically containing media samples. */ + public static final int DATA_TYPE_MEDIA = 1; + /** A data type constant for media, typically containing only initialization data. */ + public static final int DATA_TYPE_MEDIA_INITIALIZATION = 2; + /** A data type constant for drm or encryption data. */ + public static final int DATA_TYPE_DRM = 3; + /** A data type constant for a manifest file. */ + public static final int DATA_TYPE_MANIFEST = 4; + /** A data type constant for time synchronization data. */ + public static final int DATA_TYPE_TIME_SYNCHRONIZATION = 5; + /** A data type constant for ads loader data. */ + public static final int DATA_TYPE_AD = 6; + /** + * A data type constant for live progressive media streams, typically containing media samples. + */ + public static final int DATA_TYPE_MEDIA_PROGRESSIVE_LIVE = 7; + /** + * Applications or extensions may define custom {@code DATA_TYPE_*} constants greater than or + * equal to this value. + */ + public static final int DATA_TYPE_CUSTOM_BASE = 10000; + + /** A type constant for tracks of unknown type. */ + public static final int TRACK_TYPE_UNKNOWN = -1; + /** A type constant for tracks of some default type, where the type itself is unknown. */ + public static final int TRACK_TYPE_DEFAULT = 0; + /** A type constant for audio tracks. */ + public static final int TRACK_TYPE_AUDIO = 1; + /** A type constant for video tracks. */ + public static final int TRACK_TYPE_VIDEO = 2; + /** A type constant for text tracks. */ + public static final int TRACK_TYPE_TEXT = 3; + /** A type constant for metadata tracks. */ + public static final int TRACK_TYPE_METADATA = 4; + /** A type constant for camera motion tracks. */ + public static final int TRACK_TYPE_CAMERA_MOTION = 5; + /** A type constant for a dummy or empty track. */ + public static final int TRACK_TYPE_NONE = 6; + /** + * Applications or extensions may define custom {@code TRACK_TYPE_*} constants greater than or + * equal to this value. + */ + public static final int TRACK_TYPE_CUSTOM_BASE = 10000; + + /** + * A selection reason constant for selections whose reasons are unknown or unspecified. + */ + public static final int SELECTION_REASON_UNKNOWN = 0; + /** + * A selection reason constant for an initial track selection. + */ + public static final int SELECTION_REASON_INITIAL = 1; + /** + * A selection reason constant for an manual (i.e. user initiated) track selection. + */ + public static final int SELECTION_REASON_MANUAL = 2; + /** + * A selection reason constant for an adaptive track selection. + */ + public static final int SELECTION_REASON_ADAPTIVE = 3; + /** + * A selection reason constant for a trick play track selection. + */ + public static final int SELECTION_REASON_TRICK_PLAY = 4; + /** + * Applications or extensions may define custom {@code SELECTION_REASON_*} constants greater than + * or equal to this value. + */ + public static final int SELECTION_REASON_CUSTOM_BASE = 10000; + + /** A default size in bytes for an individual allocation that forms part of a larger buffer. */ + public static final int DEFAULT_BUFFER_SEGMENT_SIZE = 64 * 1024; + + /** "cenc" scheme type name as defined in ISO/IEC 23001-7:2016. */ + @SuppressWarnings("ConstantField") + public static final String CENC_TYPE_cenc = "cenc"; + + /** "cbc1" scheme type name as defined in ISO/IEC 23001-7:2016. */ + @SuppressWarnings("ConstantField") + public static final String CENC_TYPE_cbc1 = "cbc1"; + + /** "cens" scheme type name as defined in ISO/IEC 23001-7:2016. */ + @SuppressWarnings("ConstantField") + public static final String CENC_TYPE_cens = "cens"; + + /** "cbcs" scheme type name as defined in ISO/IEC 23001-7:2016. */ + @SuppressWarnings("ConstantField") + public static final String CENC_TYPE_cbcs = "cbcs"; + + /** + * The Nil UUID as defined by + * <a href="https://tools.ietf.org/html/rfc4122#section-4.1.7">RFC4122</a>. + */ + public static final UUID UUID_NIL = new UUID(0L, 0L); + + /** + * UUID for the W3C + * <a href="https://w3c.github.io/encrypted-media/format-registry/initdata/cenc.html">Common PSSH + * box</a>. + */ + public static final UUID COMMON_PSSH_UUID = new UUID(0x1077EFECC0B24D02L, 0xACE33C1E52E2FB4BL); + + /** + * UUID for the ClearKey DRM scheme. + * <p> + * ClearKey is supported on Android devices running Android 5.0 (API Level 21) and up. + */ + public static final UUID CLEARKEY_UUID = new UUID(0xE2719D58A985B3C9L, 0x781AB030AF78D30EL); + + /** + * UUID for the Widevine DRM scheme. + * <p> + * Widevine is supported on Android devices running Android 4.3 (API Level 18) and up. + */ + public static final UUID WIDEVINE_UUID = new UUID(0xEDEF8BA979D64ACEL, 0xA3C827DCD51D21EDL); + + /** + * UUID for the PlayReady DRM scheme. + * <p> + * PlayReady is supported on all AndroidTV devices. Note that most other Android devices do not + * provide PlayReady support. + */ + public static final UUID PLAYREADY_UUID = new UUID(0x9A04F07998404286L, 0xAB92E65BE0885F95L); + + /** + * The type of a message that can be passed to a video {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link Surface}, or + * null. + */ + public static final int MSG_SET_SURFACE = 1; + + /** + * A type of a message that can be passed to an audio {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be a {@link Float} with 0 being + * silence and 1 being unity gain. + */ + public static final int MSG_SET_VOLUME = 2; + + /** + * A type of a message that can be passed to an audio {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be an {@link + * org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes} instance that will configure the + * underlying audio track. If not set, the default audio attributes will be used. They are + * suitable for general media playback. + * + * <p>Setting the audio attributes during playback may introduce a short gap in audio output as + * the audio track is recreated. A new audio session id will also be generated. + * + * <p>If tunneling is enabled by the track selector, the specified audio attributes will be + * ignored, but they will take effect if audio is later played without tunneling. + * + * <p>If the device is running a build before platform API version 21, audio attributes cannot be + * set directly on the underlying audio track. In this case, the usage will be mapped onto an + * equivalent stream type using {@link Util#getStreamTypeForAudioUsage(int)}. + * + * <p>To get audio attributes that are equivalent to a legacy stream type, pass the stream type to + * {@link Util#getAudioUsageForStreamType(int)} and use the returned {@link C.AudioUsage} to build + * an audio attributes instance. + */ + public static final int MSG_SET_AUDIO_ATTRIBUTES = 3; + + /** + * The type of a message that can be passed to a {@link MediaCodec}-based video {@link Renderer} + * via {@link ExoPlayer#createMessage(Target)}. The message payload should be one of the integer + * scaling modes in {@link C.VideoScalingMode}. + * + * <p>Note that the scaling mode only applies if the {@link Surface} targeted by the renderer is + * owned by a {@link android.view.SurfaceView}. + */ + public static final int MSG_SET_SCALING_MODE = 4; + + /** + * A type of a message that can be passed to an audio {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be an {@link AuxEffectInfo} + * instance representing an auxiliary audio effect for the underlying audio track. + */ + public static final int MSG_SET_AUX_EFFECT_INFO = 5; + + /** + * The type of a message that can be passed to a video {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be a {@link + * VideoFrameMetadataListener} instance, or null. + */ + public static final int MSG_SET_VIDEO_FRAME_METADATA_LISTENER = 6; + + /** + * The type of a message that can be passed to a camera motion {@link Renderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be a {@link CameraMotionListener} + * instance, or null. + */ + public static final int MSG_SET_CAMERA_MOTION_LISTENER = 7; + + /** + * The type of a message that can be passed to a {@link SimpleDecoderVideoRenderer} via {@link + * ExoPlayer#createMessage(Target)}. The message payload should be the target {@link + * VideoDecoderOutputBufferRenderer}, or null. + * + * <p>This message is intended only for use with extension renderers that expect a {@link + * VideoDecoderOutputBufferRenderer}. For other use cases, an output surface should be passed via + * {@link #MSG_SET_SURFACE} instead. + */ + public static final int MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER = 8; + + /** + * Applications or extensions may define custom {@code MSG_*} constants that can be passed to + * {@link Renderer}s. These custom constants must be greater than or equal to this value. + */ + public static final int MSG_CUSTOM_BASE = 10000; + + /** + * The stereo mode for 360/3D/VR videos. One of {@link Format#NO_VALUE}, {@link + * #STEREO_MODE_MONO}, {@link #STEREO_MODE_TOP_BOTTOM}, {@link #STEREO_MODE_LEFT_RIGHT} or {@link + * #STEREO_MODE_STEREO_MESH}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Format.NO_VALUE, + STEREO_MODE_MONO, + STEREO_MODE_TOP_BOTTOM, + STEREO_MODE_LEFT_RIGHT, + STEREO_MODE_STEREO_MESH + }) + public @interface StereoMode {} + /** + * Indicates Monoscopic stereo layout, used with 360/3D/VR videos. + */ + public static final int STEREO_MODE_MONO = 0; + /** + * Indicates Top-Bottom stereo layout, used with 360/3D/VR videos. + */ + public static final int STEREO_MODE_TOP_BOTTOM = 1; + /** + * Indicates Left-Right stereo layout, used with 360/3D/VR videos. + */ + public static final int STEREO_MODE_LEFT_RIGHT = 2; + /** + * Indicates a stereo layout where the left and right eyes have separate meshes, + * used with 360/3D/VR videos. + */ + public static final int STEREO_MODE_STEREO_MESH = 3; + + /** + * Video colorspaces. One of {@link Format#NO_VALUE}, {@link #COLOR_SPACE_BT709}, {@link + * #COLOR_SPACE_BT601} or {@link #COLOR_SPACE_BT2020}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({Format.NO_VALUE, COLOR_SPACE_BT709, COLOR_SPACE_BT601, COLOR_SPACE_BT2020}) + public @interface ColorSpace {} + /** + * @see MediaFormat#COLOR_STANDARD_BT709 + */ + public static final int COLOR_SPACE_BT709 = MediaFormat.COLOR_STANDARD_BT709; + /** + * @see MediaFormat#COLOR_STANDARD_BT601_PAL + */ + public static final int COLOR_SPACE_BT601 = MediaFormat.COLOR_STANDARD_BT601_PAL; + /** + * @see MediaFormat#COLOR_STANDARD_BT2020 + */ + public static final int COLOR_SPACE_BT2020 = MediaFormat.COLOR_STANDARD_BT2020; + + /** + * Video color transfer characteristics. One of {@link Format#NO_VALUE}, {@link + * #COLOR_TRANSFER_SDR}, {@link #COLOR_TRANSFER_ST2084} or {@link #COLOR_TRANSFER_HLG}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({Format.NO_VALUE, COLOR_TRANSFER_SDR, COLOR_TRANSFER_ST2084, COLOR_TRANSFER_HLG}) + public @interface ColorTransfer {} + /** + * @see MediaFormat#COLOR_TRANSFER_SDR_VIDEO + */ + public static final int COLOR_TRANSFER_SDR = MediaFormat.COLOR_TRANSFER_SDR_VIDEO; + /** + * @see MediaFormat#COLOR_TRANSFER_ST2084 + */ + public static final int COLOR_TRANSFER_ST2084 = MediaFormat.COLOR_TRANSFER_ST2084; + /** + * @see MediaFormat#COLOR_TRANSFER_HLG + */ + public static final int COLOR_TRANSFER_HLG = MediaFormat.COLOR_TRANSFER_HLG; + + /** + * Video color range. One of {@link Format#NO_VALUE}, {@link #COLOR_RANGE_LIMITED} or {@link + * #COLOR_RANGE_FULL}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({Format.NO_VALUE, COLOR_RANGE_LIMITED, COLOR_RANGE_FULL}) + public @interface ColorRange {} + /** + * @see MediaFormat#COLOR_RANGE_LIMITED + */ + public static final int COLOR_RANGE_LIMITED = MediaFormat.COLOR_RANGE_LIMITED; + /** + * @see MediaFormat#COLOR_RANGE_FULL + */ + public static final int COLOR_RANGE_FULL = MediaFormat.COLOR_RANGE_FULL; + + /** Video projection types. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Format.NO_VALUE, + PROJECTION_RECTANGULAR, + PROJECTION_EQUIRECTANGULAR, + PROJECTION_CUBEMAP, + PROJECTION_MESH + }) + public @interface Projection {} + /** Conventional rectangular projection. */ + public static final int PROJECTION_RECTANGULAR = 0; + /** Equirectangular spherical projection. */ + public static final int PROJECTION_EQUIRECTANGULAR = 1; + /** Cube map projection. */ + public static final int PROJECTION_CUBEMAP = 2; + /** 3-D mesh projection. */ + public static final int PROJECTION_MESH = 3; + + /** + * Priority for media playback. + * + * <p>Larger values indicate higher priorities. + */ + public static final int PRIORITY_PLAYBACK = 0; + + /** + * Priority for media downloading. + * + * <p>Larger values indicate higher priorities. + */ + public static final int PRIORITY_DOWNLOAD = PRIORITY_PLAYBACK - 1000; + + /** + * Network connection type. One of {@link #NETWORK_TYPE_UNKNOWN}, {@link #NETWORK_TYPE_OFFLINE}, + * {@link #NETWORK_TYPE_WIFI}, {@link #NETWORK_TYPE_2G}, {@link #NETWORK_TYPE_3G}, {@link + * #NETWORK_TYPE_4G}, {@link #NETWORK_TYPE_5G}, {@link #NETWORK_TYPE_CELLULAR_UNKNOWN}, {@link + * #NETWORK_TYPE_ETHERNET} or {@link #NETWORK_TYPE_OTHER}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + NETWORK_TYPE_UNKNOWN, + NETWORK_TYPE_OFFLINE, + NETWORK_TYPE_WIFI, + NETWORK_TYPE_2G, + NETWORK_TYPE_3G, + NETWORK_TYPE_4G, + NETWORK_TYPE_5G, + NETWORK_TYPE_CELLULAR_UNKNOWN, + NETWORK_TYPE_ETHERNET, + NETWORK_TYPE_OTHER + }) + public @interface NetworkType {} + /** Unknown network type. */ + public static final int NETWORK_TYPE_UNKNOWN = 0; + /** No network connection. */ + public static final int NETWORK_TYPE_OFFLINE = 1; + /** Network type for a Wifi connection. */ + public static final int NETWORK_TYPE_WIFI = 2; + /** Network type for a 2G cellular connection. */ + public static final int NETWORK_TYPE_2G = 3; + /** Network type for a 3G cellular connection. */ + public static final int NETWORK_TYPE_3G = 4; + /** Network type for a 4G cellular connection. */ + public static final int NETWORK_TYPE_4G = 5; + /** Network type for a 5G cellular connection. */ + public static final int NETWORK_TYPE_5G = 9; + /** + * Network type for cellular connections which cannot be mapped to one of {@link + * #NETWORK_TYPE_2G}, {@link #NETWORK_TYPE_3G}, or {@link #NETWORK_TYPE_4G}. + */ + public static final int NETWORK_TYPE_CELLULAR_UNKNOWN = 6; + /** Network type for an Ethernet connection. */ + public static final int NETWORK_TYPE_ETHERNET = 7; + /** Network type for other connections which are not Wifi or cellular (e.g. VPN, Bluetooth). */ + public static final int NETWORK_TYPE_OTHER = 8; + + /** + * Mode specifying whether the player should hold a WakeLock and a WifiLock. One of {@link + * #WAKE_MODE_NONE}, {@link #WAKE_MODE_LOCAL} and {@link #WAKE_MODE_NETWORK}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({WAKE_MODE_NONE, WAKE_MODE_LOCAL, WAKE_MODE_NETWORK}) + public @interface WakeMode {} + /** + * A wake mode that will not cause the player to hold any locks. + * + * <p>This is suitable for applications that do not play media with the screen off. + */ + public static final int WAKE_MODE_NONE = 0; + /** + * A wake mode that will cause the player to hold a {@link android.os.PowerManager.WakeLock} + * during playback. + * + * <p>This is suitable for applications that play media with the screen off and do not load media + * over wifi. + */ + public static final int WAKE_MODE_LOCAL = 1; + /** + * A wake mode that will cause the player to hold a {@link android.os.PowerManager.WakeLock} and a + * {@link android.net.wifi.WifiManager.WifiLock} during playback. + * + * <p>This is suitable for applications that play media with the screen off and may load media + * over wifi. + */ + public static final int WAKE_MODE_NETWORK = 2; + + /** + * Track role flags. Possible flag values are {@link #ROLE_FLAG_MAIN}, {@link + * #ROLE_FLAG_ALTERNATE}, {@link #ROLE_FLAG_SUPPLEMENTARY}, {@link #ROLE_FLAG_COMMENTARY}, {@link + * #ROLE_FLAG_DUB}, {@link #ROLE_FLAG_EMERGENCY}, {@link #ROLE_FLAG_CAPTION}, {@link + * #ROLE_FLAG_SUBTITLE}, {@link #ROLE_FLAG_SIGN}, {@link #ROLE_FLAG_DESCRIBES_VIDEO}, {@link + * #ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND}, {@link #ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY}, + * {@link #ROLE_FLAG_TRANSCRIBES_DIALOG} and {@link #ROLE_FLAG_EASY_TO_READ}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + ROLE_FLAG_MAIN, + ROLE_FLAG_ALTERNATE, + ROLE_FLAG_SUPPLEMENTARY, + ROLE_FLAG_COMMENTARY, + ROLE_FLAG_DUB, + ROLE_FLAG_EMERGENCY, + ROLE_FLAG_CAPTION, + ROLE_FLAG_SUBTITLE, + ROLE_FLAG_SIGN, + ROLE_FLAG_DESCRIBES_VIDEO, + ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND, + ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY, + ROLE_FLAG_TRANSCRIBES_DIALOG, + ROLE_FLAG_EASY_TO_READ + }) + public @interface RoleFlags {} + /** Indicates a main track. */ + public static final int ROLE_FLAG_MAIN = 1; + /** + * Indicates an alternate track. For example a video track recorded from an different view point + * than the main track(s). + */ + public static final int ROLE_FLAG_ALTERNATE = 1 << 1; + /** + * Indicates a supplementary track, meaning the track has lower importance than the main track(s). + * For example a video track that provides a visual accompaniment to a main audio track. + */ + public static final int ROLE_FLAG_SUPPLEMENTARY = 1 << 2; + /** Indicates the track contains commentary, for example from the director. */ + public static final int ROLE_FLAG_COMMENTARY = 1 << 3; + /** + * Indicates the track is in a different language from the original, for example dubbed audio or + * translated captions. + */ + public static final int ROLE_FLAG_DUB = 1 << 4; + /** Indicates the track contains information about a current emergency. */ + public static final int ROLE_FLAG_EMERGENCY = 1 << 5; + /** + * Indicates the track contains captions. This flag may be set on video tracks to indicate the + * presence of burned in captions. + */ + public static final int ROLE_FLAG_CAPTION = 1 << 6; + /** + * Indicates the track contains subtitles. This flag may be set on video tracks to indicate the + * presence of burned in subtitles. + */ + public static final int ROLE_FLAG_SUBTITLE = 1 << 7; + /** Indicates the track contains a visual sign-language interpretation of an audio track. */ + public static final int ROLE_FLAG_SIGN = 1 << 8; + /** Indicates the track contains an audio or textual description of a video track. */ + public static final int ROLE_FLAG_DESCRIBES_VIDEO = 1 << 9; + /** Indicates the track contains a textual description of music and sound. */ + public static final int ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND = 1 << 10; + /** Indicates the track is designed for improved intelligibility of dialogue. */ + public static final int ROLE_FLAG_ENHANCED_DIALOG_INTELLIGIBILITY = 1 << 11; + /** Indicates the track contains a transcription of spoken dialog. */ + public static final int ROLE_FLAG_TRANSCRIBES_DIALOG = 1 << 12; + /** Indicates the track contains a text that has been edited for ease of reading. */ + public static final int ROLE_FLAG_EASY_TO_READ = 1 << 13; + + /** + * Converts a time in microseconds to the corresponding time in milliseconds, preserving + * {@link #TIME_UNSET} and {@link #TIME_END_OF_SOURCE} values. + * + * @param timeUs The time in microseconds. + * @return The corresponding time in milliseconds. + */ + public static long usToMs(long timeUs) { + return (timeUs == TIME_UNSET || timeUs == TIME_END_OF_SOURCE) ? timeUs : (timeUs / 1000); + } + + /** + * Converts a time in milliseconds to the corresponding time in microseconds, preserving + * {@link #TIME_UNSET} values and {@link #TIME_END_OF_SOURCE} values. + * + * @param timeMs The time in milliseconds. + * @return The corresponding time in microseconds. + */ + public static long msToUs(long timeMs) { + return (timeMs == TIME_UNSET || timeMs == TIME_END_OF_SOURCE) ? timeMs : (timeMs * 1000); + } + + /** + * Returns a newly generated audio session identifier, or {@link AudioManager#ERROR} if an error + * occurred in which case audio playback may fail. + * + * @see AudioManager#generateAudioSessionId() + */ + @TargetApi(21) + public static int generateAudioSessionIdV21(Context context) { + return ((AudioManager) context.getSystemService(Context.AUDIO_SERVICE)) + .generateAudioSessionId(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ControlDispatcher.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ControlDispatcher.java new file mode 100644 index 0000000000..a23b44e685 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ControlDispatcher.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.RepeatMode; + +/** + * Dispatches operations to the {@link Player}. + * <p> + * Implementations may choose to suppress (e.g. prevent playback from resuming if audio focus is + * denied) or modify (e.g. change the seek position to prevent a user from seeking past a + * non-skippable advert) operations. + */ +public interface ControlDispatcher { + + /** + * Dispatches a {@link Player#setPlayWhenReady(boolean)} operation. + * + * @param player The {@link Player} to which the operation should be dispatched. + * @param playWhenReady Whether playback should proceed when ready. + * @return True if the operation was dispatched. False if suppressed. + */ + boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady); + + /** + * Dispatches a {@link Player#seekTo(int, long)} operation. + * + * @param player The {@link Player} to which the operation should be dispatched. + * @param windowIndex The index of the window. + * @param positionMs The seek position in the specified window, or {@link C#TIME_UNSET} to seek to + * the window's default position. + * @return True if the operation was dispatched. False if suppressed. + */ + boolean dispatchSeekTo(Player player, int windowIndex, long positionMs); + + /** + * Dispatches a {@link Player#setRepeatMode(int)} operation. + * + * @param player The {@link Player} to which the operation should be dispatched. + * @param repeatMode The repeat mode. + * @return True if the operation was dispatched. False if suppressed. + */ + boolean dispatchSetRepeatMode(Player player, @RepeatMode int repeatMode); + + /** + * Dispatches a {@link Player#setShuffleModeEnabled(boolean)} operation. + * + * @param player The {@link Player} to which the operation should be dispatched. + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return True if the operation was dispatched. False if suppressed. + */ + boolean dispatchSetShuffleModeEnabled(Player player, boolean shuffleModeEnabled); + + /** + * Dispatches a {@link Player#stop()} operation. + * + * @param player The {@link Player} to which the operation should be dispatched. + * @param reset Whether the player should be reset. + * @return True if the operation was dispatched. False if suppressed. + */ + boolean dispatchStop(Player player, boolean reset); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultControlDispatcher.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultControlDispatcher.java new file mode 100644 index 0000000000..32fa0edf6e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultControlDispatcher.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.RepeatMode; + +/** + * Default {@link ControlDispatcher} that dispatches all operations to the player without + * modification. + */ +public class DefaultControlDispatcher implements ControlDispatcher { + + @Override + public boolean dispatchSetPlayWhenReady(Player player, boolean playWhenReady) { + player.setPlayWhenReady(playWhenReady); + return true; + } + + @Override + public boolean dispatchSeekTo(Player player, int windowIndex, long positionMs) { + player.seekTo(windowIndex, positionMs); + return true; + } + + @Override + public boolean dispatchSetRepeatMode(Player player, @RepeatMode int repeatMode) { + player.setRepeatMode(repeatMode); + return true; + } + + @Override + public boolean dispatchSetShuffleModeEnabled(Player player, boolean shuffleModeEnabled) { + player.setShuffleModeEnabled(shuffleModeEnabled); + return true; + } + + @Override + public boolean dispatchStop(Player player, boolean reset) { + player.stop(reset); + return true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultLoadControl.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultLoadControl.java new file mode 100644 index 0000000000..ad5350a722 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultLoadControl.java @@ -0,0 +1,473 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * The default {@link LoadControl} implementation. + */ +public class DefaultLoadControl implements LoadControl { + + /** + * The default minimum duration of media that the player will attempt to ensure is buffered at all + * times, in milliseconds. This value is only applied to playbacks without video. + */ + public static final int DEFAULT_MIN_BUFFER_MS = 15000; + + /** + * The default maximum duration of media that the player will attempt to buffer, in milliseconds. + * For playbacks with video, this is also the default minimum duration of media that the player + * will attempt to ensure is buffered. + */ + public static final int DEFAULT_MAX_BUFFER_MS = 50000; + + /** + * The default duration of media that must be buffered for playback to start or resume following a + * user action such as a seek, in milliseconds. + */ + public static final int DEFAULT_BUFFER_FOR_PLAYBACK_MS = 2500; + + /** + * The default duration of media that must be buffered for playback to resume after a rebuffer, in + * milliseconds. A rebuffer is defined to be caused by buffer depletion rather than a user action. + */ + public static final int DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = 5000; + + /** + * The default target buffer size in bytes. The value ({@link C#LENGTH_UNSET}) means that the load + * control will calculate the target buffer size based on the selected tracks. + */ + public static final int DEFAULT_TARGET_BUFFER_BYTES = C.LENGTH_UNSET; + + /** The default prioritization of buffer time constraints over size constraints. */ + public static final boolean DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS = true; + + /** The default back buffer duration in milliseconds. */ + public static final int DEFAULT_BACK_BUFFER_DURATION_MS = 0; + + /** The default for whether the back buffer is retained from the previous keyframe. */ + public static final boolean DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME = false; + + /** A default size in bytes for a video buffer. */ + public static final int DEFAULT_VIDEO_BUFFER_SIZE = 500 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for an audio buffer. */ + public static final int DEFAULT_AUDIO_BUFFER_SIZE = 54 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a text buffer. */ + public static final int DEFAULT_TEXT_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a metadata buffer. */ + public static final int DEFAULT_METADATA_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a camera motion buffer. */ + public static final int DEFAULT_CAMERA_MOTION_BUFFER_SIZE = 2 * C.DEFAULT_BUFFER_SEGMENT_SIZE; + + /** A default size in bytes for a muxed buffer (e.g. containing video, audio and text). */ + public static final int DEFAULT_MUXED_BUFFER_SIZE = + DEFAULT_VIDEO_BUFFER_SIZE + DEFAULT_AUDIO_BUFFER_SIZE + DEFAULT_TEXT_BUFFER_SIZE; + + /** Builder for {@link DefaultLoadControl}. */ + public static final class Builder { + + private DefaultAllocator allocator; + private int minBufferAudioMs; + private int minBufferVideoMs; + private int maxBufferMs; + private int bufferForPlaybackMs; + private int bufferForPlaybackAfterRebufferMs; + private int targetBufferBytes; + private boolean prioritizeTimeOverSizeThresholds; + private int backBufferDurationMs; + private boolean retainBackBufferFromKeyframe; + private boolean createDefaultLoadControlCalled; + + /** Constructs a new instance. */ + public Builder() { + minBufferAudioMs = DEFAULT_MIN_BUFFER_MS; + minBufferVideoMs = DEFAULT_MAX_BUFFER_MS; + maxBufferMs = DEFAULT_MAX_BUFFER_MS; + bufferForPlaybackMs = DEFAULT_BUFFER_FOR_PLAYBACK_MS; + bufferForPlaybackAfterRebufferMs = DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; + targetBufferBytes = DEFAULT_TARGET_BUFFER_BYTES; + prioritizeTimeOverSizeThresholds = DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS; + backBufferDurationMs = DEFAULT_BACK_BUFFER_DURATION_MS; + retainBackBufferFromKeyframe = DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME; + } + + /** + * Sets the {@link DefaultAllocator} used by the loader. + * + * @param allocator The {@link DefaultAllocator}. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + */ + public Builder setAllocator(DefaultAllocator allocator) { + Assertions.checkState(!createDefaultLoadControlCalled); + this.allocator = allocator; + return this; + } + + /** + * Sets the buffer duration parameters. + * + * @param minBufferMs The minimum duration of media that the player will attempt to ensure is + * buffered at all times, in milliseconds. + * @param maxBufferMs The maximum duration of media that the player will attempt to buffer, in + * milliseconds. + * @param bufferForPlaybackMs The duration of media that must be buffered for playback to start + * or resume following a user action such as a seek, in milliseconds. + * @param bufferForPlaybackAfterRebufferMs The default duration of media that must be buffered + * for playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be + * caused by buffer depletion rather than a user action. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + */ + public Builder setBufferDurationsMs( + int minBufferMs, + int maxBufferMs, + int bufferForPlaybackMs, + int bufferForPlaybackAfterRebufferMs) { + Assertions.checkState(!createDefaultLoadControlCalled); + assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0"); + assertGreaterOrEqual( + bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0"); + assertGreaterOrEqual(minBufferMs, bufferForPlaybackMs, "minBufferMs", "bufferForPlaybackMs"); + assertGreaterOrEqual( + minBufferMs, + bufferForPlaybackAfterRebufferMs, + "minBufferMs", + "bufferForPlaybackAfterRebufferMs"); + assertGreaterOrEqual(maxBufferMs, minBufferMs, "maxBufferMs", "minBufferMs"); + this.minBufferAudioMs = minBufferMs; + this.minBufferVideoMs = minBufferMs; + this.maxBufferMs = maxBufferMs; + this.bufferForPlaybackMs = bufferForPlaybackMs; + this.bufferForPlaybackAfterRebufferMs = bufferForPlaybackAfterRebufferMs; + return this; + } + + /** + * Sets the target buffer size in bytes. If set to {@link C#LENGTH_UNSET}, the target buffer + * size will be calculated based on the selected tracks. + * + * @param targetBufferBytes The target buffer size in bytes. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + */ + public Builder setTargetBufferBytes(int targetBufferBytes) { + Assertions.checkState(!createDefaultLoadControlCalled); + this.targetBufferBytes = targetBufferBytes; + return this; + } + + /** + * Sets whether the load control prioritizes buffer time constraints over buffer size + * constraints. + * + * @param prioritizeTimeOverSizeThresholds Whether the load control prioritizes buffer time + * constraints over buffer size constraints. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + */ + public Builder setPrioritizeTimeOverSizeThresholds(boolean prioritizeTimeOverSizeThresholds) { + Assertions.checkState(!createDefaultLoadControlCalled); + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; + return this; + } + + /** + * Sets the back buffer duration, and whether the back buffer is retained from the previous + * keyframe. + * + * @param backBufferDurationMs The back buffer duration in milliseconds. + * @param retainBackBufferFromKeyframe Whether the back buffer is retained from the previous + * keyframe. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #createDefaultLoadControl()} has already been called. + */ + public Builder setBackBuffer(int backBufferDurationMs, boolean retainBackBufferFromKeyframe) { + Assertions.checkState(!createDefaultLoadControlCalled); + assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0"); + this.backBufferDurationMs = backBufferDurationMs; + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; + return this; + } + + /** Creates a {@link DefaultLoadControl}. */ + public DefaultLoadControl createDefaultLoadControl() { + Assertions.checkState(!createDefaultLoadControlCalled); + createDefaultLoadControlCalled = true; + if (allocator == null) { + allocator = new DefaultAllocator(/* trimOnReset= */ true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + } + return new DefaultLoadControl( + allocator, + minBufferAudioMs, + minBufferVideoMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs, + targetBufferBytes, + prioritizeTimeOverSizeThresholds, + backBufferDurationMs, + retainBackBufferFromKeyframe); + } + } + + private final DefaultAllocator allocator; + + private final long minBufferAudioUs; + private final long minBufferVideoUs; + private final long maxBufferUs; + private final long bufferForPlaybackUs; + private final long bufferForPlaybackAfterRebufferUs; + private final int targetBufferBytesOverwrite; + private final boolean prioritizeTimeOverSizeThresholds; + private final long backBufferDurationUs; + private final boolean retainBackBufferFromKeyframe; + + private int targetBufferSize; + private boolean isBuffering; + private boolean hasVideo; + + /** Constructs a new instance, using the {@code DEFAULT_*} constants defined in this class. */ + @SuppressWarnings("deprecation") + public DefaultLoadControl() { + this(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)); + } + + /** @deprecated Use {@link Builder} instead. */ + @Deprecated + public DefaultLoadControl(DefaultAllocator allocator) { + this( + allocator, + /* minBufferAudioMs= */ DEFAULT_MIN_BUFFER_MS, + /* minBufferVideoMs= */ DEFAULT_MAX_BUFFER_MS, + DEFAULT_MAX_BUFFER_MS, + DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS, + DEFAULT_TARGET_BUFFER_BYTES, + DEFAULT_PRIORITIZE_TIME_OVER_SIZE_THRESHOLDS, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); + } + + /** @deprecated Use {@link Builder} instead. */ + @Deprecated + public DefaultLoadControl( + DefaultAllocator allocator, + int minBufferMs, + int maxBufferMs, + int bufferForPlaybackMs, + int bufferForPlaybackAfterRebufferMs, + int targetBufferBytes, + boolean prioritizeTimeOverSizeThresholds) { + this( + allocator, + /* minBufferAudioMs= */ minBufferMs, + /* minBufferVideoMs= */ minBufferMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs, + targetBufferBytes, + prioritizeTimeOverSizeThresholds, + DEFAULT_BACK_BUFFER_DURATION_MS, + DEFAULT_RETAIN_BACK_BUFFER_FROM_KEYFRAME); + } + + protected DefaultLoadControl( + DefaultAllocator allocator, + int minBufferAudioMs, + int minBufferVideoMs, + int maxBufferMs, + int bufferForPlaybackMs, + int bufferForPlaybackAfterRebufferMs, + int targetBufferBytes, + boolean prioritizeTimeOverSizeThresholds, + int backBufferDurationMs, + boolean retainBackBufferFromKeyframe) { + assertGreaterOrEqual(bufferForPlaybackMs, 0, "bufferForPlaybackMs", "0"); + assertGreaterOrEqual( + bufferForPlaybackAfterRebufferMs, 0, "bufferForPlaybackAfterRebufferMs", "0"); + assertGreaterOrEqual( + minBufferAudioMs, bufferForPlaybackMs, "minBufferAudioMs", "bufferForPlaybackMs"); + assertGreaterOrEqual( + minBufferVideoMs, bufferForPlaybackMs, "minBufferVideoMs", "bufferForPlaybackMs"); + assertGreaterOrEqual( + minBufferAudioMs, + bufferForPlaybackAfterRebufferMs, + "minBufferAudioMs", + "bufferForPlaybackAfterRebufferMs"); + assertGreaterOrEqual( + minBufferVideoMs, + bufferForPlaybackAfterRebufferMs, + "minBufferVideoMs", + "bufferForPlaybackAfterRebufferMs"); + assertGreaterOrEqual(maxBufferMs, minBufferAudioMs, "maxBufferMs", "minBufferAudioMs"); + assertGreaterOrEqual(maxBufferMs, minBufferVideoMs, "maxBufferMs", "minBufferVideoMs"); + assertGreaterOrEqual(backBufferDurationMs, 0, "backBufferDurationMs", "0"); + + this.allocator = allocator; + this.minBufferAudioUs = C.msToUs(minBufferAudioMs); + this.minBufferVideoUs = C.msToUs(minBufferVideoMs); + this.maxBufferUs = C.msToUs(maxBufferMs); + this.bufferForPlaybackUs = C.msToUs(bufferForPlaybackMs); + this.bufferForPlaybackAfterRebufferUs = C.msToUs(bufferForPlaybackAfterRebufferMs); + this.targetBufferBytesOverwrite = targetBufferBytes; + this.prioritizeTimeOverSizeThresholds = prioritizeTimeOverSizeThresholds; + this.backBufferDurationUs = C.msToUs(backBufferDurationMs); + this.retainBackBufferFromKeyframe = retainBackBufferFromKeyframe; + } + + @Override + public void onPrepared() { + reset(false); + } + + @Override + public void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroups, + TrackSelectionArray trackSelections) { + hasVideo = hasVideo(renderers, trackSelections); + targetBufferSize = + targetBufferBytesOverwrite == C.LENGTH_UNSET + ? calculateTargetBufferSize(renderers, trackSelections) + : targetBufferBytesOverwrite; + allocator.setTargetBufferSize(targetBufferSize); + } + + @Override + public void onStopped() { + reset(true); + } + + @Override + public void onReleased() { + reset(true); + } + + @Override + public Allocator getAllocator() { + return allocator; + } + + @Override + public long getBackBufferDurationUs() { + return backBufferDurationUs; + } + + @Override + public boolean retainBackBufferFromKeyframe() { + return retainBackBufferFromKeyframe; + } + + @Override + public boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed) { + boolean targetBufferSizeReached = allocator.getTotalBytesAllocated() >= targetBufferSize; + long minBufferUs = hasVideo ? minBufferVideoUs : minBufferAudioUs; + if (playbackSpeed > 1) { + // The playback speed is faster than real time, so scale up the minimum required media + // duration to keep enough media buffered for a playout duration of minBufferUs. + long mediaDurationMinBufferUs = + Util.getMediaDurationForPlayoutDuration(minBufferUs, playbackSpeed); + minBufferUs = Math.min(mediaDurationMinBufferUs, maxBufferUs); + } + if (bufferedDurationUs < minBufferUs) { + isBuffering = prioritizeTimeOverSizeThresholds || !targetBufferSizeReached; + } else if (bufferedDurationUs >= maxBufferUs || targetBufferSizeReached) { + isBuffering = false; + } // Else don't change the buffering state + return isBuffering; + } + + @Override + public boolean shouldStartPlayback( + long bufferedDurationUs, float playbackSpeed, boolean rebuffering) { + bufferedDurationUs = Util.getPlayoutDurationForMediaDuration(bufferedDurationUs, playbackSpeed); + long minBufferDurationUs = rebuffering ? bufferForPlaybackAfterRebufferUs : bufferForPlaybackUs; + return minBufferDurationUs <= 0 + || bufferedDurationUs >= minBufferDurationUs + || (!prioritizeTimeOverSizeThresholds + && allocator.getTotalBytesAllocated() >= targetBufferSize); + } + + /** + * Calculate target buffer size in bytes based on the selected tracks. The player will try not to + * exceed this target buffer. Only used when {@code targetBufferBytes} is {@link C#LENGTH_UNSET}. + * + * @param renderers The renderers for which the track were selected. + * @param trackSelectionArray The selected tracks. + * @return The target buffer size in bytes. + */ + protected int calculateTargetBufferSize( + Renderer[] renderers, TrackSelectionArray trackSelectionArray) { + int targetBufferSize = 0; + for (int i = 0; i < renderers.length; i++) { + if (trackSelectionArray.get(i) != null) { + targetBufferSize += getDefaultBufferSize(renderers[i].getTrackType()); + } + } + return targetBufferSize; + } + + private void reset(boolean resetAllocator) { + targetBufferSize = 0; + isBuffering = false; + if (resetAllocator) { + allocator.reset(); + } + } + + private static int getDefaultBufferSize(int trackType) { + switch (trackType) { + case C.TRACK_TYPE_DEFAULT: + return DEFAULT_MUXED_BUFFER_SIZE; + case C.TRACK_TYPE_AUDIO: + return DEFAULT_AUDIO_BUFFER_SIZE; + case C.TRACK_TYPE_VIDEO: + return DEFAULT_VIDEO_BUFFER_SIZE; + case C.TRACK_TYPE_TEXT: + return DEFAULT_TEXT_BUFFER_SIZE; + case C.TRACK_TYPE_METADATA: + return DEFAULT_METADATA_BUFFER_SIZE; + case C.TRACK_TYPE_CAMERA_MOTION: + return DEFAULT_CAMERA_MOTION_BUFFER_SIZE; + case C.TRACK_TYPE_NONE: + return 0; + default: + throw new IllegalArgumentException(); + } + } + + private static boolean hasVideo(Renderer[] renderers, TrackSelectionArray trackSelectionArray) { + for (int i = 0; i < renderers.length; i++) { + if (renderers[i].getTrackType() == C.TRACK_TYPE_VIDEO && trackSelectionArray.get(i) != null) { + return true; + } + } + return false; + } + + private static void assertGreaterOrEqual(int value1, int value2, String name1, String name2) { + Assertions.checkArgument(value1 >= value2, name1 + " cannot be less than " + name2); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultMediaClock.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultMediaClock.java new file mode 100644 index 0000000000..9967bfeb9e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultMediaClock.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MediaClock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.StandaloneMediaClock; + +/** + * Default {@link MediaClock} which uses a renderer media clock and falls back to a + * {@link StandaloneMediaClock} if necessary. + */ +/* package */ final class DefaultMediaClock implements MediaClock { + + /** + * Listener interface to be notified of changes to the active playback parameters. + */ + public interface PlaybackParameterListener { + + /** + * Called when the active playback parameters changed. Will not be called for {@link + * #setPlaybackParameters(PlaybackParameters)}. + * + * @param newPlaybackParameters The newly active {@link PlaybackParameters}. + */ + void onPlaybackParametersChanged(PlaybackParameters newPlaybackParameters); + } + + private final StandaloneMediaClock standaloneClock; + private final PlaybackParameterListener listener; + + @Nullable private Renderer rendererClockSource; + @Nullable private MediaClock rendererClock; + private boolean isUsingStandaloneClock; + private boolean standaloneClockIsStarted; + + /** + * Creates a new instance with listener for playback parameter changes and a {@link Clock} to use + * for the standalone clock implementation. + * + * @param listener A {@link PlaybackParameterListener} to listen for playback parameter + * changes. + * @param clock A {@link Clock}. + */ + public DefaultMediaClock(PlaybackParameterListener listener, Clock clock) { + this.listener = listener; + this.standaloneClock = new StandaloneMediaClock(clock); + isUsingStandaloneClock = true; + } + + /** + * Starts the standalone fallback clock. + */ + public void start() { + standaloneClockIsStarted = true; + standaloneClock.start(); + } + + /** + * Stops the standalone fallback clock. + */ + public void stop() { + standaloneClockIsStarted = false; + standaloneClock.stop(); + } + + /** + * Resets the position of the standalone fallback clock. + * + * @param positionUs The position to set in microseconds. + */ + public void resetPosition(long positionUs) { + standaloneClock.resetPosition(positionUs); + } + + /** + * Notifies the media clock that a renderer has been enabled. Starts using the media clock of the + * provided renderer if available. + * + * @param renderer The renderer which has been enabled. + * @throws ExoPlaybackException If the renderer provides a media clock and another renderer media + * clock is already provided. + */ + public void onRendererEnabled(Renderer renderer) throws ExoPlaybackException { + MediaClock rendererMediaClock = renderer.getMediaClock(); + if (rendererMediaClock != null && rendererMediaClock != rendererClock) { + if (rendererClock != null) { + throw ExoPlaybackException.createForUnexpected( + new IllegalStateException("Multiple renderer media clocks enabled.")); + } + this.rendererClock = rendererMediaClock; + this.rendererClockSource = renderer; + rendererClock.setPlaybackParameters(standaloneClock.getPlaybackParameters()); + } + } + + /** + * Notifies the media clock that a renderer has been disabled. Stops using the media clock of this + * renderer if used. + * + * @param renderer The renderer which has been disabled. + */ + public void onRendererDisabled(Renderer renderer) { + if (renderer == rendererClockSource) { + this.rendererClock = null; + this.rendererClockSource = null; + isUsingStandaloneClock = true; + } + } + + /** + * Syncs internal clock if needed and returns current clock position in microseconds. + * + * @param isReadingAhead Whether the renderers are reading ahead. + */ + public long syncAndGetPositionUs(boolean isReadingAhead) { + syncClocks(isReadingAhead); + return getPositionUs(); + } + + // MediaClock implementation. + + @Override + public long getPositionUs() { + return isUsingStandaloneClock ? standaloneClock.getPositionUs() : rendererClock.getPositionUs(); + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + if (rendererClock != null) { + rendererClock.setPlaybackParameters(playbackParameters); + playbackParameters = rendererClock.getPlaybackParameters(); + } + standaloneClock.setPlaybackParameters(playbackParameters); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return rendererClock != null + ? rendererClock.getPlaybackParameters() + : standaloneClock.getPlaybackParameters(); + } + + private void syncClocks(boolean isReadingAhead) { + if (shouldUseStandaloneClock(isReadingAhead)) { + isUsingStandaloneClock = true; + if (standaloneClockIsStarted) { + standaloneClock.start(); + } + return; + } + long rendererClockPositionUs = rendererClock.getPositionUs(); + if (isUsingStandaloneClock) { + // Ensure enabling the renderer clock doesn't jump backwards in time. + if (rendererClockPositionUs < standaloneClock.getPositionUs()) { + standaloneClock.stop(); + return; + } + isUsingStandaloneClock = false; + if (standaloneClockIsStarted) { + standaloneClock.start(); + } + } + // Continuously sync stand-alone clock to renderer clock so that it can take over if needed. + standaloneClock.resetPosition(rendererClockPositionUs); + PlaybackParameters playbackParameters = rendererClock.getPlaybackParameters(); + if (!playbackParameters.equals(standaloneClock.getPlaybackParameters())) { + standaloneClock.setPlaybackParameters(playbackParameters); + listener.onPlaybackParametersChanged(playbackParameters); + } + } + + private boolean shouldUseStandaloneClock(boolean isReadingAhead) { + // Use the standalone clock if the clock providing renderer is not set or has ended. Also use + // the standalone clock if the renderer is not ready and we have finished reading the stream or + // are reading ahead to avoid getting stuck if tracks in the current period have uneven + // durations. See: https://github.com/google/ExoPlayer/issues/1874. + return rendererClockSource == null + || rendererClockSource.isEnded() + || (!rendererClockSource.isReady() + && (isReadingAhead || rendererClockSource.hasReadStreamToEnd())); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultRenderersFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultRenderersFactory.java new file mode 100644 index 0000000000..95fe509ee9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/DefaultRenderersFactory.java @@ -0,0 +1,580 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.content.Context; +import android.media.MediaCodec; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioProcessor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.DefaultAudioSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.TextOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.TextRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.MediaCodecVideoRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical.CameraMotionRenderer; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Constructor; +import java.util.ArrayList; + +/** + * Default {@link RenderersFactory} implementation. + */ +public class DefaultRenderersFactory implements RenderersFactory { + + /** + * The default maximum duration for which a video renderer can attempt to seamlessly join an + * ongoing playback. + */ + public static final long DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS = 5000; + + /** + * Modes for using extension renderers. One of {@link #EXTENSION_RENDERER_MODE_OFF}, {@link + * #EXTENSION_RENDERER_MODE_ON} or {@link #EXTENSION_RENDERER_MODE_PREFER}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({EXTENSION_RENDERER_MODE_OFF, EXTENSION_RENDERER_MODE_ON, EXTENSION_RENDERER_MODE_PREFER}) + public @interface ExtensionRendererMode {} + /** + * Do not allow use of extension renderers. + */ + public static final int EXTENSION_RENDERER_MODE_OFF = 0; + /** + * Allow use of extension renderers. Extension renderers are indexed after core renderers of the + * same type. A {@link TrackSelector} that prefers the first suitable renderer will therefore + * prefer to use a core renderer to an extension renderer in the case that both are able to play + * a given track. + */ + public static final int EXTENSION_RENDERER_MODE_ON = 1; + /** + * Allow use of extension renderers. Extension renderers are indexed before core renderers of the + * same type. A {@link TrackSelector} that prefers the first suitable renderer will therefore + * prefer to use an extension renderer to a core renderer in the case that both are able to play + * a given track. + */ + public static final int EXTENSION_RENDERER_MODE_PREFER = 2; + + private static final String TAG = "DefaultRenderersFactory"; + + protected static final int MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY = 50; + + private final Context context; + @Nullable private DrmSessionManager<FrameworkMediaCrypto> drmSessionManager; + @ExtensionRendererMode private int extensionRendererMode; + private long allowedVideoJoiningTimeMs; + private boolean playClearSamplesWithoutKeys; + private boolean enableDecoderFallback; + private MediaCodecSelector mediaCodecSelector; + + /** @param context A {@link Context}. */ + public DefaultRenderersFactory(Context context) { + this.context = context; + extensionRendererMode = EXTENSION_RENDERER_MODE_OFF; + allowedVideoJoiningTimeMs = DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS; + mediaCodecSelector = MediaCodecSelector.DEFAULT; + } + + /** + * @deprecated Use {@link #DefaultRenderersFactory(Context)} and pass {@link DrmSessionManager} + * directly to {@link SimpleExoPlayer.Builder}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public DefaultRenderersFactory( + Context context, @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) { + this(context, drmSessionManager, EXTENSION_RENDERER_MODE_OFF); + } + + /** + * @deprecated Use {@link #DefaultRenderersFactory(Context)} and {@link + * #setExtensionRendererMode(int)}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public DefaultRenderersFactory( + Context context, @ExtensionRendererMode int extensionRendererMode) { + this(context, extensionRendererMode, DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS); + } + + /** + * @deprecated Use {@link #DefaultRenderersFactory(Context)} and {@link + * #setExtensionRendererMode(int)}, and pass {@link DrmSessionManager} directly to {@link + * SimpleExoPlayer.Builder}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public DefaultRenderersFactory( + Context context, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + @ExtensionRendererMode int extensionRendererMode) { + this(context, drmSessionManager, extensionRendererMode, DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS); + } + + /** + * @deprecated Use {@link #DefaultRenderersFactory(Context)}, {@link + * #setExtensionRendererMode(int)} and {@link #setAllowedVideoJoiningTimeMs(long)}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public DefaultRenderersFactory( + Context context, + @ExtensionRendererMode int extensionRendererMode, + long allowedVideoJoiningTimeMs) { + this(context, null, extensionRendererMode, allowedVideoJoiningTimeMs); + } + + /** + * @deprecated Use {@link #DefaultRenderersFactory(Context)}, {@link + * #setExtensionRendererMode(int)} and {@link #setAllowedVideoJoiningTimeMs(long)}, and pass + * {@link DrmSessionManager} directly to {@link SimpleExoPlayer.Builder}. + */ + @Deprecated + public DefaultRenderersFactory( + Context context, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + @ExtensionRendererMode int extensionRendererMode, + long allowedVideoJoiningTimeMs) { + this.context = context; + this.extensionRendererMode = extensionRendererMode; + this.allowedVideoJoiningTimeMs = allowedVideoJoiningTimeMs; + this.drmSessionManager = drmSessionManager; + mediaCodecSelector = MediaCodecSelector.DEFAULT; + } + + /** + * Sets the extension renderer mode, which determines if and how available extension renderers are + * used. Note that extensions must be included in the application build for them to be considered + * available. + * + * <p>The default value is {@link #EXTENSION_RENDERER_MODE_OFF}. + * + * @param extensionRendererMode The extension renderer mode. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setExtensionRendererMode( + @ExtensionRendererMode int extensionRendererMode) { + this.extensionRendererMode = extensionRendererMode; + return this; + } + + /** + * Sets whether renderers are permitted to play clear regions of encrypted media prior to having + * obtained the keys necessary to decrypt encrypted regions of the media. For encrypted media that + * starts with a short clear region, this allows playback to begin in parallel with key + * acquisition, which can reduce startup latency. + * + * <p>The default value is {@code false}. + * + * @param playClearSamplesWithoutKeys Whether renderers are permitted to play clear regions of + * encrypted media prior to having obtained the keys necessary to decrypt encrypted regions of + * the media. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setPlayClearSamplesWithoutKeys( + boolean playClearSamplesWithoutKeys) { + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + return this; + } + + /** + * Sets whether to enable fallback to lower-priority decoders if decoder initialization fails. + * This may result in using a decoder that is less efficient or slower than the primary decoder. + * + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setEnableDecoderFallback(boolean enableDecoderFallback) { + this.enableDecoderFallback = enableDecoderFallback; + return this; + } + + /** + * Sets a {@link MediaCodecSelector} for use by {@link MediaCodec} based renderers. + * + * <p>The default value is {@link MediaCodecSelector#DEFAULT}. + * + * @param mediaCodecSelector The {@link MediaCodecSelector}. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setMediaCodecSelector(MediaCodecSelector mediaCodecSelector) { + this.mediaCodecSelector = mediaCodecSelector; + return this; + } + + /** + * Sets the maximum duration for which video renderers can attempt to seamlessly join an ongoing + * playback. + * + * <p>The default value is {@link #DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS}. + * + * @param allowedVideoJoiningTimeMs The maximum duration for which video renderers can attempt to + * seamlessly join an ongoing playback, in milliseconds. + * @return This factory, for convenience. + */ + public DefaultRenderersFactory setAllowedVideoJoiningTimeMs(long allowedVideoJoiningTimeMs) { + this.allowedVideoJoiningTimeMs = allowedVideoJoiningTimeMs; + return this; + } + + @Override + public Renderer[] createRenderers( + Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, + TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) { + if (drmSessionManager == null) { + drmSessionManager = this.drmSessionManager; + } + ArrayList<Renderer> renderersList = new ArrayList<>(); + buildVideoRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + enableDecoderFallback, + eventHandler, + videoRendererEventListener, + allowedVideoJoiningTimeMs, + renderersList); + buildAudioRenderers( + context, + extensionRendererMode, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + enableDecoderFallback, + buildAudioProcessors(), + eventHandler, + audioRendererEventListener, + renderersList); + buildTextRenderers(context, textRendererOutput, eventHandler.getLooper(), + extensionRendererMode, renderersList); + buildMetadataRenderers(context, metadataRendererOutput, eventHandler.getLooper(), + extensionRendererMode, renderersList); + buildCameraMotionRenderers(context, extensionRendererMode, renderersList); + buildMiscellaneousRenderers(context, eventHandler, extensionRendererMode, renderersList); + return renderersList.toArray(new Renderer[0]); + } + + /** + * Builds video renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param extensionRendererMode The extension renderer mode. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player will + * not be used for DRM protected playbacks. + * @param playClearSamplesWithoutKeys Whether renderers are permitted to play clear regions of + * encrypted media prior to having obtained the keys necessary to decrypt encrypted regions of + * the media. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is slower/less efficient than + * the primary decoder. + * @param eventHandler A handler associated with the main thread's looper. + * @param eventListener An event listener. + * @param allowedVideoJoiningTimeMs The maximum duration for which video renderers can attempt to + * seamlessly join an ongoing playback, in milliseconds. + * @param out An array to which the built renderers should be appended. + */ + protected void buildVideoRenderers( + Context context, + @ExtensionRendererMode int extensionRendererMode, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + boolean enableDecoderFallback, + Handler eventHandler, + VideoRendererEventListener eventListener, + long allowedVideoJoiningTimeMs, + ArrayList<Renderer> out) { + out.add( + new MediaCodecVideoRenderer( + context, + mediaCodecSelector, + allowedVideoJoiningTimeMs, + drmSessionManager, + playClearSamplesWithoutKeys, + enableDecoderFallback, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return; + } + int extensionRendererIndex = out.size(); + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex--; + } + + try { + // Full class names used for constructor args so the LINT rule triggers if any of them move. + // LINT.IfChange + Class<?> clazz = Class.forName("org.mozilla.thirdparty.com.google.android.exoplayer2.ext.vp9.LibvpxVideoRenderer"); + Constructor<?> constructor = + clazz.getConstructor( + long.class, + android.os.Handler.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener.class, + int.class); + // LINT.ThenChange(../../../../../../../proguard-rules.txt) + Renderer renderer = + (Renderer) + constructor.newInstance( + allowedVideoJoiningTimeMs, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); + out.add(extensionRendererIndex++, renderer); + Log.i(TAG, "Loaded LibvpxVideoRenderer."); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the extension. + } catch (Exception e) { + // The extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating VP9 extension", e); + } + + try { + // Full class names used for constructor args so the LINT rule triggers if any of them move. + // LINT.IfChange + Class<?> clazz = Class.forName("org.mozilla.thirdparty.com.google.android.exoplayer2.ext.av1.Libgav1VideoRenderer"); + Constructor<?> constructor = + clazz.getConstructor( + long.class, + android.os.Handler.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener.class, + int.class); + // LINT.ThenChange(../../../../../../../proguard-rules.txt) + Renderer renderer = + (Renderer) + constructor.newInstance( + allowedVideoJoiningTimeMs, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY); + out.add(extensionRendererIndex++, renderer); + Log.i(TAG, "Loaded Libgav1VideoRenderer."); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the extension. + } catch (Exception e) { + // The extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating AV1 extension", e); + } + } + + /** + * Builds audio renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param extensionRendererMode The extension renderer mode. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the player will + * not be used for DRM protected playbacks. + * @param playClearSamplesWithoutKeys Whether renderers are permitted to play clear regions of + * encrypted media prior to having obtained the keys necessary to decrypt encrypted regions of + * the media. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is slower/less efficient than + * the primary decoder. + * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio buffers + * before output. May be empty. + * @param eventHandler A handler to use when invoking event listeners and outputs. + * @param eventListener An event listener. + * @param out An array to which the built renderers should be appended. + */ + protected void buildAudioRenderers( + Context context, + @ExtensionRendererMode int extensionRendererMode, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + boolean enableDecoderFallback, + AudioProcessor[] audioProcessors, + Handler eventHandler, + AudioRendererEventListener eventListener, + ArrayList<Renderer> out) { + out.add( + new MediaCodecAudioRenderer( + context, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + enableDecoderFallback, + eventHandler, + eventListener, + new DefaultAudioSink(AudioCapabilities.getCapabilities(context), audioProcessors))); + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return; + } + int extensionRendererIndex = out.size(); + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex--; + } + + try { + // Full class names used for constructor args so the LINT rule triggers if any of them move. + // LINT.IfChange + Class<?> clazz = Class.forName("org.mozilla.thirdparty.com.google.android.exoplayer2.ext.opus.LibopusAudioRenderer"); + Constructor<?> constructor = + clazz.getConstructor( + android.os.Handler.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioProcessor[].class); + // LINT.ThenChange(../../../../../../../proguard-rules.txt) + Renderer renderer = + (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + out.add(extensionRendererIndex++, renderer); + Log.i(TAG, "Loaded LibopusAudioRenderer."); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the extension. + } catch (Exception e) { + // The extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating Opus extension", e); + } + + try { + // Full class names used for constructor args so the LINT rule triggers if any of them move. + // LINT.IfChange + Class<?> clazz = Class.forName("org.mozilla.thirdparty.com.google.android.exoplayer2.ext.flac.LibflacAudioRenderer"); + Constructor<?> constructor = + clazz.getConstructor( + android.os.Handler.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioProcessor[].class); + // LINT.ThenChange(../../../../../../../proguard-rules.txt) + Renderer renderer = + (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + out.add(extensionRendererIndex++, renderer); + Log.i(TAG, "Loaded LibflacAudioRenderer."); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the extension. + } catch (Exception e) { + // The extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating FLAC extension", e); + } + + try { + // Full class names used for constructor args so the LINT rule triggers if any of them move. + // LINT.IfChange + Class<?> clazz = + Class.forName("org.mozilla.thirdparty.com.google.android.exoplayer2.ext.ffmpeg.FfmpegAudioRenderer"); + Constructor<?> constructor = + clazz.getConstructor( + android.os.Handler.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener.class, + org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioProcessor[].class); + // LINT.ThenChange(../../../../../../../proguard-rules.txt) + Renderer renderer = + (Renderer) constructor.newInstance(eventHandler, eventListener, audioProcessors); + out.add(extensionRendererIndex++, renderer); + Log.i(TAG, "Loaded FfmpegAudioRenderer."); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the extension. + } catch (Exception e) { + // The extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating FFmpeg extension", e); + } + } + + /** + * Builds text renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param output An output for the renderers. + * @param outputLooper The looper associated with the thread on which the output should be called. + * @param extensionRendererMode The extension renderer mode. + * @param out An array to which the built renderers should be appended. + */ + protected void buildTextRenderers( + Context context, + TextOutput output, + Looper outputLooper, + @ExtensionRendererMode int extensionRendererMode, + ArrayList<Renderer> out) { + out.add(new TextRenderer(output, outputLooper)); + } + + /** + * Builds metadata renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param output An output for the renderers. + * @param outputLooper The looper associated with the thread on which the output should be called. + * @param extensionRendererMode The extension renderer mode. + * @param out An array to which the built renderers should be appended. + */ + protected void buildMetadataRenderers( + Context context, + MetadataOutput output, + Looper outputLooper, + @ExtensionRendererMode int extensionRendererMode, + ArrayList<Renderer> out) { + out.add(new MetadataRenderer(output, outputLooper)); + } + + /** + * Builds camera motion renderers for use by the player. + * + * @param context The {@link Context} associated with the player. + * @param extensionRendererMode The extension renderer mode. + * @param out An array to which the built renderers should be appended. + */ + protected void buildCameraMotionRenderers( + Context context, @ExtensionRendererMode int extensionRendererMode, ArrayList<Renderer> out) { + out.add(new CameraMotionRenderer()); + } + + /** + * Builds any miscellaneous renderers used by the player. + * + * @param context The {@link Context} associated with the player. + * @param eventHandler A handler to use when invoking event listeners and outputs. + * @param extensionRendererMode The extension renderer mode. + * @param out An array to which the built renderers should be appended. + */ + protected void buildMiscellaneousRenderers(Context context, Handler eventHandler, + @ExtensionRendererMode int extensionRendererMode, ArrayList<Renderer> out) { + // Do nothing. + } + + /** + * Builds an array of {@link AudioProcessor}s that will process PCM audio before output. + */ + protected AudioProcessor[] buildAudioProcessors() { + return new AudioProcessor[0]; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlaybackException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlaybackException.java new file mode 100644 index 0000000000..bad5cc7693 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlaybackException.java @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.SystemClock; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.FormatSupport; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Thrown when a non-recoverable playback failure occurs. + */ +public final class ExoPlaybackException extends Exception { + + /** + * The type of source that produced the error. One of {@link #TYPE_SOURCE}, {@link #TYPE_RENDERER} + * {@link #TYPE_UNEXPECTED}, {@link #TYPE_REMOTE} or {@link #TYPE_OUT_OF_MEMORY}. Note that new + * types may be added in the future and error handling should handle unknown type values. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_SOURCE, TYPE_RENDERER, TYPE_UNEXPECTED, TYPE_REMOTE, TYPE_OUT_OF_MEMORY}) + public @interface Type {} + /** + * The error occurred loading data from a {@link MediaSource}. + * <p> + * Call {@link #getSourceException()} to retrieve the underlying cause. + */ + public static final int TYPE_SOURCE = 0; + /** + * The error occurred in a {@link Renderer}. + * <p> + * Call {@link #getRendererException()} to retrieve the underlying cause. + */ + public static final int TYPE_RENDERER = 1; + /** + * The error was an unexpected {@link RuntimeException}. + * <p> + * Call {@link #getUnexpectedException()} to retrieve the underlying cause. + */ + public static final int TYPE_UNEXPECTED = 2; + /** + * The error occurred in a remote component. + * + * <p>Call {@link #getMessage()} to retrieve the message associated with the error. + */ + public static final int TYPE_REMOTE = 3; + /** The error was an {@link OutOfMemoryError}. */ + public static final int TYPE_OUT_OF_MEMORY = 4; + + /** The {@link Type} of the playback failure. */ + @Type public final int type; + + /** + * If {@link #type} is {@link #TYPE_RENDERER}, this is the index of the renderer. + */ + public final int rendererIndex; + + /** + * If {@link #type} is {@link #TYPE_RENDERER}, this is the {@link Format} the renderer was using + * at the time of the exception, or null if the renderer wasn't using a {@link Format}. + */ + @Nullable public final Format rendererFormat; + + /** + * If {@link #type} is {@link #TYPE_RENDERER}, this is the level of {@link FormatSupport} of the + * renderer for {@link #rendererFormat}. If {@link #rendererFormat} is null, this is {@link + * RendererCapabilities#FORMAT_HANDLED}. + */ + @FormatSupport public final int rendererFormatSupport; + + /** The value of {@link SystemClock#elapsedRealtime()} when this exception was created. */ + public final long timestampMs; + + @Nullable private final Throwable cause; + + /** + * Creates an instance of type {@link #TYPE_SOURCE}. + * + * @param cause The cause of the failure. + * @return The created instance. + */ + public static ExoPlaybackException createForSource(IOException cause) { + return new ExoPlaybackException(TYPE_SOURCE, cause); + } + + /** + * Creates an instance of type {@link #TYPE_RENDERER}. + * + * @param cause The cause of the failure. + * @param rendererIndex The index of the renderer in which the failure occurred. + * @param rendererFormat The {@link Format} the renderer was using at the time of the exception, + * or null if the renderer wasn't using a {@link Format}. + * @param rendererFormatSupport The {@link FormatSupport} of the renderer for {@code + * rendererFormat}. Ignored if {@code rendererFormat} is null. + * @return The created instance. + */ + public static ExoPlaybackException createForRenderer( + Exception cause, + int rendererIndex, + @Nullable Format rendererFormat, + @FormatSupport int rendererFormatSupport) { + return new ExoPlaybackException( + TYPE_RENDERER, + cause, + rendererIndex, + rendererFormat, + rendererFormat == null ? RendererCapabilities.FORMAT_HANDLED : rendererFormatSupport); + } + + /** + * Creates an instance of type {@link #TYPE_UNEXPECTED}. + * + * @param cause The cause of the failure. + * @return The created instance. + */ + public static ExoPlaybackException createForUnexpected(RuntimeException cause) { + return new ExoPlaybackException(TYPE_UNEXPECTED, cause); + } + + /** + * Creates an instance of type {@link #TYPE_REMOTE}. + * + * @param message The message associated with the error. + * @return The created instance. + */ + public static ExoPlaybackException createForRemote(String message) { + return new ExoPlaybackException(TYPE_REMOTE, message); + } + + /** + * Creates an instance of type {@link #TYPE_OUT_OF_MEMORY}. + * + * @param cause The cause of the failure. + * @return The created instance. + */ + public static ExoPlaybackException createForOutOfMemoryError(OutOfMemoryError cause) { + return new ExoPlaybackException(TYPE_OUT_OF_MEMORY, cause); + } + + private ExoPlaybackException(@Type int type, Throwable cause) { + this( + type, + cause, + /* rendererIndex= */ C.INDEX_UNSET, + /* rendererFormat= */ null, + /* rendererFormatSupport= */ RendererCapabilities.FORMAT_HANDLED); + } + + private ExoPlaybackException( + @Type int type, + Throwable cause, + int rendererIndex, + @Nullable Format rendererFormat, + @FormatSupport int rendererFormatSupport) { + super(cause); + this.type = type; + this.cause = cause; + this.rendererIndex = rendererIndex; + this.rendererFormat = rendererFormat; + this.rendererFormatSupport = rendererFormatSupport; + timestampMs = SystemClock.elapsedRealtime(); + } + + private ExoPlaybackException(@Type int type, String message) { + super(message); + this.type = type; + rendererIndex = C.INDEX_UNSET; + rendererFormat = null; + rendererFormatSupport = RendererCapabilities.FORMAT_UNSUPPORTED_TYPE; + cause = null; + timestampMs = SystemClock.elapsedRealtime(); + } + + /** + * Retrieves the underlying error when {@link #type} is {@link #TYPE_SOURCE}. + * + * @throws IllegalStateException If {@link #type} is not {@link #TYPE_SOURCE}. + */ + public IOException getSourceException() { + Assertions.checkState(type == TYPE_SOURCE); + return (IOException) Assertions.checkNotNull(cause); + } + + /** + * Retrieves the underlying error when {@link #type} is {@link #TYPE_RENDERER}. + * + * @throws IllegalStateException If {@link #type} is not {@link #TYPE_RENDERER}. + */ + public Exception getRendererException() { + Assertions.checkState(type == TYPE_RENDERER); + return (Exception) Assertions.checkNotNull(cause); + } + + /** + * Retrieves the underlying error when {@link #type} is {@link #TYPE_UNEXPECTED}. + * + * @throws IllegalStateException If {@link #type} is not {@link #TYPE_UNEXPECTED}. + */ + public RuntimeException getUnexpectedException() { + Assertions.checkState(type == TYPE_UNEXPECTED); + return (RuntimeException) Assertions.checkNotNull(cause); + } + + /** + * Retrieves the underlying error when {@link #type} is {@link #TYPE_OUT_OF_MEMORY}. + * + * @throws IllegalStateException If {@link #type} is not {@link #TYPE_OUT_OF_MEMORY}. + */ + public OutOfMemoryError getOutOfMemoryError() { + Assertions.checkState(type == TYPE_OUT_OF_MEMORY); + return (OutOfMemoryError) Assertions.checkNotNull(cause); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayer.java new file mode 100644 index 0000000000..048c1776c9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayer.java @@ -0,0 +1,404 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.content.Context; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsCollector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.MediaCodecAudioRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ClippingMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.LoopingMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MergingMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ProgressiveMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SingleSampleMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.TextRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.MediaCodecVideoRenderer; + +/** + * An extensible media player that plays {@link MediaSource}s. Instances can be obtained from {@link + * SimpleExoPlayer.Builder} or {@link ExoPlayer.Builder}. + * + * <h3>Player components</h3> + * + * <p>ExoPlayer is designed to make few assumptions about (and hence impose few restrictions on) the + * type of the media being played, how and where it is stored, and how it is rendered. Rather than + * implementing the loading and rendering of media directly, ExoPlayer implementations delegate this + * work to components that are injected when a player is created or when it's prepared for playback. + * Components common to all ExoPlayer implementations are: + * + * <ul> + * <li>A <b>{@link MediaSource}</b> that defines the media to be played, loads the media, and from + * which the loaded media can be read. A MediaSource is injected via {@link + * #prepare(MediaSource)} at the start of playback. The library modules provide default + * implementations for progressive media files ({@link ProgressiveMediaSource}), DASH + * (DashMediaSource), SmoothStreaming (SsMediaSource) and HLS (HlsMediaSource), an + * implementation for loading single media samples ({@link SingleSampleMediaSource}) that's + * most often used for side-loaded subtitle files, and implementations for building more + * complex MediaSources from simpler ones ({@link MergingMediaSource}, {@link + * ConcatenatingMediaSource}, {@link LoopingMediaSource} and {@link ClippingMediaSource}). + * <li><b>{@link Renderer}</b>s that render individual components of the media. The library + * provides default implementations for common media types ({@link MediaCodecVideoRenderer}, + * {@link MediaCodecAudioRenderer}, {@link TextRenderer} and {@link MetadataRenderer}). A + * Renderer consumes media from the MediaSource being played. Renderers are injected when the + * player is created. + * <li>A <b>{@link TrackSelector}</b> that selects tracks provided by the MediaSource to be + * consumed by each of the available Renderers. The library provides a default implementation + * ({@link DefaultTrackSelector}) suitable for most use cases. A TrackSelector is injected + * when the player is created. + * <li>A <b>{@link LoadControl}</b> that controls when the MediaSource buffers more media, and how + * much media is buffered. The library provides a default implementation ({@link + * DefaultLoadControl}) suitable for most use cases. A LoadControl is injected when the player + * is created. + * </ul> + * + * <p>An ExoPlayer can be built using the default components provided by the library, but may also + * be built using custom implementations if non-standard behaviors are required. For example a + * custom LoadControl could be injected to change the player's buffering strategy, or a custom + * Renderer could be injected to add support for a video codec not supported natively by Android. + * + * <p>The concept of injecting components that implement pieces of player functionality is present + * throughout the library. The default component implementations listed above delegate work to + * further injected components. This allows many sub-components to be individually replaced with + * custom implementations. For example the default MediaSource implementations require one or more + * {@link DataSource} factories to be injected via their constructors. By providing a custom factory + * it's possible to load data from a non-standard source, or through a different network stack. + * + * <h3>Threading model</h3> + * + * <p>The figure below shows ExoPlayer's threading model. + * + * <p style="align:center"><img src="doc-files/exoplayer-threading-model.svg" alt="ExoPlayer's + * threading model"> + * + * <ul> + * <li>ExoPlayer instances must be accessed from a single application thread. For the vast + * majority of cases this should be the application's main thread. Using the application's + * main thread is also a requirement when using ExoPlayer's UI components or the IMA + * extension. The thread on which an ExoPlayer instance must be accessed can be explicitly + * specified by passing a `Looper` when creating the player. If no `Looper` is specified, then + * the `Looper` of the thread that the player is created on is used, or if that thread does + * not have a `Looper`, the `Looper` of the application's main thread is used. In all cases + * the `Looper` of the thread from which the player must be accessed can be queried using + * {@link #getApplicationLooper()}. + * <li>Registered listeners are called on the thread associated with {@link + * #getApplicationLooper()}. Note that this means registered listeners are called on the same + * thread which must be used to access the player. + * <li>An internal playback thread is responsible for playback. Injected player components such as + * Renderers, MediaSources, TrackSelectors and LoadControls are called by the player on this + * thread. + * <li>When the application performs an operation on the player, for example a seek, a message is + * delivered to the internal playback thread via a message queue. The internal playback thread + * consumes messages from the queue and performs the corresponding operations. Similarly, when + * a playback event occurs on the internal playback thread, a message is delivered to the + * application thread via a second message queue. The application thread consumes messages + * from the queue, updating the application visible state and calling corresponding listener + * methods. + * <li>Injected player components may use additional background threads. For example a MediaSource + * may use background threads to load data. These are implementation specific. + * </ul> + */ +public interface ExoPlayer extends Player { + + /** + * A builder for {@link ExoPlayer} instances. + * + * <p>See {@link #Builder(Context, Renderer...)} for the list of default values. + */ + final class Builder { + + private final Renderer[] renderers; + + private Clock clock; + private TrackSelector trackSelector; + private LoadControl loadControl; + private BandwidthMeter bandwidthMeter; + private Looper looper; + private AnalyticsCollector analyticsCollector; + private boolean useLazyPreparation; + private boolean buildCalled; + + /** + * Creates a builder with a list of {@link Renderer Renderers}. + * + * <p>The builder uses the following default values: + * + * <ul> + * <li>{@link TrackSelector}: {@link DefaultTrackSelector} + * <li>{@link LoadControl}: {@link DefaultLoadControl} + * <li>{@link BandwidthMeter}: {@link DefaultBandwidthMeter#getSingletonInstance(Context)} + * <li>{@link Looper}: The {@link Looper} associated with the current thread, or the {@link + * Looper} of the application's main thread if the current thread doesn't have a {@link + * Looper} + * <li>{@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} + * <li>{@code useLazyPreparation}: {@code true} + * <li>{@link Clock}: {@link Clock#DEFAULT} + * </ul> + * + * @param context A {@link Context}. + * @param renderers The {@link Renderer Renderers} to be used by the player. + */ + public Builder(Context context, Renderer... renderers) { + this( + renderers, + new DefaultTrackSelector(context), + new DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(context), + Util.getLooper(), + new AnalyticsCollector(Clock.DEFAULT), + /* useLazyPreparation= */ true, + Clock.DEFAULT); + } + + /** + * Creates a builder with the specified custom components. + * + * <p>Note that this constructor is only useful if you try to ensure that ExoPlayer's default + * components can be removed by ProGuard or R8. For most components except renderers, there is + * only a marginal benefit of doing that. + * + * @param renderers The {@link Renderer Renderers} to be used by the player. + * @param trackSelector A {@link TrackSelector}. + * @param loadControl A {@link LoadControl}. + * @param bandwidthMeter A {@link BandwidthMeter}. + * @param looper A {@link Looper} that must be used for all calls to the player. + * @param analyticsCollector An {@link AnalyticsCollector}. + * @param useLazyPreparation Whether media sources should be initialized lazily. + * @param clock A {@link Clock}. Should always be {@link Clock#DEFAULT}. + */ + public Builder( + Renderer[] renderers, + TrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + Looper looper, + AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, + Clock clock) { + Assertions.checkArgument(renderers.length > 0); + this.renderers = renderers; + this.trackSelector = trackSelector; + this.loadControl = loadControl; + this.bandwidthMeter = bandwidthMeter; + this.looper = looper; + this.analyticsCollector = analyticsCollector; + this.useLazyPreparation = useLazyPreparation; + this.clock = clock; + } + + /** + * Sets the {@link TrackSelector} that will be used by the player. + * + * @param trackSelector A {@link TrackSelector}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setTrackSelector(TrackSelector trackSelector) { + Assertions.checkState(!buildCalled); + this.trackSelector = trackSelector; + return this; + } + + /** + * Sets the {@link LoadControl} that will be used by the player. + * + * @param loadControl A {@link LoadControl}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setLoadControl(LoadControl loadControl) { + Assertions.checkState(!buildCalled); + this.loadControl = loadControl; + return this; + } + + /** + * Sets the {@link BandwidthMeter} that will be used by the player. + * + * @param bandwidthMeter A {@link BandwidthMeter}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) { + Assertions.checkState(!buildCalled); + this.bandwidthMeter = bandwidthMeter; + return this; + } + + /** + * Sets the {@link Looper} that must be used for all calls to the player and that is used to + * call listeners on. + * + * @param looper A {@link Looper}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setLooper(Looper looper) { + Assertions.checkState(!buildCalled); + this.looper = looper; + return this; + } + + /** + * Sets the {@link AnalyticsCollector} that will collect and forward all player events. + * + * @param analyticsCollector An {@link AnalyticsCollector}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setAnalyticsCollector(AnalyticsCollector analyticsCollector) { + Assertions.checkState(!buildCalled); + this.analyticsCollector = analyticsCollector; + return this; + } + + /** + * Sets whether media sources should be initialized lazily. + * + * <p>If false, all initial preparation steps (e.g., manifest loads) happen immediately. If + * true, these initial preparations are triggered only when the player starts buffering the + * media. + * + * @param useLazyPreparation Whether to use lazy preparation. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setUseLazyPreparation(boolean useLazyPreparation) { + Assertions.checkState(!buildCalled); + this.useLazyPreparation = useLazyPreparation; + return this; + } + + /** + * Sets the {@link Clock} that will be used by the player. Should only be set for testing + * purposes. + * + * @param clock A {@link Clock}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @VisibleForTesting + public Builder setClock(Clock clock) { + Assertions.checkState(!buildCalled); + this.clock = clock; + return this; + } + + /** + * Builds an {@link ExoPlayer} instance. + * + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public ExoPlayer build() { + Assertions.checkState(!buildCalled); + buildCalled = true; + return new ExoPlayerImpl( + renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + } + } + + /** Returns the {@link Looper} associated with the playback thread. */ + Looper getPlaybackLooper(); + + /** + * Retries a failed or stopped playback. Does nothing if the player has been reset, or if playback + * has not failed or been stopped. + */ + void retry(); + + /** + * Prepares the player to play the provided {@link MediaSource}. Equivalent to {@code + * prepare(mediaSource, true, true)}. + */ + void prepare(MediaSource mediaSource); + + /** + * Prepares the player to play the provided {@link MediaSource}, optionally resetting the playback + * position the default position in the first {@link Timeline.Window}. + * + * @param mediaSource The {@link MediaSource} to play. + * @param resetPosition Whether the playback position should be reset to the default position in + * the first {@link Timeline.Window}. If false, playback will start from the position defined + * by {@link #getCurrentWindowIndex()} and {@link #getCurrentPosition()}. + * @param resetState Whether the timeline, manifest, tracks and track selections should be reset. + * Should be true unless the player is being prepared to play the same media as it was playing + * previously (e.g. if playback failed and is being retried). + */ + void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState); + + /** + * Creates a message that can be sent to a {@link PlayerMessage.Target}. By default, the message + * will be delivered immediately without blocking on the playback thread. The default {@link + * PlayerMessage#getType()} is 0 and the default {@link PlayerMessage#getPayload()} is null. If a + * position is specified with {@link PlayerMessage#setPosition(long)}, the message will be + * delivered at this position in the current window defined by {@link #getCurrentWindowIndex()}. + * Alternatively, the message can be sent at a specific window using {@link + * PlayerMessage#setPosition(int, long)}. + */ + PlayerMessage createMessage(PlayerMessage.Target target); + + /** + * Sets the parameters that control how seek operations are performed. + * + * @param seekParameters The seek parameters, or {@code null} to use the defaults. + */ + void setSeekParameters(@Nullable SeekParameters seekParameters); + + /** Returns the currently active {@link SeekParameters} of the player. */ + SeekParameters getSeekParameters(); + + /** + * Sets whether the player is allowed to keep holding limited resources such as video decoders, + * even when in the idle state. By doing so, the player may be able to reduce latency when + * starting to play another piece of content for which the same resources are required. + * + * <p>This mode should be used with caution, since holding limited resources may prevent other + * players of media components from acquiring them. It should only be enabled when <em>both</em> + * of the following conditions are true: + * + * <ul> + * <li>The application that owns the player is in the foreground. + * <li>The player is used in a way that may benefit from foreground mode. For this to be true, + * the same player instance must be used to play multiple pieces of content, and there must + * be gaps between the playbacks (i.e. {@link #stop} is called to halt one playback, and + * {@link #prepare} is called some time later to start a new one). + * </ul> + * + * <p>Note that foreground mode is <em>not</em> useful for switching between content without gaps + * between the playbacks. For this use case {@link #stop} does not need to be called, and simply + * calling {@link #prepare} for the new media will cause limited resources to be retained even if + * foreground mode is not enabled. + * + * <p>If foreground mode is enabled, it's the application's responsibility to disable it when the + * conditions described above no longer hold. + * + * @param foregroundMode Whether the player is allowed to keep limited resources even when in the + * idle state. + */ + void setForegroundMode(boolean foregroundMode); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerFactory.java new file mode 100644 index 0000000000..a2e89fc3cc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerFactory.java @@ -0,0 +1,350 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.content.Context; +import android.os.Looper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsCollector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** @deprecated Use {@link SimpleExoPlayer.Builder} or {@link ExoPlayer.Builder} instead. */ +@Deprecated +public final class ExoPlayerFactory { + + private ExoPlayerFactory() {} + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode) { + RenderersFactory renderersFactory = + new DefaultRenderersFactory(context).setExtensionRendererMode(extensionRendererMode); + return newSimpleInstance( + context, renderersFactory, trackSelector, loadControl, drmSessionManager); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + @DefaultRenderersFactory.ExtensionRendererMode int extensionRendererMode, + long allowedVideoJoiningTimeMs) { + RenderersFactory renderersFactory = + new DefaultRenderersFactory(context) + .setExtensionRendererMode(extensionRendererMode) + .setAllowedVideoJoiningTimeMs(allowedVideoJoiningTimeMs); + return newSimpleInstance( + context, renderersFactory, trackSelector, loadControl, drmSessionManager); + } + + /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance(Context context) { + return newSimpleInstance(context, new DefaultTrackSelector(context)); + } + + /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance(Context context, TrackSelector trackSelector) { + return newSimpleInstance(context, new DefaultRenderersFactory(context), trackSelector); + } + + /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, RenderersFactory renderersFactory, TrackSelector trackSelector) { + return newSimpleInstance(context, renderersFactory, trackSelector, new DefaultLoadControl()); + } + + /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, TrackSelector trackSelector, LoadControl loadControl) { + RenderersFactory renderersFactory = new DefaultRenderersFactory(context); + return newSimpleInstance(context, renderersFactory, trackSelector, loadControl); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) { + RenderersFactory renderersFactory = new DefaultRenderersFactory(context); + return newSimpleInstance( + context, renderersFactory, trackSelector, loadControl, drmSessionManager); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) { + return newSimpleInstance( + context, renderersFactory, trackSelector, new DefaultLoadControl(), drmSessionManager); + } + + /** @deprecated Use {@link SimpleExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl) { + return newSimpleInstance( + context, + renderersFactory, + trackSelector, + loadControl, + /* drmSessionManager= */ null, + Util.getLooper()); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) { + return newSimpleInstance( + context, renderersFactory, trackSelector, loadControl, drmSessionManager, Util.getLooper()); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + BandwidthMeter bandwidthMeter) { + return newSimpleInstance( + context, + renderersFactory, + trackSelector, + loadControl, + drmSessionManager, + bandwidthMeter, + new AnalyticsCollector(Clock.DEFAULT), + Util.getLooper()); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + AnalyticsCollector analyticsCollector) { + return newSimpleInstance( + context, + renderersFactory, + trackSelector, + loadControl, + drmSessionManager, + analyticsCollector, + Util.getLooper()); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + Looper looper) { + return newSimpleInstance( + context, + renderersFactory, + trackSelector, + loadControl, + drmSessionManager, + new AnalyticsCollector(Clock.DEFAULT), + looper); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + AnalyticsCollector analyticsCollector, + Looper looper) { + return newSimpleInstance( + context, + renderersFactory, + trackSelector, + loadControl, + drmSessionManager, + DefaultBandwidthMeter.getSingletonInstance(context), + analyticsCollector, + looper); + } + + /** + * @deprecated Use {@link SimpleExoPlayer.Builder} instead. The {@link DrmSessionManager} cannot + * be passed to {@link SimpleExoPlayer.Builder} and should instead be injected into the {@link + * MediaSource} factories. + */ + @SuppressWarnings("deprecation") + @Deprecated + public static SimpleExoPlayer newSimpleInstance( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + BandwidthMeter bandwidthMeter, + AnalyticsCollector analyticsCollector, + Looper looper) { + return new SimpleExoPlayer( + context, + renderersFactory, + trackSelector, + loadControl, + drmSessionManager, + bandwidthMeter, + analyticsCollector, + Clock.DEFAULT, + looper); + } + + /** @deprecated Use {@link ExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static ExoPlayer newInstance( + Context context, Renderer[] renderers, TrackSelector trackSelector) { + return newInstance(context, renderers, trackSelector, new DefaultLoadControl()); + } + + /** @deprecated Use {@link ExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static ExoPlayer newInstance( + Context context, Renderer[] renderers, TrackSelector trackSelector, LoadControl loadControl) { + return newInstance(context, renderers, trackSelector, loadControl, Util.getLooper()); + } + + /** @deprecated Use {@link ExoPlayer.Builder} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public static ExoPlayer newInstance( + Context context, + Renderer[] renderers, + TrackSelector trackSelector, + LoadControl loadControl, + Looper looper) { + return newInstance( + context, + renderers, + trackSelector, + loadControl, + DefaultBandwidthMeter.getSingletonInstance(context), + looper); + } + + /** @deprecated Use {@link ExoPlayer.Builder} instead. */ + @Deprecated + public static ExoPlayer newInstance( + Context context, + Renderer[] renderers, + TrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + Looper looper) { + return new ExoPlayerImpl( + renderers, trackSelector, loadControl, bandwidthMeter, Clock.DEFAULT, looper); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerImpl.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerImpl.java new file mode 100644 index 0000000000..eb9eaae2cf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -0,0 +1,848 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlayerMessage.Target; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +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.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayDeque; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * An {@link ExoPlayer} implementation. Instances can be obtained from {@link ExoPlayer.Builder}. + */ +/* package */ final class ExoPlayerImpl extends BasePlayer implements ExoPlayer { + + private static final String TAG = "ExoPlayerImpl"; + + /** + * This empty track selector result can only be used for {@link PlaybackInfo#trackSelectorResult} + * when the player does not have any track selection made (such as when player is reset, or when + * player seeks to an unprepared period). It will not be used as result of any {@link + * TrackSelector#selectTracks(RendererCapabilities[], TrackGroupArray, MediaPeriodId, Timeline)} + * operation. + */ + /* package */ final TrackSelectorResult emptyTrackSelectorResult; + + private final Renderer[] renderers; + private final TrackSelector trackSelector; + private final Handler eventHandler; + private final ExoPlayerImplInternal internalPlayer; + private final Handler internalPlayerHandler; + private final CopyOnWriteArrayList<ListenerHolder> listeners; + private final Timeline.Period period; + private final ArrayDeque<Runnable> pendingListenerNotifications; + + private MediaSource mediaSource; + private boolean playWhenReady; + @PlaybackSuppressionReason private int playbackSuppressionReason; + @RepeatMode private int repeatMode; + private boolean shuffleModeEnabled; + private int pendingOperationAcks; + private boolean hasPendingPrepare; + private boolean hasPendingSeek; + private boolean foregroundMode; + private int pendingSetPlaybackParametersAcks; + private PlaybackParameters playbackParameters; + private SeekParameters seekParameters; + + // Playback information when there is no pending seek/set source operation. + private PlaybackInfo playbackInfo; + + // Playback information when there is a pending seek/set source operation. + private int maskingWindowIndex; + private int maskingPeriodIndex; + private long maskingWindowPositionMs; + + /** + * Constructs an instance. Must be called from a thread that has an associated {@link Looper}. + * + * @param renderers The {@link Renderer}s that will be used by the instance. + * @param trackSelector The {@link TrackSelector} that will be used by the instance. + * @param loadControl The {@link LoadControl} that will be used by the instance. + * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. + * @param clock The {@link Clock} that will be used by the instance. + * @param looper The {@link Looper} which must be used for all calls to the player and which is + * used to call listeners on. + */ + @SuppressLint("HandlerLeak") + public ExoPlayerImpl( + Renderer[] renderers, + TrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + Clock clock, + Looper looper) { + Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " [" + + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]"); + Assertions.checkState(renderers.length > 0); + this.renderers = Assertions.checkNotNull(renderers); + this.trackSelector = Assertions.checkNotNull(trackSelector); + this.playWhenReady = false; + this.repeatMode = Player.REPEAT_MODE_OFF; + this.shuffleModeEnabled = false; + this.listeners = new CopyOnWriteArrayList<>(); + emptyTrackSelectorResult = + new TrackSelectorResult( + new RendererConfiguration[renderers.length], + new TrackSelection[renderers.length], + null); + period = new Timeline.Period(); + playbackParameters = PlaybackParameters.DEFAULT; + seekParameters = SeekParameters.DEFAULT; + playbackSuppressionReason = PLAYBACK_SUPPRESSION_REASON_NONE; + eventHandler = + new Handler(looper) { + @Override + public void handleMessage(Message msg) { + ExoPlayerImpl.this.handleEvent(msg); + } + }; + playbackInfo = PlaybackInfo.createDummy(/* startPositionUs= */ 0, emptyTrackSelectorResult); + pendingListenerNotifications = new ArrayDeque<>(); + internalPlayer = + new ExoPlayerImplInternal( + renderers, + trackSelector, + emptyTrackSelectorResult, + loadControl, + bandwidthMeter, + playWhenReady, + repeatMode, + shuffleModeEnabled, + eventHandler, + clock); + internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper()); + } + + @Override + @Nullable + public AudioComponent getAudioComponent() { + return null; + } + + @Override + @Nullable + public VideoComponent getVideoComponent() { + return null; + } + + @Override + @Nullable + public TextComponent getTextComponent() { + return null; + } + + @Override + @Nullable + public MetadataComponent getMetadataComponent() { + return null; + } + + @Override + public Looper getPlaybackLooper() { + return internalPlayer.getPlaybackLooper(); + } + + @Override + public Looper getApplicationLooper() { + return eventHandler.getLooper(); + } + + @Override + public void addListener(Player.EventListener listener) { + listeners.addIfAbsent(new ListenerHolder(listener)); + } + + @Override + public void removeListener(Player.EventListener listener) { + for (ListenerHolder listenerHolder : listeners) { + if (listenerHolder.listener.equals(listener)) { + listenerHolder.release(); + listeners.remove(listenerHolder); + } + } + } + + @Override + @State + public int getPlaybackState() { + return playbackInfo.playbackState; + } + + @Override + @PlaybackSuppressionReason + public int getPlaybackSuppressionReason() { + return playbackSuppressionReason; + } + + @Override + @Nullable + public ExoPlaybackException getPlaybackError() { + return playbackInfo.playbackError; + } + + @Override + public void retry() { + if (mediaSource != null && playbackInfo.playbackState == Player.STATE_IDLE) { + prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); + } + } + + @Override + public void prepare(MediaSource mediaSource) { + prepare(mediaSource, /* resetPosition= */ true, /* resetState= */ true); + } + + @Override + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + this.mediaSource = mediaSource; + PlaybackInfo playbackInfo = + getResetPlaybackInfo( + resetPosition, + resetState, + /* resetError= */ true, + /* playbackState= */ Player.STATE_BUFFERING); + // Trigger internal prepare first before updating the playback info and notifying external + // listeners to ensure that new operations issued in the listener notifications reach the + // player after this prepare. The internal player can't change the playback info immediately + // because it uses a callback. + hasPendingPrepare = true; + pendingOperationAcks++; + internalPlayer.prepare(mediaSource, resetPosition, resetState); + updatePlaybackInfo( + playbackInfo, + /* positionDiscontinuity= */ false, + /* ignored */ DISCONTINUITY_REASON_INTERNAL, + TIMELINE_CHANGE_REASON_RESET, + /* seekProcessed= */ false); + } + + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + setPlayWhenReady(playWhenReady, PLAYBACK_SUPPRESSION_REASON_NONE); + } + + public void setPlayWhenReady( + boolean playWhenReady, @PlaybackSuppressionReason int playbackSuppressionReason) { + boolean oldIsPlaying = isPlaying(); + boolean oldInternalPlayWhenReady = + this.playWhenReady && this.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE; + boolean internalPlayWhenReady = + playWhenReady && playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE; + if (oldInternalPlayWhenReady != internalPlayWhenReady) { + internalPlayer.setPlayWhenReady(internalPlayWhenReady); + } + boolean playWhenReadyChanged = this.playWhenReady != playWhenReady; + boolean suppressionReasonChanged = this.playbackSuppressionReason != playbackSuppressionReason; + this.playWhenReady = playWhenReady; + this.playbackSuppressionReason = playbackSuppressionReason; + boolean isPlaying = isPlaying(); + boolean isPlayingChanged = oldIsPlaying != isPlaying; + if (playWhenReadyChanged || suppressionReasonChanged || isPlayingChanged) { + int playbackState = playbackInfo.playbackState; + notifyListeners( + listener -> { + if (playWhenReadyChanged) { + listener.onPlayerStateChanged(playWhenReady, playbackState); + } + if (suppressionReasonChanged) { + listener.onPlaybackSuppressionReasonChanged(playbackSuppressionReason); + } + if (isPlayingChanged) { + listener.onIsPlayingChanged(isPlaying); + } + }); + } + } + + @Override + public boolean getPlayWhenReady() { + return playWhenReady; + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + if (this.repeatMode != repeatMode) { + this.repeatMode = repeatMode; + internalPlayer.setRepeatMode(repeatMode); + notifyListeners(listener -> listener.onRepeatModeChanged(repeatMode)); + } + } + + @Override + public @RepeatMode int getRepeatMode() { + return repeatMode; + } + + @Override + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + if (this.shuffleModeEnabled != shuffleModeEnabled) { + this.shuffleModeEnabled = shuffleModeEnabled; + internalPlayer.setShuffleModeEnabled(shuffleModeEnabled); + notifyListeners(listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled)); + } + } + + @Override + public boolean getShuffleModeEnabled() { + return shuffleModeEnabled; + } + + @Override + public boolean isLoading() { + return playbackInfo.isLoading; + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + Timeline timeline = playbackInfo.timeline; + if (windowIndex < 0 || (!timeline.isEmpty() && windowIndex >= timeline.getWindowCount())) { + throw new IllegalSeekPositionException(timeline, windowIndex, positionMs); + } + hasPendingSeek = true; + pendingOperationAcks++; + if (isPlayingAd()) { + // TODO: Investigate adding support for seeking during ads. This is complicated to do in + // general because the midroll ad preceding the seek destination must be played before the + // content position can be played, if a different ad is playing at the moment. + Log.w(TAG, "seekTo ignored because an ad is playing"); + eventHandler + .obtainMessage( + ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED, + /* operationAcks */ 1, + /* positionDiscontinuityReason */ C.INDEX_UNSET, + playbackInfo) + .sendToTarget(); + return; + } + maskingWindowIndex = windowIndex; + if (timeline.isEmpty()) { + maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs; + maskingPeriodIndex = 0; + } else { + long windowPositionUs = positionMs == C.TIME_UNSET + ? timeline.getWindow(windowIndex, window).getDefaultPositionUs() : C.msToUs(positionMs); + Pair<Object, Long> periodUidAndPosition = + timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs); + maskingWindowPositionMs = C.usToMs(windowPositionUs); + maskingPeriodIndex = timeline.getIndexOfPeriod(periodUidAndPosition.first); + } + internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs)); + notifyListeners(listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)); + } + + @Override + public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { + if (playbackParameters == null) { + playbackParameters = PlaybackParameters.DEFAULT; + } + if (this.playbackParameters.equals(playbackParameters)) { + return; + } + pendingSetPlaybackParametersAcks++; + this.playbackParameters = playbackParameters; + internalPlayer.setPlaybackParameters(playbackParameters); + PlaybackParameters playbackParametersToNotify = playbackParameters; + notifyListeners(listener -> listener.onPlaybackParametersChanged(playbackParametersToNotify)); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return playbackParameters; + } + + @Override + public void setSeekParameters(@Nullable SeekParameters seekParameters) { + if (seekParameters == null) { + seekParameters = SeekParameters.DEFAULT; + } + if (!this.seekParameters.equals(seekParameters)) { + this.seekParameters = seekParameters; + internalPlayer.setSeekParameters(seekParameters); + } + } + + @Override + public SeekParameters getSeekParameters() { + return seekParameters; + } + + @Override + public void setForegroundMode(boolean foregroundMode) { + if (this.foregroundMode != foregroundMode) { + this.foregroundMode = foregroundMode; + internalPlayer.setForegroundMode(foregroundMode); + } + } + + @Override + public void stop(boolean reset) { + if (reset) { + mediaSource = null; + } + PlaybackInfo playbackInfo = + getResetPlaybackInfo( + /* resetPosition= */ reset, + /* resetState= */ reset, + /* resetError= */ reset, + /* playbackState= */ Player.STATE_IDLE); + // Trigger internal stop first before updating the playback info and notifying external + // listeners to ensure that new operations issued in the listener notifications reach the + // player after this stop. The internal player can't change the playback info immediately + // because it uses a callback. + pendingOperationAcks++; + internalPlayer.stop(reset); + updatePlaybackInfo( + playbackInfo, + /* positionDiscontinuity= */ false, + /* ignored */ DISCONTINUITY_REASON_INTERNAL, + TIMELINE_CHANGE_REASON_RESET, + /* seekProcessed= */ false); + } + + @Override + public void release() { + Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " [" + + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + + ExoPlayerLibraryInfo.registeredModules() + "]"); + mediaSource = null; + internalPlayer.release(); + eventHandler.removeCallbacksAndMessages(null); + playbackInfo = + getResetPlaybackInfo( + /* resetPosition= */ false, + /* resetState= */ false, + /* resetError= */ false, + /* playbackState= */ Player.STATE_IDLE); + } + + @Override + public PlayerMessage createMessage(Target target) { + return new PlayerMessage( + internalPlayer, + target, + playbackInfo.timeline, + getCurrentWindowIndex(), + internalPlayerHandler); + } + + @Override + public int getCurrentPeriodIndex() { + if (shouldMaskPosition()) { + return maskingPeriodIndex; + } else { + return playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); + } + } + + @Override + public int getCurrentWindowIndex() { + if (shouldMaskPosition()) { + return maskingWindowIndex; + } else { + return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period) + .windowIndex; + } + } + + @Override + public long getDuration() { + if (isPlayingAd()) { + MediaPeriodId periodId = playbackInfo.periodId; + playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period); + long adDurationUs = period.getAdDurationUs(periodId.adGroupIndex, periodId.adIndexInAdGroup); + return C.usToMs(adDurationUs); + } + return getContentDuration(); + } + + @Override + public long getCurrentPosition() { + if (shouldMaskPosition()) { + return maskingWindowPositionMs; + } else if (playbackInfo.periodId.isAd()) { + return C.usToMs(playbackInfo.positionUs); + } else { + return periodPositionUsToWindowPositionMs(playbackInfo.periodId, playbackInfo.positionUs); + } + } + + @Override + public long getBufferedPosition() { + if (isPlayingAd()) { + return playbackInfo.loadingMediaPeriodId.equals(playbackInfo.periodId) + ? C.usToMs(playbackInfo.bufferedPositionUs) + : getDuration(); + } + return getContentBufferedPosition(); + } + + @Override + public long getTotalBufferedDuration() { + return C.usToMs(playbackInfo.totalBufferedDurationUs); + } + + @Override + public boolean isPlayingAd() { + return !shouldMaskPosition() && playbackInfo.periodId.isAd(); + } + + @Override + public int getCurrentAdGroupIndex() { + return isPlayingAd() ? playbackInfo.periodId.adGroupIndex : C.INDEX_UNSET; + } + + @Override + public int getCurrentAdIndexInAdGroup() { + return isPlayingAd() ? playbackInfo.periodId.adIndexInAdGroup : C.INDEX_UNSET; + } + + @Override + public long getContentPosition() { + if (isPlayingAd()) { + playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period); + return playbackInfo.contentPositionUs == C.TIME_UNSET + ? playbackInfo.timeline.getWindow(getCurrentWindowIndex(), window).getDefaultPositionMs() + : period.getPositionInWindowMs() + C.usToMs(playbackInfo.contentPositionUs); + } else { + return getCurrentPosition(); + } + } + + @Override + public long getContentBufferedPosition() { + if (shouldMaskPosition()) { + return maskingWindowPositionMs; + } + if (playbackInfo.loadingMediaPeriodId.windowSequenceNumber + != playbackInfo.periodId.windowSequenceNumber) { + return playbackInfo.timeline.getWindow(getCurrentWindowIndex(), window).getDurationMs(); + } + long contentBufferedPositionUs = playbackInfo.bufferedPositionUs; + if (playbackInfo.loadingMediaPeriodId.isAd()) { + Timeline.Period loadingPeriod = + playbackInfo.timeline.getPeriodByUid(playbackInfo.loadingMediaPeriodId.periodUid, period); + contentBufferedPositionUs = + loadingPeriod.getAdGroupTimeUs(playbackInfo.loadingMediaPeriodId.adGroupIndex); + if (contentBufferedPositionUs == C.TIME_END_OF_SOURCE) { + contentBufferedPositionUs = loadingPeriod.durationUs; + } + } + return periodPositionUsToWindowPositionMs( + playbackInfo.loadingMediaPeriodId, contentBufferedPositionUs); + } + + @Override + public int getRendererCount() { + return renderers.length; + } + + @Override + public int getRendererType(int index) { + return renderers[index].getTrackType(); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + return playbackInfo.trackGroups; + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + return playbackInfo.trackSelectorResult.selections; + } + + @Override + public Timeline getCurrentTimeline() { + return playbackInfo.timeline; + } + + // Not private so it can be called from an inner class without going through a thunk method. + /* package */ void handleEvent(Message msg) { + switch (msg.what) { + case ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED: + handlePlaybackInfo( + (PlaybackInfo) msg.obj, + /* operationAcks= */ msg.arg1, + /* positionDiscontinuity= */ msg.arg2 != C.INDEX_UNSET, + /* positionDiscontinuityReason= */ msg.arg2); + break; + case ExoPlayerImplInternal.MSG_PLAYBACK_PARAMETERS_CHANGED: + handlePlaybackParameters((PlaybackParameters) msg.obj, /* operationAck= */ msg.arg1 != 0); + break; + default: + throw new IllegalStateException(); + } + } + + private void handlePlaybackParameters( + PlaybackParameters playbackParameters, boolean operationAck) { + if (operationAck) { + pendingSetPlaybackParametersAcks--; + } + if (pendingSetPlaybackParametersAcks == 0) { + if (!this.playbackParameters.equals(playbackParameters)) { + this.playbackParameters = playbackParameters; + notifyListeners(listener -> listener.onPlaybackParametersChanged(playbackParameters)); + } + } + } + + private void handlePlaybackInfo( + PlaybackInfo playbackInfo, + int operationAcks, + boolean positionDiscontinuity, + @DiscontinuityReason int positionDiscontinuityReason) { + pendingOperationAcks -= operationAcks; + if (pendingOperationAcks == 0) { + if (playbackInfo.startPositionUs == C.TIME_UNSET) { + // Replace internal unset start position with externally visible start position of zero. + playbackInfo = + playbackInfo.copyWithNewPosition( + playbackInfo.periodId, + /* positionUs= */ 0, + playbackInfo.contentPositionUs, + playbackInfo.totalBufferedDurationUs); + } + if (!this.playbackInfo.timeline.isEmpty() && playbackInfo.timeline.isEmpty()) { + // Update the masking variables, which are used when the timeline becomes empty. + maskingPeriodIndex = 0; + maskingWindowIndex = 0; + maskingWindowPositionMs = 0; + } + @Player.TimelineChangeReason + int timelineChangeReason = + hasPendingPrepare + ? Player.TIMELINE_CHANGE_REASON_PREPARED + : Player.TIMELINE_CHANGE_REASON_DYNAMIC; + boolean seekProcessed = hasPendingSeek; + hasPendingPrepare = false; + hasPendingSeek = false; + updatePlaybackInfo( + playbackInfo, + positionDiscontinuity, + positionDiscontinuityReason, + timelineChangeReason, + seekProcessed); + } + } + + private PlaybackInfo getResetPlaybackInfo( + boolean resetPosition, + boolean resetState, + boolean resetError, + @Player.State int playbackState) { + if (resetPosition) { + maskingWindowIndex = 0; + maskingPeriodIndex = 0; + maskingWindowPositionMs = 0; + } else { + maskingWindowIndex = getCurrentWindowIndex(); + maskingPeriodIndex = getCurrentPeriodIndex(); + maskingWindowPositionMs = getCurrentPosition(); + } + // Also reset period-based PlaybackInfo positions if resetting the state. + resetPosition = resetPosition || resetState; + MediaPeriodId mediaPeriodId = + resetPosition + ? playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period) + : playbackInfo.periodId; + long startPositionUs = resetPosition ? 0 : playbackInfo.positionUs; + long contentPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.contentPositionUs; + return new PlaybackInfo( + resetState ? Timeline.EMPTY : playbackInfo.timeline, + mediaPeriodId, + startPositionUs, + contentPositionUs, + playbackState, + resetError ? null : playbackInfo.playbackError, + /* isLoading= */ false, + resetState ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, + resetState ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, + mediaPeriodId, + startPositionUs, + /* totalBufferedDurationUs= */ 0, + startPositionUs); + } + + private void updatePlaybackInfo( + PlaybackInfo playbackInfo, + boolean positionDiscontinuity, + @Player.DiscontinuityReason int positionDiscontinuityReason, + @Player.TimelineChangeReason int timelineChangeReason, + boolean seekProcessed) { + boolean previousIsPlaying = isPlaying(); + // Assign playback info immediately such that all getters return the right values. + PlaybackInfo previousPlaybackInfo = this.playbackInfo; + this.playbackInfo = playbackInfo; + boolean isPlaying = isPlaying(); + notifyListeners( + new PlaybackInfoUpdate( + playbackInfo, + previousPlaybackInfo, + listeners, + trackSelector, + positionDiscontinuity, + positionDiscontinuityReason, + timelineChangeReason, + seekProcessed, + playWhenReady, + /* isPlayingChanged= */ previousIsPlaying != isPlaying)); + } + + private void notifyListeners(ListenerInvocation listenerInvocation) { + CopyOnWriteArrayList<ListenerHolder> listenerSnapshot = new CopyOnWriteArrayList<>(listeners); + notifyListeners(() -> invokeAll(listenerSnapshot, listenerInvocation)); + } + + private void notifyListeners(Runnable listenerNotificationRunnable) { + boolean isRunningRecursiveListenerNotification = !pendingListenerNotifications.isEmpty(); + pendingListenerNotifications.addLast(listenerNotificationRunnable); + if (isRunningRecursiveListenerNotification) { + return; + } + while (!pendingListenerNotifications.isEmpty()) { + pendingListenerNotifications.peekFirst().run(); + pendingListenerNotifications.removeFirst(); + } + } + + private long periodPositionUsToWindowPositionMs(MediaPeriodId periodId, long positionUs) { + long positionMs = C.usToMs(positionUs); + playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period); + positionMs += period.getPositionInWindowMs(); + return positionMs; + } + + private boolean shouldMaskPosition() { + return playbackInfo.timeline.isEmpty() || pendingOperationAcks > 0; + } + + private static final class PlaybackInfoUpdate implements Runnable { + + private final PlaybackInfo playbackInfo; + private final CopyOnWriteArrayList<ListenerHolder> listenerSnapshot; + private final TrackSelector trackSelector; + private final boolean positionDiscontinuity; + private final @Player.DiscontinuityReason int positionDiscontinuityReason; + private final @Player.TimelineChangeReason int timelineChangeReason; + private final boolean seekProcessed; + private final boolean playbackStateChanged; + private final boolean playbackErrorChanged; + private final boolean timelineChanged; + private final boolean isLoadingChanged; + private final boolean trackSelectorResultChanged; + private final boolean playWhenReady; + private final boolean isPlayingChanged; + + public PlaybackInfoUpdate( + PlaybackInfo playbackInfo, + PlaybackInfo previousPlaybackInfo, + CopyOnWriteArrayList<ListenerHolder> listeners, + TrackSelector trackSelector, + boolean positionDiscontinuity, + @DiscontinuityReason int positionDiscontinuityReason, + @TimelineChangeReason int timelineChangeReason, + boolean seekProcessed, + boolean playWhenReady, + boolean isPlayingChanged) { + this.playbackInfo = playbackInfo; + this.listenerSnapshot = new CopyOnWriteArrayList<>(listeners); + this.trackSelector = trackSelector; + this.positionDiscontinuity = positionDiscontinuity; + this.positionDiscontinuityReason = positionDiscontinuityReason; + this.timelineChangeReason = timelineChangeReason; + this.seekProcessed = seekProcessed; + this.playWhenReady = playWhenReady; + this.isPlayingChanged = isPlayingChanged; + playbackStateChanged = previousPlaybackInfo.playbackState != playbackInfo.playbackState; + playbackErrorChanged = + previousPlaybackInfo.playbackError != playbackInfo.playbackError + && playbackInfo.playbackError != null; + timelineChanged = previousPlaybackInfo.timeline != playbackInfo.timeline; + isLoadingChanged = previousPlaybackInfo.isLoading != playbackInfo.isLoading; + trackSelectorResultChanged = + previousPlaybackInfo.trackSelectorResult != playbackInfo.trackSelectorResult; + } + + @Override + public void run() { + if (timelineChanged || timelineChangeReason == TIMELINE_CHANGE_REASON_PREPARED) { + invokeAll( + listenerSnapshot, + listener -> listener.onTimelineChanged(playbackInfo.timeline, timelineChangeReason)); + } + if (positionDiscontinuity) { + invokeAll( + listenerSnapshot, + listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason)); + } + if (playbackErrorChanged) { + invokeAll(listenerSnapshot, listener -> listener.onPlayerError(playbackInfo.playbackError)); + } + if (trackSelectorResultChanged) { + trackSelector.onSelectionActivated(playbackInfo.trackSelectorResult.info); + invokeAll( + listenerSnapshot, + listener -> + listener.onTracksChanged( + playbackInfo.trackGroups, playbackInfo.trackSelectorResult.selections)); + } + if (isLoadingChanged) { + invokeAll(listenerSnapshot, listener -> listener.onLoadingChanged(playbackInfo.isLoading)); + } + if (playbackStateChanged) { + invokeAll( + listenerSnapshot, + listener -> listener.onPlayerStateChanged(playWhenReady, playbackInfo.playbackState)); + } + if (isPlayingChanged) { + invokeAll( + listenerSnapshot, + listener -> + listener.onIsPlayingChanged(playbackInfo.playbackState == Player.STATE_READY)); + } + if (seekProcessed) { + invokeAll(listenerSnapshot, EventListener::onSeekProcessed); + } + } + } + + private static void invokeAll( + CopyOnWriteArrayList<ListenerHolder> listeners, ListenerInvocation listenerInvocation) { + for (ListenerHolder listenerHolder : listeners) { + listenerHolder.invoke(listenerInvocation); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerImplInternal.java new file mode 100644 index 0000000000..a4462ad1c4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -0,0 +1,2045 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.Process; +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.DefaultMediaClock.PlaybackParameterListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.HandlerWrapper; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Implements the internal behavior of {@link ExoPlayerImpl}. */ +/* package */ final class ExoPlayerImplInternal + implements Handler.Callback, + MediaPeriod.Callback, + TrackSelector.InvalidationListener, + MediaSourceCaller, + PlaybackParameterListener, + PlayerMessage.Sender { + + private static final String TAG = "ExoPlayerImplInternal"; + + // External messages + public static final int MSG_PLAYBACK_INFO_CHANGED = 0; + public static final int MSG_PLAYBACK_PARAMETERS_CHANGED = 1; + + // Internal messages + private static final int MSG_PREPARE = 0; + private static final int MSG_SET_PLAY_WHEN_READY = 1; + private static final int MSG_DO_SOME_WORK = 2; + private static final int MSG_SEEK_TO = 3; + private static final int MSG_SET_PLAYBACK_PARAMETERS = 4; + private static final int MSG_SET_SEEK_PARAMETERS = 5; + private static final int MSG_STOP = 6; + private static final int MSG_RELEASE = 7; + private static final int MSG_REFRESH_SOURCE_INFO = 8; + private static final int MSG_PERIOD_PREPARED = 9; + private static final int MSG_SOURCE_CONTINUE_LOADING_REQUESTED = 10; + private static final int MSG_TRACK_SELECTION_INVALIDATED = 11; + private static final int MSG_SET_REPEAT_MODE = 12; + private static final int MSG_SET_SHUFFLE_ENABLED = 13; + private static final int MSG_SET_FOREGROUND_MODE = 14; + private static final int MSG_SEND_MESSAGE = 15; + private static final int MSG_SEND_MESSAGE_TO_TARGET_THREAD = 16; + private static final int MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL = 17; + + private static final int ACTIVE_INTERVAL_MS = 10; + private static final int IDLE_INTERVAL_MS = 1000; + + private final Renderer[] renderers; + private final RendererCapabilities[] rendererCapabilities; + private final TrackSelector trackSelector; + private final TrackSelectorResult emptyTrackSelectorResult; + private final LoadControl loadControl; + private final BandwidthMeter bandwidthMeter; + private final HandlerWrapper handler; + private final HandlerThread internalPlaybackThread; + private final Handler eventHandler; + private final Timeline.Window window; + private final Timeline.Period period; + private final long backBufferDurationUs; + private final boolean retainBackBufferFromKeyframe; + private final DefaultMediaClock mediaClock; + private final PlaybackInfoUpdate playbackInfoUpdate; + private final ArrayList<PendingMessageInfo> pendingMessages; + private final Clock clock; + private final MediaPeriodQueue queue; + + @SuppressWarnings("unused") + private SeekParameters seekParameters; + + private PlaybackInfo playbackInfo; + private MediaSource mediaSource; + private Renderer[] enabledRenderers; + private boolean released; + private boolean playWhenReady; + private boolean rebuffering; + private boolean shouldContinueLoading; + @Player.RepeatMode private int repeatMode; + private boolean shuffleModeEnabled; + private boolean foregroundMode; + + private int pendingPrepareCount; + private SeekPosition pendingInitialSeekPosition; + private long rendererPositionUs; + private int nextPendingMessageIndex; + private boolean deliverPendingMessageAtStartPositionRequired; + + public ExoPlayerImplInternal( + Renderer[] renderers, + TrackSelector trackSelector, + TrackSelectorResult emptyTrackSelectorResult, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + boolean playWhenReady, + @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled, + Handler eventHandler, + Clock clock) { + this.renderers = renderers; + this.trackSelector = trackSelector; + this.emptyTrackSelectorResult = emptyTrackSelectorResult; + this.loadControl = loadControl; + this.bandwidthMeter = bandwidthMeter; + this.playWhenReady = playWhenReady; + this.repeatMode = repeatMode; + this.shuffleModeEnabled = shuffleModeEnabled; + this.eventHandler = eventHandler; + this.clock = clock; + this.queue = new MediaPeriodQueue(); + + backBufferDurationUs = loadControl.getBackBufferDurationUs(); + retainBackBufferFromKeyframe = loadControl.retainBackBufferFromKeyframe(); + + seekParameters = SeekParameters.DEFAULT; + playbackInfo = + PlaybackInfo.createDummy(/* startPositionUs= */ C.TIME_UNSET, emptyTrackSelectorResult); + playbackInfoUpdate = new PlaybackInfoUpdate(); + rendererCapabilities = new RendererCapabilities[renderers.length]; + for (int i = 0; i < renderers.length; i++) { + renderers[i].setIndex(i); + rendererCapabilities[i] = renderers[i].getCapabilities(); + } + mediaClock = new DefaultMediaClock(this, clock); + pendingMessages = new ArrayList<>(); + enabledRenderers = new Renderer[0]; + window = new Timeline.Window(); + period = new Timeline.Period(); + trackSelector.init(/* listener= */ this, bandwidthMeter); + + // Note: The documentation for Process.THREAD_PRIORITY_AUDIO that states "Applications can + // not normally change to this priority" is incorrect. + internalPlaybackThread = + new HandlerThread("ExoPlayerImplInternal:Handler", Process.THREAD_PRIORITY_AUDIO); + internalPlaybackThread.start(); + handler = clock.createHandler(internalPlaybackThread.getLooper(), this); + deliverPendingMessageAtStartPositionRequired = true; + } + + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + handler + .obtainMessage(MSG_PREPARE, resetPosition ? 1 : 0, resetState ? 1 : 0, mediaSource) + .sendToTarget(); + } + + public void setPlayWhenReady(boolean playWhenReady) { + handler.obtainMessage(MSG_SET_PLAY_WHEN_READY, playWhenReady ? 1 : 0, 0).sendToTarget(); + } + + public void setRepeatMode(@Player.RepeatMode int repeatMode) { + handler.obtainMessage(MSG_SET_REPEAT_MODE, repeatMode, 0).sendToTarget(); + } + + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + handler.obtainMessage(MSG_SET_SHUFFLE_ENABLED, shuffleModeEnabled ? 1 : 0, 0).sendToTarget(); + } + + public void seekTo(Timeline timeline, int windowIndex, long positionUs) { + handler + .obtainMessage(MSG_SEEK_TO, new SeekPosition(timeline, windowIndex, positionUs)) + .sendToTarget(); + } + + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + handler.obtainMessage(MSG_SET_PLAYBACK_PARAMETERS, playbackParameters).sendToTarget(); + } + + public void setSeekParameters(SeekParameters seekParameters) { + handler.obtainMessage(MSG_SET_SEEK_PARAMETERS, seekParameters).sendToTarget(); + } + + public void stop(boolean reset) { + handler.obtainMessage(MSG_STOP, reset ? 1 : 0, 0).sendToTarget(); + } + + @Override + public synchronized void sendMessage(PlayerMessage message) { + if (released || !internalPlaybackThread.isAlive()) { + Log.w(TAG, "Ignoring messages sent after release."); + message.markAsProcessed(/* isDelivered= */ false); + return; + } + handler.obtainMessage(MSG_SEND_MESSAGE, message).sendToTarget(); + } + + public synchronized void setForegroundMode(boolean foregroundMode) { + if (released || !internalPlaybackThread.isAlive()) { + return; + } + if (foregroundMode) { + handler.obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 1, 0).sendToTarget(); + } else { + AtomicBoolean processedFlag = new AtomicBoolean(); + handler + .obtainMessage(MSG_SET_FOREGROUND_MODE, /* foregroundMode */ 0, 0, processedFlag) + .sendToTarget(); + boolean wasInterrupted = false; + while (!processedFlag.get()) { + try { + wait(); + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); + } + } + } + + public synchronized void release() { + if (released || !internalPlaybackThread.isAlive()) { + return; + } + handler.sendEmptyMessage(MSG_RELEASE); + boolean wasInterrupted = false; + while (!released) { + try { + wait(); + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); + } + } + + public Looper getPlaybackLooper() { + return internalPlaybackThread.getLooper(); + } + + // MediaSource.MediaSourceCaller implementation. + + @Override + public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) { + handler + .obtainMessage(MSG_REFRESH_SOURCE_INFO, new MediaSourceRefreshInfo(source, timeline)) + .sendToTarget(); + } + + // MediaPeriod.Callback implementation. + + @Override + public void onPrepared(MediaPeriod source) { + handler.obtainMessage(MSG_PERIOD_PREPARED, source).sendToTarget(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + handler.obtainMessage(MSG_SOURCE_CONTINUE_LOADING_REQUESTED, source).sendToTarget(); + } + + // TrackSelector.InvalidationListener implementation. + + @Override + public void onTrackSelectionsInvalidated() { + handler.sendEmptyMessage(MSG_TRACK_SELECTION_INVALIDATED); + } + + // DefaultMediaClock.PlaybackParameterListener implementation. + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + sendPlaybackParametersChangedInternal(playbackParameters, /* acknowledgeCommand= */ false); + } + + // Handler.Callback implementation. + + @Override + public boolean handleMessage(Message msg) { + try { + switch (msg.what) { + case MSG_PREPARE: + prepareInternal( + (MediaSource) msg.obj, + /* resetPosition= */ msg.arg1 != 0, + /* resetState= */ msg.arg2 != 0); + break; + case MSG_SET_PLAY_WHEN_READY: + setPlayWhenReadyInternal(msg.arg1 != 0); + break; + case MSG_SET_REPEAT_MODE: + setRepeatModeInternal(msg.arg1); + break; + case MSG_SET_SHUFFLE_ENABLED: + setShuffleModeEnabledInternal(msg.arg1 != 0); + break; + case MSG_DO_SOME_WORK: + doSomeWork(); + break; + case MSG_SEEK_TO: + seekToInternal((SeekPosition) msg.obj); + break; + case MSG_SET_PLAYBACK_PARAMETERS: + setPlaybackParametersInternal((PlaybackParameters) msg.obj); + break; + case MSG_SET_SEEK_PARAMETERS: + setSeekParametersInternal((SeekParameters) msg.obj); + break; + case MSG_SET_FOREGROUND_MODE: + setForegroundModeInternal( + /* foregroundMode= */ msg.arg1 != 0, /* processedFlag= */ (AtomicBoolean) msg.obj); + break; + case MSG_STOP: + stopInternal( + /* forceResetRenderers= */ false, + /* resetPositionAndState= */ msg.arg1 != 0, + /* acknowledgeStop= */ true); + break; + case MSG_PERIOD_PREPARED: + handlePeriodPrepared((MediaPeriod) msg.obj); + break; + case MSG_REFRESH_SOURCE_INFO: + handleSourceInfoRefreshed((MediaSourceRefreshInfo) msg.obj); + break; + case MSG_SOURCE_CONTINUE_LOADING_REQUESTED: + handleContinueLoadingRequested((MediaPeriod) msg.obj); + break; + case MSG_TRACK_SELECTION_INVALIDATED: + reselectTracksInternal(); + break; + case MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL: + handlePlaybackParameters( + (PlaybackParameters) msg.obj, /* acknowledgeCommand= */ msg.arg1 != 0); + break; + case MSG_SEND_MESSAGE: + sendMessageInternal((PlayerMessage) msg.obj); + break; + case MSG_SEND_MESSAGE_TO_TARGET_THREAD: + sendMessageToTargetThread((PlayerMessage) msg.obj); + break; + case MSG_RELEASE: + releaseInternal(); + // Return immediately to not send playback info updates after release. + return true; + default: + return false; + } + maybeNotifyPlaybackInfoChanged(); + } catch (ExoPlaybackException e) { + Log.e(TAG, getExoPlaybackExceptionMessage(e), e); + stopInternal( + /* forceResetRenderers= */ true, + /* resetPositionAndState= */ false, + /* acknowledgeStop= */ false); + playbackInfo = playbackInfo.copyWithPlaybackError(e); + maybeNotifyPlaybackInfoChanged(); + } catch (IOException e) { + Log.e(TAG, "Source error", e); + stopInternal( + /* forceResetRenderers= */ false, + /* resetPositionAndState= */ false, + /* acknowledgeStop= */ false); + playbackInfo = playbackInfo.copyWithPlaybackError(ExoPlaybackException.createForSource(e)); + maybeNotifyPlaybackInfoChanged(); + } catch (RuntimeException | OutOfMemoryError e) { + Log.e(TAG, "Internal runtime error", e); + ExoPlaybackException error = + e instanceof OutOfMemoryError + ? ExoPlaybackException.createForOutOfMemoryError((OutOfMemoryError) e) + : ExoPlaybackException.createForUnexpected((RuntimeException) e); + stopInternal( + /* forceResetRenderers= */ true, + /* resetPositionAndState= */ false, + /* acknowledgeStop= */ false); + playbackInfo = playbackInfo.copyWithPlaybackError(error); + maybeNotifyPlaybackInfoChanged(); + } + return true; + } + + // Private methods. + + private String getExoPlaybackExceptionMessage(ExoPlaybackException e) { + if (e.type != ExoPlaybackException.TYPE_RENDERER) { + return "Playback error."; + } + return "Renderer error: index=" + + e.rendererIndex + + ", type=" + + Util.getTrackTypeString(renderers[e.rendererIndex].getTrackType()) + + ", format=" + + e.rendererFormat + + ", rendererSupport=" + + RendererCapabilities.getFormatSupportString(e.rendererFormatSupport); + } + + private void setState(int state) { + if (playbackInfo.playbackState != state) { + playbackInfo = playbackInfo.copyWithPlaybackState(state); + } + } + + private void maybeNotifyPlaybackInfoChanged() { + if (playbackInfoUpdate.hasPendingUpdate(playbackInfo)) { + eventHandler + .obtainMessage( + MSG_PLAYBACK_INFO_CHANGED, + playbackInfoUpdate.operationAcks, + playbackInfoUpdate.positionDiscontinuity + ? playbackInfoUpdate.discontinuityReason + : C.INDEX_UNSET, + playbackInfo) + .sendToTarget(); + playbackInfoUpdate.reset(playbackInfo); + } + } + + private void prepareInternal(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + pendingPrepareCount++; + resetInternal( + /* resetRenderers= */ false, + /* releaseMediaSource= */ true, + resetPosition, + resetState, + /* resetError= */ true); + loadControl.onPrepared(); + this.mediaSource = mediaSource; + setState(Player.STATE_BUFFERING); + mediaSource.prepareSource(/* caller= */ this, bandwidthMeter.getTransferListener()); + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + + private void setPlayWhenReadyInternal(boolean playWhenReady) throws ExoPlaybackException { + rebuffering = false; + this.playWhenReady = playWhenReady; + if (!playWhenReady) { + stopRenderers(); + updatePlaybackPositions(); + } else { + if (playbackInfo.playbackState == Player.STATE_READY) { + startRenderers(); + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } else if (playbackInfo.playbackState == Player.STATE_BUFFERING) { + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + } + } + + private void setRepeatModeInternal(@Player.RepeatMode int repeatMode) + throws ExoPlaybackException { + this.repeatMode = repeatMode; + if (!queue.updateRepeatMode(repeatMode)) { + seekToCurrentPosition(/* sendDiscontinuity= */ true); + } + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + } + + private void setShuffleModeEnabledInternal(boolean shuffleModeEnabled) + throws ExoPlaybackException { + this.shuffleModeEnabled = shuffleModeEnabled; + if (!queue.updateShuffleModeEnabled(shuffleModeEnabled)) { + seekToCurrentPosition(/* sendDiscontinuity= */ true); + } + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + } + + private void seekToCurrentPosition(boolean sendDiscontinuity) throws ExoPlaybackException { + // Renderers may have read from a period that's been removed. Seek back to the current + // position of the playing period to make sure none of the removed period is played. + MediaPeriodId periodId = queue.getPlayingPeriod().info.id; + long newPositionUs = + seekToPeriodPosition(periodId, playbackInfo.positionUs, /* forceDisableRenderers= */ true); + if (newPositionUs != playbackInfo.positionUs) { + playbackInfo = copyWithNewPosition(periodId, newPositionUs, playbackInfo.contentPositionUs); + if (sendDiscontinuity) { + playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL); + } + } + } + + private void startRenderers() throws ExoPlaybackException { + rebuffering = false; + mediaClock.start(); + for (Renderer renderer : enabledRenderers) { + renderer.start(); + } + } + + private void stopRenderers() throws ExoPlaybackException { + mediaClock.stop(); + for (Renderer renderer : enabledRenderers) { + ensureStopped(renderer); + } + } + + private void updatePlaybackPositions() throws ExoPlaybackException { + MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + if (playingPeriodHolder == null) { + return; + } + + // Update the playback position. + long discontinuityPositionUs = + playingPeriodHolder.prepared + ? playingPeriodHolder.mediaPeriod.readDiscontinuity() + : C.TIME_UNSET; + if (discontinuityPositionUs != C.TIME_UNSET) { + resetRendererPosition(discontinuityPositionUs); + // A MediaPeriod may report a discontinuity at the current playback position to ensure the + // renderers are flushed. Only report the discontinuity externally if the position changed. + if (discontinuityPositionUs != playbackInfo.positionUs) { + playbackInfo = + copyWithNewPosition( + playbackInfo.periodId, discontinuityPositionUs, playbackInfo.contentPositionUs); + playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL); + } + } else { + rendererPositionUs = + mediaClock.syncAndGetPositionUs( + /* isReadingAhead= */ playingPeriodHolder != queue.getReadingPeriod()); + long periodPositionUs = playingPeriodHolder.toPeriodTime(rendererPositionUs); + maybeTriggerPendingMessages(playbackInfo.positionUs, periodPositionUs); + playbackInfo.positionUs = periodPositionUs; + } + + // Update the buffered position and total buffered duration. + MediaPeriodHolder loadingPeriod = queue.getLoadingPeriod(); + playbackInfo.bufferedPositionUs = loadingPeriod.getBufferedPositionUs(); + playbackInfo.totalBufferedDurationUs = getTotalBufferedDurationUs(); + } + + private void doSomeWork() throws ExoPlaybackException, IOException { + long operationStartTimeMs = clock.uptimeMillis(); + updatePeriods(); + + if (playbackInfo.playbackState == Player.STATE_IDLE + || playbackInfo.playbackState == Player.STATE_ENDED) { + // Remove all messages. Prepare (in case of IDLE) or seek (in case of ENDED) will resume. + handler.removeMessages(MSG_DO_SOME_WORK); + return; + } + + @Nullable MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + if (playingPeriodHolder == null) { + // We're still waiting until the playing period is available. + scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + return; + } + + TraceUtil.beginSection("doSomeWork"); + + updatePlaybackPositions(); + + boolean renderersEnded = true; + boolean renderersAllowPlayback = true; + if (playingPeriodHolder.prepared) { + long rendererPositionElapsedRealtimeUs = SystemClock.elapsedRealtime() * 1000; + playingPeriodHolder.mediaPeriod.discardBuffer( + playbackInfo.positionUs - backBufferDurationUs, retainBackBufferFromKeyframe); + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + if (renderer.getState() == Renderer.STATE_DISABLED) { + continue; + } + // TODO: Each renderer should return the maximum delay before which it wishes to be called + // again. The minimum of these values should then be used as the delay before the next + // invocation of this method. + renderer.render(rendererPositionUs, rendererPositionElapsedRealtimeUs); + renderersEnded = renderersEnded && renderer.isEnded(); + // Determine whether the renderer allows playback to continue. Playback can continue if the + // renderer is ready or ended. Also continue playback if the renderer is reading ahead into + // the next stream or is waiting for the next stream. This is to avoid getting stuck if + // tracks in the current period have uneven durations and are still being read by another + // renderer. See: https://github.com/google/ExoPlayer/issues/1874. + boolean isReadingAhead = playingPeriodHolder.sampleStreams[i] != renderer.getStream(); + boolean isWaitingForNextStream = + !isReadingAhead + && playingPeriodHolder.getNext() != null + && renderer.hasReadStreamToEnd(); + boolean allowsPlayback = + isReadingAhead || isWaitingForNextStream || renderer.isReady() || renderer.isEnded(); + renderersAllowPlayback = renderersAllowPlayback && allowsPlayback; + if (!allowsPlayback) { + renderer.maybeThrowStreamError(); + } + } + } else { + playingPeriodHolder.mediaPeriod.maybeThrowPrepareError(); + } + + long playingPeriodDurationUs = playingPeriodHolder.info.durationUs; + if (renderersEnded + && playingPeriodHolder.prepared + && (playingPeriodDurationUs == C.TIME_UNSET + || playingPeriodDurationUs <= playbackInfo.positionUs) + && playingPeriodHolder.info.isFinal) { + setState(Player.STATE_ENDED); + stopRenderers(); + } else if (playbackInfo.playbackState == Player.STATE_BUFFERING + && shouldTransitionToReadyState(renderersAllowPlayback)) { + setState(Player.STATE_READY); + if (playWhenReady) { + startRenderers(); + } + } else if (playbackInfo.playbackState == Player.STATE_READY + && !(enabledRenderers.length == 0 ? isTimelineReady() : renderersAllowPlayback)) { + rebuffering = playWhenReady; + setState(Player.STATE_BUFFERING); + stopRenderers(); + } + + if (playbackInfo.playbackState == Player.STATE_BUFFERING) { + for (Renderer renderer : enabledRenderers) { + renderer.maybeThrowStreamError(); + } + } + + if ((playWhenReady && playbackInfo.playbackState == Player.STATE_READY) + || playbackInfo.playbackState == Player.STATE_BUFFERING) { + scheduleNextWork(operationStartTimeMs, ACTIVE_INTERVAL_MS); + } else if (enabledRenderers.length != 0 && playbackInfo.playbackState != Player.STATE_ENDED) { + scheduleNextWork(operationStartTimeMs, IDLE_INTERVAL_MS); + } else { + handler.removeMessages(MSG_DO_SOME_WORK); + } + + TraceUtil.endSection(); + } + + private void scheduleNextWork(long thisOperationStartTimeMs, long intervalMs) { + handler.removeMessages(MSG_DO_SOME_WORK); + handler.sendEmptyMessageAtTime(MSG_DO_SOME_WORK, thisOperationStartTimeMs + intervalMs); + } + + private void seekToInternal(SeekPosition seekPosition) throws ExoPlaybackException { + playbackInfoUpdate.incrementPendingOperationAcks(/* operationAcks= */ 1); + + MediaPeriodId periodId; + long periodPositionUs; + long contentPositionUs; + boolean seekPositionAdjusted; + Pair<Object, Long> resolvedSeekPosition = + resolveSeekPosition(seekPosition, /* trySubsequentPeriods= */ true); + if (resolvedSeekPosition == null) { + // The seek position was valid for the timeline that it was performed into, but the + // timeline has changed or is not ready and a suitable seek position could not be resolved. + periodId = playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period); + periodPositionUs = C.TIME_UNSET; + contentPositionUs = C.TIME_UNSET; + seekPositionAdjusted = true; + } else { + // Update the resolved seek position to take ads into account. + Object periodUid = resolvedSeekPosition.first; + contentPositionUs = resolvedSeekPosition.second; + periodId = queue.resolveMediaPeriodIdForAds(periodUid, contentPositionUs); + if (periodId.isAd()) { + periodPositionUs = 0; + seekPositionAdjusted = true; + } else { + periodPositionUs = resolvedSeekPosition.second; + seekPositionAdjusted = seekPosition.windowPositionUs == C.TIME_UNSET; + } + } + + try { + if (mediaSource == null || pendingPrepareCount > 0) { + // Save seek position for later, as we are still waiting for a prepared source. + pendingInitialSeekPosition = seekPosition; + } else if (periodPositionUs == C.TIME_UNSET) { + // End playback, as we didn't manage to find a valid seek position. + setState(Player.STATE_ENDED); + resetInternal( + /* resetRenderers= */ false, + /* releaseMediaSource= */ false, + /* resetPosition= */ true, + /* resetState= */ false, + /* resetError= */ true); + } else { + // Execute the seek in the current media periods. + long newPeriodPositionUs = periodPositionUs; + if (periodId.equals(playbackInfo.periodId)) { + MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + if (playingPeriodHolder != null + && playingPeriodHolder.prepared + && newPeriodPositionUs != 0) { + newPeriodPositionUs = + playingPeriodHolder.mediaPeriod.getAdjustedSeekPositionUs( + newPeriodPositionUs, seekParameters); + } + if (C.usToMs(newPeriodPositionUs) == C.usToMs(playbackInfo.positionUs)) { + // Seek will be performed to the current position. Do nothing. + periodPositionUs = playbackInfo.positionUs; + return; + } + } + newPeriodPositionUs = seekToPeriodPosition(periodId, newPeriodPositionUs); + seekPositionAdjusted |= periodPositionUs != newPeriodPositionUs; + periodPositionUs = newPeriodPositionUs; + } + } finally { + playbackInfo = copyWithNewPosition(periodId, periodPositionUs, contentPositionUs); + if (seekPositionAdjusted) { + playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT); + } + } + } + + private long seekToPeriodPosition(MediaPeriodId periodId, long periodPositionUs) + throws ExoPlaybackException { + // Force disable renderers if they are reading from a period other than the one being played. + return seekToPeriodPosition( + periodId, periodPositionUs, queue.getPlayingPeriod() != queue.getReadingPeriod()); + } + + private long seekToPeriodPosition( + MediaPeriodId periodId, long periodPositionUs, boolean forceDisableRenderers) + throws ExoPlaybackException { + stopRenderers(); + rebuffering = false; + if (playbackInfo.playbackState != Player.STATE_IDLE && !playbackInfo.timeline.isEmpty()) { + setState(Player.STATE_BUFFERING); + } + + // Clear the timeline, but keep the requested period if it is already prepared. + MediaPeriodHolder oldPlayingPeriodHolder = queue.getPlayingPeriod(); + MediaPeriodHolder newPlayingPeriodHolder = oldPlayingPeriodHolder; + while (newPlayingPeriodHolder != null) { + if (periodId.equals(newPlayingPeriodHolder.info.id) && newPlayingPeriodHolder.prepared) { + queue.removeAfter(newPlayingPeriodHolder); + break; + } + newPlayingPeriodHolder = queue.advancePlayingPeriod(); + } + + // Disable all renderers if the period being played is changing, if the seek results in negative + // renderer timestamps, or if forced. + if (forceDisableRenderers + || oldPlayingPeriodHolder != newPlayingPeriodHolder + || (newPlayingPeriodHolder != null + && newPlayingPeriodHolder.toRendererTime(periodPositionUs) < 0)) { + for (Renderer renderer : enabledRenderers) { + disableRenderer(renderer); + } + enabledRenderers = new Renderer[0]; + oldPlayingPeriodHolder = null; + if (newPlayingPeriodHolder != null) { + newPlayingPeriodHolder.setRendererOffset(/* rendererPositionOffsetUs= */ 0); + } + } + + // Update the holders. + if (newPlayingPeriodHolder != null) { + updatePlayingPeriodRenderers(oldPlayingPeriodHolder); + if (newPlayingPeriodHolder.hasEnabledTracks) { + periodPositionUs = newPlayingPeriodHolder.mediaPeriod.seekToUs(periodPositionUs); + newPlayingPeriodHolder.mediaPeriod.discardBuffer( + periodPositionUs - backBufferDurationUs, retainBackBufferFromKeyframe); + } + resetRendererPosition(periodPositionUs); + maybeContinueLoading(); + } else { + queue.clear(/* keepFrontPeriodUid= */ true); + // New period has not been prepared. + playbackInfo = + playbackInfo.copyWithTrackInfo(TrackGroupArray.EMPTY, emptyTrackSelectorResult); + resetRendererPosition(periodPositionUs); + } + + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + return periodPositionUs; + } + + private void resetRendererPosition(long periodPositionUs) throws ExoPlaybackException { + MediaPeriodHolder playingMediaPeriod = queue.getPlayingPeriod(); + rendererPositionUs = + playingMediaPeriod == null + ? periodPositionUs + : playingMediaPeriod.toRendererTime(periodPositionUs); + mediaClock.resetPosition(rendererPositionUs); + for (Renderer renderer : enabledRenderers) { + renderer.resetPosition(rendererPositionUs); + } + notifyTrackSelectionDiscontinuity(); + } + + private void setPlaybackParametersInternal(PlaybackParameters playbackParameters) { + mediaClock.setPlaybackParameters(playbackParameters); + sendPlaybackParametersChangedInternal( + mediaClock.getPlaybackParameters(), /* acknowledgeCommand= */ true); + } + + private void setSeekParametersInternal(SeekParameters seekParameters) { + this.seekParameters = seekParameters; + } + + private void setForegroundModeInternal( + boolean foregroundMode, @Nullable AtomicBoolean processedFlag) { + if (this.foregroundMode != foregroundMode) { + this.foregroundMode = foregroundMode; + if (!foregroundMode) { + for (Renderer renderer : renderers) { + if (renderer.getState() == Renderer.STATE_DISABLED) { + renderer.reset(); + } + } + } + } + if (processedFlag != null) { + synchronized (this) { + processedFlag.set(true); + notifyAll(); + } + } + } + + private void stopInternal( + boolean forceResetRenderers, boolean resetPositionAndState, boolean acknowledgeStop) { + resetInternal( + /* resetRenderers= */ forceResetRenderers || !foregroundMode, + /* releaseMediaSource= */ true, + /* resetPosition= */ resetPositionAndState, + /* resetState= */ resetPositionAndState, + /* resetError= */ resetPositionAndState); + playbackInfoUpdate.incrementPendingOperationAcks( + pendingPrepareCount + (acknowledgeStop ? 1 : 0)); + pendingPrepareCount = 0; + loadControl.onStopped(); + setState(Player.STATE_IDLE); + } + + private void releaseInternal() { + resetInternal( + /* resetRenderers= */ true, + /* releaseMediaSource= */ true, + /* resetPosition= */ true, + /* resetState= */ true, + /* resetError= */ false); + loadControl.onReleased(); + setState(Player.STATE_IDLE); + internalPlaybackThread.quit(); + synchronized (this) { + released = true; + notifyAll(); + } + } + + private void resetInternal( + boolean resetRenderers, + boolean releaseMediaSource, + boolean resetPosition, + boolean resetState, + boolean resetError) { + handler.removeMessages(MSG_DO_SOME_WORK); + rebuffering = false; + mediaClock.stop(); + rendererPositionUs = 0; + for (Renderer renderer : enabledRenderers) { + try { + disableRenderer(renderer); + } catch (ExoPlaybackException | RuntimeException e) { + // There's nothing we can do. + Log.e(TAG, "Disable failed.", e); + } + } + if (resetRenderers) { + for (Renderer renderer : renderers) { + try { + renderer.reset(); + } catch (RuntimeException e) { + // There's nothing we can do. + Log.e(TAG, "Reset failed.", e); + } + } + } + enabledRenderers = new Renderer[0]; + + if (resetPosition) { + pendingInitialSeekPosition = null; + } else if (resetState) { + // When resetting the state, also reset the period-based PlaybackInfo position and convert + // existing position to initial seek instead. + resetPosition = true; + if (pendingInitialSeekPosition == null && !playbackInfo.timeline.isEmpty()) { + playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period); + long windowPositionUs = playbackInfo.positionUs + period.getPositionInWindowUs(); + pendingInitialSeekPosition = + new SeekPosition(Timeline.EMPTY, period.windowIndex, windowPositionUs); + } + } + + queue.clear(/* keepFrontPeriodUid= */ !resetState); + shouldContinueLoading = false; + if (resetState) { + queue.setTimeline(Timeline.EMPTY); + for (PendingMessageInfo pendingMessageInfo : pendingMessages) { + pendingMessageInfo.message.markAsProcessed(/* isDelivered= */ false); + } + pendingMessages.clear(); + nextPendingMessageIndex = 0; + } + MediaPeriodId mediaPeriodId = + resetPosition + ? playbackInfo.getDummyFirstMediaPeriodId(shuffleModeEnabled, window, period) + : playbackInfo.periodId; + // Set the start position to TIME_UNSET so that a subsequent seek to 0 isn't ignored. + long startPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.positionUs; + long contentPositionUs = resetPosition ? C.TIME_UNSET : playbackInfo.contentPositionUs; + playbackInfo = + new PlaybackInfo( + resetState ? Timeline.EMPTY : playbackInfo.timeline, + mediaPeriodId, + startPositionUs, + contentPositionUs, + playbackInfo.playbackState, + resetError ? null : playbackInfo.playbackError, + /* isLoading= */ false, + resetState ? TrackGroupArray.EMPTY : playbackInfo.trackGroups, + resetState ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult, + mediaPeriodId, + startPositionUs, + /* totalBufferedDurationUs= */ 0, + startPositionUs); + if (releaseMediaSource) { + if (mediaSource != null) { + mediaSource.releaseSource(/* caller= */ this); + mediaSource = null; + } + } + } + + private void sendMessageInternal(PlayerMessage message) throws ExoPlaybackException { + if (message.getPositionMs() == C.TIME_UNSET) { + // If no delivery time is specified, trigger immediate message delivery. + sendMessageToTarget(message); + } else if (mediaSource == null || pendingPrepareCount > 0) { + // Still waiting for initial timeline to resolve position. + pendingMessages.add(new PendingMessageInfo(message)); + } else { + PendingMessageInfo pendingMessageInfo = new PendingMessageInfo(message); + if (resolvePendingMessagePosition(pendingMessageInfo)) { + pendingMessages.add(pendingMessageInfo); + // Ensure new message is inserted according to playback order. + Collections.sort(pendingMessages); + } else { + message.markAsProcessed(/* isDelivered= */ false); + } + } + } + + private void sendMessageToTarget(PlayerMessage message) throws ExoPlaybackException { + if (message.getHandler().getLooper() == handler.getLooper()) { + deliverMessage(message); + if (playbackInfo.playbackState == Player.STATE_READY + || playbackInfo.playbackState == Player.STATE_BUFFERING) { + // The message may have caused something to change that now requires us to do work. + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + } else { + handler.obtainMessage(MSG_SEND_MESSAGE_TO_TARGET_THREAD, message).sendToTarget(); + } + } + + private void sendMessageToTargetThread(final PlayerMessage message) { + Handler handler = message.getHandler(); + if (!handler.getLooper().getThread().isAlive()) { + Log.w("TAG", "Trying to send message on a dead thread."); + message.markAsProcessed(/* isDelivered= */ false); + return; + } + handler.post( + () -> { + try { + deliverMessage(message); + } catch (ExoPlaybackException e) { + Log.e(TAG, "Unexpected error delivering message on external thread.", e); + throw new RuntimeException(e); + } + }); + } + + private void deliverMessage(PlayerMessage message) throws ExoPlaybackException { + if (message.isCanceled()) { + return; + } + try { + message.getTarget().handleMessage(message.getType(), message.getPayload()); + } finally { + message.markAsProcessed(/* isDelivered= */ true); + } + } + + private void resolvePendingMessagePositions() { + for (int i = pendingMessages.size() - 1; i >= 0; i--) { + if (!resolvePendingMessagePosition(pendingMessages.get(i))) { + // Unable to resolve a new position for the message. Remove it. + pendingMessages.get(i).message.markAsProcessed(/* isDelivered= */ false); + pendingMessages.remove(i); + } + } + // Re-sort messages by playback order. + Collections.sort(pendingMessages); + } + + private boolean resolvePendingMessagePosition(PendingMessageInfo pendingMessageInfo) { + if (pendingMessageInfo.resolvedPeriodUid == null) { + // Position is still unresolved. Try to find window in current timeline. + Pair<Object, Long> periodPosition = + resolveSeekPosition( + new SeekPosition( + pendingMessageInfo.message.getTimeline(), + pendingMessageInfo.message.getWindowIndex(), + C.msToUs(pendingMessageInfo.message.getPositionMs())), + /* trySubsequentPeriods= */ false); + if (periodPosition == null) { + return false; + } + pendingMessageInfo.setResolvedPosition( + playbackInfo.timeline.getIndexOfPeriod(periodPosition.first), + periodPosition.second, + periodPosition.first); + } else { + // Position has been resolved for a previous timeline. Try to find the updated period index. + int index = playbackInfo.timeline.getIndexOfPeriod(pendingMessageInfo.resolvedPeriodUid); + if (index == C.INDEX_UNSET) { + return false; + } + pendingMessageInfo.resolvedPeriodIndex = index; + } + return true; + } + + private void maybeTriggerPendingMessages(long oldPeriodPositionUs, long newPeriodPositionUs) + throws ExoPlaybackException { + if (pendingMessages.isEmpty() || playbackInfo.periodId.isAd()) { + return; + } + // If this is the first call from the start position, include oldPeriodPositionUs in potential + // trigger positions, but make sure we deliver it only once. + if (playbackInfo.startPositionUs == oldPeriodPositionUs + && deliverPendingMessageAtStartPositionRequired) { + oldPeriodPositionUs--; + } + deliverPendingMessageAtStartPositionRequired = false; + + // Correct next index if necessary (e.g. after seeking, timeline changes, or new messages) + int currentPeriodIndex = + playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid); + PendingMessageInfo previousInfo = + nextPendingMessageIndex > 0 ? pendingMessages.get(nextPendingMessageIndex - 1) : null; + while (previousInfo != null + && (previousInfo.resolvedPeriodIndex > currentPeriodIndex + || (previousInfo.resolvedPeriodIndex == currentPeriodIndex + && previousInfo.resolvedPeriodTimeUs > oldPeriodPositionUs))) { + nextPendingMessageIndex--; + previousInfo = + nextPendingMessageIndex > 0 ? pendingMessages.get(nextPendingMessageIndex - 1) : null; + } + PendingMessageInfo nextInfo = + nextPendingMessageIndex < pendingMessages.size() + ? pendingMessages.get(nextPendingMessageIndex) + : null; + while (nextInfo != null + && nextInfo.resolvedPeriodUid != null + && (nextInfo.resolvedPeriodIndex < currentPeriodIndex + || (nextInfo.resolvedPeriodIndex == currentPeriodIndex + && nextInfo.resolvedPeriodTimeUs <= oldPeriodPositionUs))) { + nextPendingMessageIndex++; + nextInfo = + nextPendingMessageIndex < pendingMessages.size() + ? pendingMessages.get(nextPendingMessageIndex) + : null; + } + // Check if any message falls within the covered time span. + while (nextInfo != null + && nextInfo.resolvedPeriodUid != null + && nextInfo.resolvedPeriodIndex == currentPeriodIndex + && nextInfo.resolvedPeriodTimeUs > oldPeriodPositionUs + && nextInfo.resolvedPeriodTimeUs <= newPeriodPositionUs) { + try { + sendMessageToTarget(nextInfo.message); + } finally { + if (nextInfo.message.getDeleteAfterDelivery() || nextInfo.message.isCanceled()) { + pendingMessages.remove(nextPendingMessageIndex); + } else { + nextPendingMessageIndex++; + } + } + nextInfo = + nextPendingMessageIndex < pendingMessages.size() + ? pendingMessages.get(nextPendingMessageIndex) + : null; + } + } + + private void ensureStopped(Renderer renderer) throws ExoPlaybackException { + if (renderer.getState() == Renderer.STATE_STARTED) { + renderer.stop(); + } + } + + private void disableRenderer(Renderer renderer) throws ExoPlaybackException { + mediaClock.onRendererDisabled(renderer); + ensureStopped(renderer); + renderer.disable(); + } + + private void reselectTracksInternal() throws ExoPlaybackException { + float playbackSpeed = mediaClock.getPlaybackParameters().speed; + // Reselect tracks on each period in turn, until the selection changes. + MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); + MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + boolean selectionsChangedForReadPeriod = true; + TrackSelectorResult newTrackSelectorResult; + while (true) { + if (periodHolder == null || !periodHolder.prepared) { + // The reselection did not change any prepared periods. + return; + } + newTrackSelectorResult = periodHolder.selectTracks(playbackSpeed, playbackInfo.timeline); + if (!newTrackSelectorResult.isEquivalent(periodHolder.getTrackSelectorResult())) { + // Selected tracks have changed for this period. + break; + } + if (periodHolder == readingPeriodHolder) { + // The track reselection didn't affect any period that has been read. + selectionsChangedForReadPeriod = false; + } + periodHolder = periodHolder.getNext(); + } + + if (selectionsChangedForReadPeriod) { + // Update streams and rebuffer for the new selection, recreating all streams if reading ahead. + MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + boolean recreateStreams = queue.removeAfter(playingPeriodHolder); + + boolean[] streamResetFlags = new boolean[renderers.length]; + long periodPositionUs = + playingPeriodHolder.applyTrackSelection( + newTrackSelectorResult, playbackInfo.positionUs, recreateStreams, streamResetFlags); + if (playbackInfo.playbackState != Player.STATE_ENDED + && periodPositionUs != playbackInfo.positionUs) { + playbackInfo = + copyWithNewPosition( + playbackInfo.periodId, periodPositionUs, playbackInfo.contentPositionUs); + playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL); + resetRendererPosition(periodPositionUs); + } + + int enabledRendererCount = 0; + boolean[] rendererWasEnabledFlags = new boolean[renderers.length]; + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + rendererWasEnabledFlags[i] = renderer.getState() != Renderer.STATE_DISABLED; + SampleStream sampleStream = playingPeriodHolder.sampleStreams[i]; + if (sampleStream != null) { + enabledRendererCount++; + } + if (rendererWasEnabledFlags[i]) { + if (sampleStream != renderer.getStream()) { + // We need to disable the renderer. + disableRenderer(renderer); + } else if (streamResetFlags[i]) { + // The renderer will continue to consume from its current stream, but needs to be reset. + renderer.resetPosition(rendererPositionUs); + } + } + } + playbackInfo = + playbackInfo.copyWithTrackInfo( + playingPeriodHolder.getTrackGroups(), playingPeriodHolder.getTrackSelectorResult()); + enableRenderers(rendererWasEnabledFlags, enabledRendererCount); + } else { + // Release and re-prepare/buffer periods after the one whose selection changed. + queue.removeAfter(periodHolder); + if (periodHolder.prepared) { + long loadingPeriodPositionUs = + Math.max( + periodHolder.info.startPositionUs, periodHolder.toPeriodTime(rendererPositionUs)); + periodHolder.applyTrackSelection(newTrackSelectorResult, loadingPeriodPositionUs, false); + } + } + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ true); + if (playbackInfo.playbackState != Player.STATE_ENDED) { + maybeContinueLoading(); + updatePlaybackPositions(); + handler.sendEmptyMessage(MSG_DO_SOME_WORK); + } + } + + private void updateTrackSelectionPlaybackSpeed(float playbackSpeed) { + MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); + while (periodHolder != null) { + TrackSelection[] trackSelections = periodHolder.getTrackSelectorResult().selections.getAll(); + for (TrackSelection trackSelection : trackSelections) { + if (trackSelection != null) { + trackSelection.onPlaybackSpeed(playbackSpeed); + } + } + periodHolder = periodHolder.getNext(); + } + } + + private void notifyTrackSelectionDiscontinuity() { + MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); + while (periodHolder != null) { + TrackSelection[] trackSelections = periodHolder.getTrackSelectorResult().selections.getAll(); + for (TrackSelection trackSelection : trackSelections) { + if (trackSelection != null) { + trackSelection.onDiscontinuity(); + } + } + periodHolder = periodHolder.getNext(); + } + } + + private boolean shouldTransitionToReadyState(boolean renderersReadyOrEnded) { + if (enabledRenderers.length == 0) { + // If there are no enabled renderers, determine whether we're ready based on the timeline. + return isTimelineReady(); + } + if (!renderersReadyOrEnded) { + return false; + } + if (!playbackInfo.isLoading) { + // Renderers are ready and we're not loading. Transition to ready, since the alternative is + // getting stuck waiting for additional media that's not being loaded. + return true; + } + // Renderers are ready and we're loading. Ask the LoadControl whether to transition. + MediaPeriodHolder loadingHolder = queue.getLoadingPeriod(); + boolean bufferedToEnd = loadingHolder.isFullyBuffered() && loadingHolder.info.isFinal; + return bufferedToEnd + || loadControl.shouldStartPlayback( + getTotalBufferedDurationUs(), mediaClock.getPlaybackParameters().speed, rebuffering); + } + + private boolean isTimelineReady() { + MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + long playingPeriodDurationUs = playingPeriodHolder.info.durationUs; + return playingPeriodHolder.prepared + && (playingPeriodDurationUs == C.TIME_UNSET + || playbackInfo.positionUs < playingPeriodDurationUs); + } + + private void maybeThrowSourceInfoRefreshError() throws IOException { + MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); + if (loadingPeriodHolder != null) { + // Defer throwing until we read all available media periods. + for (Renderer renderer : enabledRenderers) { + if (!renderer.hasReadStreamToEnd()) { + return; + } + } + } + mediaSource.maybeThrowSourceInfoRefreshError(); + } + + private void handleSourceInfoRefreshed(MediaSourceRefreshInfo sourceRefreshInfo) + throws ExoPlaybackException { + if (sourceRefreshInfo.source != mediaSource) { + // Stale event. + return; + } + playbackInfoUpdate.incrementPendingOperationAcks(pendingPrepareCount); + pendingPrepareCount = 0; + + Timeline oldTimeline = playbackInfo.timeline; + Timeline timeline = sourceRefreshInfo.timeline; + queue.setTimeline(timeline); + playbackInfo = playbackInfo.copyWithTimeline(timeline); + resolvePendingMessagePositions(); + + MediaPeriodId newPeriodId = playbackInfo.periodId; + long oldContentPositionUs = + playbackInfo.periodId.isAd() ? playbackInfo.contentPositionUs : playbackInfo.positionUs; + long newContentPositionUs = oldContentPositionUs; + if (pendingInitialSeekPosition != null) { + // Resolve initial seek position. + Pair<Object, Long> periodPosition = + resolveSeekPosition(pendingInitialSeekPosition, /* trySubsequentPeriods= */ true); + pendingInitialSeekPosition = null; + if (periodPosition == null) { + // The seek position was valid for the timeline that it was performed into, but the + // timeline has changed and a suitable seek position could not be resolved in the new one. + handleSourceInfoRefreshEndedPlayback(); + return; + } + newContentPositionUs = periodPosition.second; + newPeriodId = queue.resolveMediaPeriodIdForAds(periodPosition.first, newContentPositionUs); + } else if (oldContentPositionUs == C.TIME_UNSET && !timeline.isEmpty()) { + // Resolve unset start position to default position. + Pair<Object, Long> defaultPosition = + getPeriodPosition( + timeline, timeline.getFirstWindowIndex(shuffleModeEnabled), C.TIME_UNSET); + newPeriodId = queue.resolveMediaPeriodIdForAds(defaultPosition.first, defaultPosition.second); + if (!newPeriodId.isAd()) { + // Keep unset start position if we need to play an ad first. + newContentPositionUs = defaultPosition.second; + } + } else if (timeline.getIndexOfPeriod(newPeriodId.periodUid) == C.INDEX_UNSET) { + // The current period isn't in the new timeline. Attempt to resolve a subsequent period whose + // window we can restart from. + Object newPeriodUid = resolveSubsequentPeriod(newPeriodId.periodUid, oldTimeline, timeline); + if (newPeriodUid == null) { + // We failed to resolve a suitable restart position. + handleSourceInfoRefreshEndedPlayback(); + return; + } + // We resolved a subsequent period. Start at the default position in the corresponding window. + Pair<Object, Long> defaultPosition = + getPeriodPosition( + timeline, timeline.getPeriodByUid(newPeriodUid, period).windowIndex, C.TIME_UNSET); + newContentPositionUs = defaultPosition.second; + newPeriodId = queue.resolveMediaPeriodIdForAds(defaultPosition.first, newContentPositionUs); + } else { + // Recheck if the current ad still needs to be played or if we need to start playing an ad. + newPeriodId = + queue.resolveMediaPeriodIdForAds(playbackInfo.periodId.periodUid, newContentPositionUs); + if (!playbackInfo.periodId.isAd() && !newPeriodId.isAd()) { + // Drop update if we keep playing the same content (MediaPeriod.periodUid are identical) and + // only MediaPeriodId.nextAdGroupIndex may have changed. This postpones a potential + // discontinuity until we reach the former next ad group position. + newPeriodId = playbackInfo.periodId; + } + } + + if (playbackInfo.periodId.equals(newPeriodId) && oldContentPositionUs == newContentPositionUs) { + // We can keep the current playing period. Update the rest of the queued periods. + if (!queue.updateQueuedPeriods(rendererPositionUs, getMaxRendererReadPositionUs())) { + seekToCurrentPosition(/* sendDiscontinuity= */ false); + } + } else { + // Something changed. Seek to new start position. + MediaPeriodHolder periodHolder = queue.getPlayingPeriod(); + if (periodHolder != null) { + // Update the new playing media period info if it already exists. + while (periodHolder.getNext() != null) { + periodHolder = periodHolder.getNext(); + if (periodHolder.info.id.equals(newPeriodId)) { + periodHolder.info = queue.getUpdatedMediaPeriodInfo(periodHolder.info); + } + } + } + // Actually do the seek. + long newPositionUs = newPeriodId.isAd() ? 0 : newContentPositionUs; + long seekedToPositionUs = seekToPeriodPosition(newPeriodId, newPositionUs); + playbackInfo = copyWithNewPosition(newPeriodId, seekedToPositionUs, newContentPositionUs); + } + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + } + + private long getMaxRendererReadPositionUs() { + MediaPeriodHolder readingHolder = queue.getReadingPeriod(); + if (readingHolder == null) { + return 0; + } + long maxReadPositionUs = readingHolder.getRendererOffset(); + if (!readingHolder.prepared) { + return maxReadPositionUs; + } + for (int i = 0; i < renderers.length; i++) { + if (renderers[i].getState() == Renderer.STATE_DISABLED + || renderers[i].getStream() != readingHolder.sampleStreams[i]) { + // Ignore disabled renderers and renderers with sample streams from previous periods. + continue; + } + long readingPositionUs = renderers[i].getReadingPositionUs(); + if (readingPositionUs == C.TIME_END_OF_SOURCE) { + return C.TIME_END_OF_SOURCE; + } else { + maxReadPositionUs = Math.max(readingPositionUs, maxReadPositionUs); + } + } + return maxReadPositionUs; + } + + private void handleSourceInfoRefreshEndedPlayback() { + if (playbackInfo.playbackState != Player.STATE_IDLE) { + setState(Player.STATE_ENDED); + } + // Reset, but retain the source so that it can still be used should a seek occur. + resetInternal( + /* resetRenderers= */ false, + /* releaseMediaSource= */ false, + /* resetPosition= */ true, + /* resetState= */ false, + /* resetError= */ true); + } + + /** + * Given a period index into an old timeline, finds the first subsequent period that also exists + * in a new timeline. The uid of this period in the new timeline is returned. + * + * @param oldPeriodUid The index of the period in the old timeline. + * @param oldTimeline The old timeline. + * @param newTimeline The new timeline. + * @return The uid in the new timeline of the first subsequent period, or null if no such period + * was found. + */ + private @Nullable Object resolveSubsequentPeriod( + Object oldPeriodUid, Timeline oldTimeline, Timeline newTimeline) { + int oldPeriodIndex = oldTimeline.getIndexOfPeriod(oldPeriodUid); + int newPeriodIndex = C.INDEX_UNSET; + int maxIterations = oldTimeline.getPeriodCount(); + for (int i = 0; i < maxIterations && newPeriodIndex == C.INDEX_UNSET; i++) { + oldPeriodIndex = + oldTimeline.getNextPeriodIndex( + oldPeriodIndex, period, window, repeatMode, shuffleModeEnabled); + if (oldPeriodIndex == C.INDEX_UNSET) { + // We've reached the end of the old timeline. + break; + } + newPeriodIndex = newTimeline.getIndexOfPeriod(oldTimeline.getUidOfPeriod(oldPeriodIndex)); + } + return newPeriodIndex == C.INDEX_UNSET ? null : newTimeline.getUidOfPeriod(newPeriodIndex); + } + + /** + * Converts a {@link SeekPosition} into the corresponding (periodUid, periodPositionUs) for the + * internal timeline. + * + * @param seekPosition The position to resolve. + * @param trySubsequentPeriods Whether the position can be resolved to a subsequent matching + * period if the original period is no longer available. + * @return The resolved position, or null if resolution was not successful. + * @throws IllegalSeekPositionException If the window index of the seek position is outside the + * bounds of the timeline. + */ + @Nullable + private Pair<Object, Long> resolveSeekPosition( + SeekPosition seekPosition, boolean trySubsequentPeriods) { + Timeline timeline = playbackInfo.timeline; + Timeline seekTimeline = seekPosition.timeline; + if (timeline.isEmpty()) { + // We don't have a valid timeline yet, so we can't resolve the position. + return null; + } + if (seekTimeline.isEmpty()) { + // The application performed a blind seek with an empty timeline (most likely based on + // knowledge of what the future timeline will be). Use the internal timeline. + seekTimeline = timeline; + } + // Map the SeekPosition to a position in the corresponding timeline. + Pair<Object, Long> periodPosition; + try { + periodPosition = + seekTimeline.getPeriodPosition( + window, period, seekPosition.windowIndex, seekPosition.windowPositionUs); + } catch (IndexOutOfBoundsException e) { + // The window index of the seek position was outside the bounds of the timeline. + return null; + } + if (timeline == seekTimeline) { + // Our internal timeline is the seek timeline, so the mapped position is correct. + return periodPosition; + } + // Attempt to find the mapped period in the internal timeline. + int periodIndex = timeline.getIndexOfPeriod(periodPosition.first); + if (periodIndex != C.INDEX_UNSET) { + // We successfully located the period in the internal timeline. + return periodPosition; + } + if (trySubsequentPeriods) { + // Try and find a subsequent period from the seek timeline in the internal timeline. + @Nullable + Object periodUid = resolveSubsequentPeriod(periodPosition.first, seekTimeline, timeline); + if (periodUid != null) { + // We found one. Use the default position of the corresponding window. + return getPeriodPosition( + timeline, timeline.getPeriodByUid(periodUid, period).windowIndex, C.TIME_UNSET); + } + } + // We didn't find one. Give up. + return null; + } + + /** + * Calls {@link Timeline#getPeriodPosition(Timeline.Window, Timeline.Period, int, long)} using the + * current timeline. + */ + private Pair<Object, Long> getPeriodPosition( + Timeline timeline, int windowIndex, long windowPositionUs) { + return timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs); + } + + private void updatePeriods() throws ExoPlaybackException, IOException { + if (mediaSource == null) { + // The player has no media source yet. + return; + } + if (pendingPrepareCount > 0) { + // We're waiting to get information about periods. + mediaSource.maybeThrowSourceInfoRefreshError(); + return; + } + maybeUpdateLoadingPeriod(); + maybeUpdateReadingPeriod(); + maybeUpdatePlayingPeriod(); + } + + private void maybeUpdateLoadingPeriod() throws ExoPlaybackException, IOException { + queue.reevaluateBuffer(rendererPositionUs); + if (queue.shouldLoadNextMediaPeriod()) { + MediaPeriodInfo info = queue.getNextMediaPeriodInfo(rendererPositionUs, playbackInfo); + if (info == null) { + maybeThrowSourceInfoRefreshError(); + } else { + MediaPeriodHolder mediaPeriodHolder = + queue.enqueueNextMediaPeriodHolder( + rendererCapabilities, + trackSelector, + loadControl.getAllocator(), + mediaSource, + info, + emptyTrackSelectorResult); + mediaPeriodHolder.mediaPeriod.prepare(this, info.startPositionUs); + if (queue.getPlayingPeriod() == mediaPeriodHolder) { + resetRendererPosition(mediaPeriodHolder.getStartPositionRendererTime()); + } + handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ false); + } + } + if (shouldContinueLoading) { + shouldContinueLoading = isLoadingPossible(); + updateIsLoading(); + } else { + maybeContinueLoading(); + } + } + + private void maybeUpdateReadingPeriod() throws ExoPlaybackException { + MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + if (readingPeriodHolder == null) { + return; + } + + if (readingPeriodHolder.getNext() == null) { + // We don't have a successor to advance the reading period to. + if (readingPeriodHolder.info.isFinal) { + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + SampleStream sampleStream = readingPeriodHolder.sampleStreams[i]; + // Defer setting the stream as final until the renderer has actually consumed the whole + // stream in case of playlist changes that cause the stream to be no longer final. + if (sampleStream != null + && renderer.getStream() == sampleStream + && renderer.hasReadStreamToEnd()) { + renderer.setCurrentStreamFinal(); + } + } + } + return; + } + + if (!hasReadingPeriodFinishedReading()) { + return; + } + + if (!readingPeriodHolder.getNext().prepared) { + // The successor is not prepared yet. + return; + } + + TrackSelectorResult oldTrackSelectorResult = readingPeriodHolder.getTrackSelectorResult(); + readingPeriodHolder = queue.advanceReadingPeriod(); + TrackSelectorResult newTrackSelectorResult = readingPeriodHolder.getTrackSelectorResult(); + + if (readingPeriodHolder.mediaPeriod.readDiscontinuity() != C.TIME_UNSET) { + // The new period starts with a discontinuity, so the renderers will play out all data, then + // be disabled and re-enabled when they start playing the next period. + setAllRendererStreamsFinal(); + return; + } + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + boolean rendererWasEnabled = oldTrackSelectorResult.isRendererEnabled(i); + if (rendererWasEnabled && !renderer.isCurrentStreamFinal()) { + // The renderer is enabled and its stream is not final, so we still have a chance to replace + // the sample streams. + TrackSelection newSelection = newTrackSelectorResult.selections.get(i); + boolean newRendererEnabled = newTrackSelectorResult.isRendererEnabled(i); + boolean isNoSampleRenderer = rendererCapabilities[i].getTrackType() == C.TRACK_TYPE_NONE; + RendererConfiguration oldConfig = oldTrackSelectorResult.rendererConfigurations[i]; + RendererConfiguration newConfig = newTrackSelectorResult.rendererConfigurations[i]; + if (newRendererEnabled && newConfig.equals(oldConfig) && !isNoSampleRenderer) { + // Replace the renderer's SampleStream so the transition to playing the next period can + // be seamless. + // This should be avoided for no-sample renderer, because skipping ahead for such + // renderer doesn't have any benefit (the renderer does not consume the sample stream), + // and it will change the provided rendererOffsetUs while the renderer is still + // rendering from the playing media period. + Format[] formats = getFormats(newSelection); + renderer.replaceStream( + formats, + readingPeriodHolder.sampleStreams[i], + readingPeriodHolder.getRendererOffset()); + } else { + // The renderer will be disabled when transitioning to playing the next period, because + // there's no new selection, or because a configuration change is required, or because + // it's a no-sample renderer for which rendererOffsetUs should be updated only when + // starting to play the next period. Mark the SampleStream as final to play out any + // remaining data. + renderer.setCurrentStreamFinal(); + } + } + } + } + + private void maybeUpdatePlayingPeriod() throws ExoPlaybackException { + boolean advancedPlayingPeriod = false; + while (shouldAdvancePlayingPeriod()) { + if (advancedPlayingPeriod) { + // If we advance more than one period at a time, notify listeners after each update. + maybeNotifyPlaybackInfoChanged(); + } + MediaPeriodHolder oldPlayingPeriodHolder = queue.getPlayingPeriod(); + if (oldPlayingPeriodHolder == queue.getReadingPeriod()) { + // The reading period hasn't advanced yet, so we can't seamlessly replace the SampleStreams + // anymore and need to re-enable the renderers. Set all current streams final to do that. + setAllRendererStreamsFinal(); + } + MediaPeriodHolder newPlayingPeriodHolder = queue.advancePlayingPeriod(); + updatePlayingPeriodRenderers(oldPlayingPeriodHolder); + playbackInfo = + copyWithNewPosition( + newPlayingPeriodHolder.info.id, + newPlayingPeriodHolder.info.startPositionUs, + newPlayingPeriodHolder.info.contentPositionUs); + int discontinuityReason = + oldPlayingPeriodHolder.info.isLastInTimelinePeriod + ? Player.DISCONTINUITY_REASON_PERIOD_TRANSITION + : Player.DISCONTINUITY_REASON_AD_INSERTION; + playbackInfoUpdate.setPositionDiscontinuity(discontinuityReason); + updatePlaybackPositions(); + advancedPlayingPeriod = true; + } + } + + private boolean shouldAdvancePlayingPeriod() { + if (!playWhenReady) { + return false; + } + MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + if (playingPeriodHolder == null) { + return false; + } + MediaPeriodHolder nextPlayingPeriodHolder = playingPeriodHolder.getNext(); + if (nextPlayingPeriodHolder == null) { + return false; + } + MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + if (playingPeriodHolder == readingPeriodHolder && !hasReadingPeriodFinishedReading()) { + return false; + } + return rendererPositionUs >= nextPlayingPeriodHolder.getStartPositionRendererTime(); + } + + private boolean hasReadingPeriodFinishedReading() { + MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod(); + if (!readingPeriodHolder.prepared) { + return false; + } + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + SampleStream sampleStream = readingPeriodHolder.sampleStreams[i]; + if (renderer.getStream() != sampleStream + || (sampleStream != null && !renderer.hasReadStreamToEnd())) { + // The current reading period is still being read by at least one renderer. + return false; + } + } + return true; + } + + private void setAllRendererStreamsFinal() { + for (Renderer renderer : renderers) { + if (renderer.getStream() != null) { + renderer.setCurrentStreamFinal(); + } + } + } + + private void handlePeriodPrepared(MediaPeriod mediaPeriod) throws ExoPlaybackException { + if (!queue.isLoading(mediaPeriod)) { + // Stale event. + return; + } + MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); + loadingPeriodHolder.handlePrepared( + mediaClock.getPlaybackParameters().speed, playbackInfo.timeline); + updateLoadControlTrackSelection( + loadingPeriodHolder.getTrackGroups(), loadingPeriodHolder.getTrackSelectorResult()); + if (loadingPeriodHolder == queue.getPlayingPeriod()) { + // This is the first prepared period, so update the position and the renderers. + resetRendererPosition(loadingPeriodHolder.info.startPositionUs); + updatePlayingPeriodRenderers(/* oldPlayingPeriodHolder= */ null); + } + maybeContinueLoading(); + } + + private void handleContinueLoadingRequested(MediaPeriod mediaPeriod) { + if (!queue.isLoading(mediaPeriod)) { + // Stale event. + return; + } + queue.reevaluateBuffer(rendererPositionUs); + maybeContinueLoading(); + } + + private void handlePlaybackParameters( + PlaybackParameters playbackParameters, boolean acknowledgeCommand) + throws ExoPlaybackException { + eventHandler + .obtainMessage( + MSG_PLAYBACK_PARAMETERS_CHANGED, acknowledgeCommand ? 1 : 0, 0, playbackParameters) + .sendToTarget(); + updateTrackSelectionPlaybackSpeed(playbackParameters.speed); + for (Renderer renderer : renderers) { + if (renderer != null) { + renderer.setOperatingRate(playbackParameters.speed); + } + } + } + + private void maybeContinueLoading() { + shouldContinueLoading = shouldContinueLoading(); + if (shouldContinueLoading) { + queue.getLoadingPeriod().continueLoading(rendererPositionUs); + } + updateIsLoading(); + } + + private boolean shouldContinueLoading() { + if (!isLoadingPossible()) { + return false; + } + long bufferedDurationUs = + getTotalBufferedDurationUs(queue.getLoadingPeriod().getNextLoadPositionUs()); + float playbackSpeed = mediaClock.getPlaybackParameters().speed; + return loadControl.shouldContinueLoading(bufferedDurationUs, playbackSpeed); + } + + private boolean isLoadingPossible() { + MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); + if (loadingPeriodHolder == null) { + return false; + } + long nextLoadPositionUs = loadingPeriodHolder.getNextLoadPositionUs(); + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE) { + return false; + } + return true; + } + + private void updateIsLoading() { + MediaPeriodHolder loadingPeriod = queue.getLoadingPeriod(); + boolean isLoading = + shouldContinueLoading || (loadingPeriod != null && loadingPeriod.mediaPeriod.isLoading()); + if (isLoading != playbackInfo.isLoading) { + playbackInfo = playbackInfo.copyWithIsLoading(isLoading); + } + } + + private PlaybackInfo copyWithNewPosition( + MediaPeriodId mediaPeriodId, long positionUs, long contentPositionUs) { + deliverPendingMessageAtStartPositionRequired = true; + return playbackInfo.copyWithNewPosition( + mediaPeriodId, positionUs, contentPositionUs, getTotalBufferedDurationUs()); + } + + @SuppressWarnings("ParameterNotNullable") + private void updatePlayingPeriodRenderers(@Nullable MediaPeriodHolder oldPlayingPeriodHolder) + throws ExoPlaybackException { + MediaPeriodHolder newPlayingPeriodHolder = queue.getPlayingPeriod(); + if (newPlayingPeriodHolder == null || oldPlayingPeriodHolder == newPlayingPeriodHolder) { + return; + } + int enabledRendererCount = 0; + boolean[] rendererWasEnabledFlags = new boolean[renderers.length]; + for (int i = 0; i < renderers.length; i++) { + Renderer renderer = renderers[i]; + rendererWasEnabledFlags[i] = renderer.getState() != Renderer.STATE_DISABLED; + if (newPlayingPeriodHolder.getTrackSelectorResult().isRendererEnabled(i)) { + enabledRendererCount++; + } + if (rendererWasEnabledFlags[i] + && (!newPlayingPeriodHolder.getTrackSelectorResult().isRendererEnabled(i) + || (renderer.isCurrentStreamFinal() + && renderer.getStream() == oldPlayingPeriodHolder.sampleStreams[i]))) { + // The renderer should be disabled before playing the next period, either because it's not + // needed to play the next period, or because we need to re-enable it as its current stream + // is final and it's not reading ahead. + disableRenderer(renderer); + } + } + playbackInfo = + playbackInfo.copyWithTrackInfo( + newPlayingPeriodHolder.getTrackGroups(), + newPlayingPeriodHolder.getTrackSelectorResult()); + enableRenderers(rendererWasEnabledFlags, enabledRendererCount); + } + + private void enableRenderers(boolean[] rendererWasEnabledFlags, int totalEnabledRendererCount) + throws ExoPlaybackException { + enabledRenderers = new Renderer[totalEnabledRendererCount]; + int enabledRendererCount = 0; + TrackSelectorResult trackSelectorResult = queue.getPlayingPeriod().getTrackSelectorResult(); + // Reset all disabled renderers before enabling any new ones. This makes sure resources released + // by the disabled renderers will be available to renderers that are being enabled. + for (int i = 0; i < renderers.length; i++) { + if (!trackSelectorResult.isRendererEnabled(i)) { + renderers[i].reset(); + } + } + // Enable the renderers. + for (int i = 0; i < renderers.length; i++) { + if (trackSelectorResult.isRendererEnabled(i)) { + enableRenderer(i, rendererWasEnabledFlags[i], enabledRendererCount++); + } + } + } + + private void enableRenderer( + int rendererIndex, boolean wasRendererEnabled, int enabledRendererIndex) + throws ExoPlaybackException { + MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod(); + Renderer renderer = renderers[rendererIndex]; + enabledRenderers[enabledRendererIndex] = renderer; + if (renderer.getState() == Renderer.STATE_DISABLED) { + TrackSelectorResult trackSelectorResult = playingPeriodHolder.getTrackSelectorResult(); + RendererConfiguration rendererConfiguration = + trackSelectorResult.rendererConfigurations[rendererIndex]; + TrackSelection newSelection = trackSelectorResult.selections.get(rendererIndex); + Format[] formats = getFormats(newSelection); + // The renderer needs enabling with its new track selection. + boolean playing = playWhenReady && playbackInfo.playbackState == Player.STATE_READY; + // Consider as joining only if the renderer was previously disabled. + boolean joining = !wasRendererEnabled && playing; + // Enable the renderer. + renderer.enable( + rendererConfiguration, + formats, + playingPeriodHolder.sampleStreams[rendererIndex], + rendererPositionUs, + joining, + playingPeriodHolder.getRendererOffset()); + mediaClock.onRendererEnabled(renderer); + // Start the renderer if playing. + if (playing) { + renderer.start(); + } + } + } + + private void handleLoadingMediaPeriodChanged(boolean loadingTrackSelectionChanged) { + MediaPeriodHolder loadingMediaPeriodHolder = queue.getLoadingPeriod(); + MediaPeriodId loadingMediaPeriodId = + loadingMediaPeriodHolder == null ? playbackInfo.periodId : loadingMediaPeriodHolder.info.id; + boolean loadingMediaPeriodChanged = + !playbackInfo.loadingMediaPeriodId.equals(loadingMediaPeriodId); + if (loadingMediaPeriodChanged) { + playbackInfo = playbackInfo.copyWithLoadingMediaPeriodId(loadingMediaPeriodId); + } + playbackInfo.bufferedPositionUs = + loadingMediaPeriodHolder == null + ? playbackInfo.positionUs + : loadingMediaPeriodHolder.getBufferedPositionUs(); + playbackInfo.totalBufferedDurationUs = getTotalBufferedDurationUs(); + if ((loadingMediaPeriodChanged || loadingTrackSelectionChanged) + && loadingMediaPeriodHolder != null + && loadingMediaPeriodHolder.prepared) { + updateLoadControlTrackSelection( + loadingMediaPeriodHolder.getTrackGroups(), + loadingMediaPeriodHolder.getTrackSelectorResult()); + } + } + + private long getTotalBufferedDurationUs() { + return getTotalBufferedDurationUs(playbackInfo.bufferedPositionUs); + } + + private long getTotalBufferedDurationUs(long bufferedPositionInLoadingPeriodUs) { + MediaPeriodHolder loadingPeriodHolder = queue.getLoadingPeriod(); + if (loadingPeriodHolder == null) { + return 0; + } + long totalBufferedDurationUs = + bufferedPositionInLoadingPeriodUs - loadingPeriodHolder.toPeriodTime(rendererPositionUs); + return Math.max(0, totalBufferedDurationUs); + } + + private void updateLoadControlTrackSelection( + TrackGroupArray trackGroups, TrackSelectorResult trackSelectorResult) { + loadControl.onTracksSelected(renderers, trackGroups, trackSelectorResult.selections); + } + + private void sendPlaybackParametersChangedInternal( + PlaybackParameters playbackParameters, boolean acknowledgeCommand) { + handler + .obtainMessage( + MSG_PLAYBACK_PARAMETERS_CHANGED_INTERNAL, + acknowledgeCommand ? 1 : 0, + 0, + playbackParameters) + .sendToTarget(); + } + + private static Format[] getFormats(TrackSelection newSelection) { + // Build an array of formats contained by the selection. + int length = newSelection != null ? newSelection.length() : 0; + Format[] formats = new Format[length]; + for (int i = 0; i < length; i++) { + formats[i] = newSelection.getFormat(i); + } + return formats; + } + + private static final class SeekPosition { + + public final Timeline timeline; + public final int windowIndex; + public final long windowPositionUs; + + public SeekPosition(Timeline timeline, int windowIndex, long windowPositionUs) { + this.timeline = timeline; + this.windowIndex = windowIndex; + this.windowPositionUs = windowPositionUs; + } + } + + private static final class PendingMessageInfo implements Comparable<PendingMessageInfo> { + + public final PlayerMessage message; + + public int resolvedPeriodIndex; + public long resolvedPeriodTimeUs; + @Nullable public Object resolvedPeriodUid; + + public PendingMessageInfo(PlayerMessage message) { + this.message = message; + } + + public void setResolvedPosition(int periodIndex, long periodTimeUs, Object periodUid) { + resolvedPeriodIndex = periodIndex; + resolvedPeriodTimeUs = periodTimeUs; + resolvedPeriodUid = periodUid; + } + + @Override + public int compareTo(PendingMessageInfo other) { + if ((resolvedPeriodUid == null) != (other.resolvedPeriodUid == null)) { + // PendingMessageInfos with a resolved period position are always smaller. + return resolvedPeriodUid != null ? -1 : 1; + } + if (resolvedPeriodUid == null) { + // Don't sort message with unresolved positions. + return 0; + } + // Sort resolved media times by period index and then by period position. + int comparePeriodIndex = resolvedPeriodIndex - other.resolvedPeriodIndex; + if (comparePeriodIndex != 0) { + return comparePeriodIndex; + } + return Util.compareLong(resolvedPeriodTimeUs, other.resolvedPeriodTimeUs); + } + } + + private static final class MediaSourceRefreshInfo { + + public final MediaSource source; + public final Timeline timeline; + + public MediaSourceRefreshInfo(MediaSource source, Timeline timeline) { + this.source = source; + this.timeline = timeline; + } + } + + private static final class PlaybackInfoUpdate { + + private PlaybackInfo lastPlaybackInfo; + private int operationAcks; + private boolean positionDiscontinuity; + private @DiscontinuityReason int discontinuityReason; + + public boolean hasPendingUpdate(PlaybackInfo playbackInfo) { + return playbackInfo != lastPlaybackInfo || operationAcks > 0 || positionDiscontinuity; + } + + public void reset(PlaybackInfo playbackInfo) { + lastPlaybackInfo = playbackInfo; + operationAcks = 0; + positionDiscontinuity = false; + } + + public void incrementPendingOperationAcks(int operationAcks) { + this.operationAcks += operationAcks; + } + + public void setPositionDiscontinuity(@DiscontinuityReason int discontinuityReason) { + if (positionDiscontinuity + && this.discontinuityReason != Player.DISCONTINUITY_REASON_INTERNAL) { + // We always prefer non-internal discontinuity reasons. We also assume that we won't report + // more than one non-internal discontinuity per message iteration. + Assertions.checkArgument(discontinuityReason == Player.DISCONTINUITY_REASON_INTERNAL); + return; + } + positionDiscontinuity = true; + this.discontinuityReason = discontinuityReason; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java new file mode 100644 index 0000000000..545017a215 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ExoPlayerLibraryInfo.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import java.util.HashSet; + +/** + * Information about the ExoPlayer library. + */ +public final class ExoPlayerLibraryInfo { + + /** + * A tag to use when logging library information. + */ + public static final String TAG = "ExoPlayer"; + + /** The version of the library expressed as a string, for example "1.2.3". */ + // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION_INT) or vice versa. + public static final String VERSION = "2.11.4"; + + /** The version of the library expressed as {@code "ExoPlayerLib/" + VERSION}. */ + // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. + public static final String VERSION_SLASHY = "ExoPlayerLib/2.11.4"; + + /** + * The version of the library expressed as an integer, for example 1002003. + * + * <p>Three digits are used for each component of {@link #VERSION}. For example "1.2.3" has the + * corresponding integer version 1002003 (001-002-003), and "123.45.6" has the corresponding + * integer version 123045006 (123-045-006). + */ + // Intentionally hardcoded. Do not derive from other constants (e.g. VERSION) or vice versa. + public static final int VERSION_INT = 2011004; + + /** + * Whether the library was compiled with {@link org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions} + * checks enabled. + */ + public static final boolean ASSERTIONS_ENABLED = true; + + /** Whether an exception should be thrown in case of an OpenGl error. */ + public static final boolean GL_ASSERTIONS_ENABLED = false; + + /** + * Whether the library was compiled with {@link org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil} + * trace enabled. + */ + public static final boolean TRACE_ENABLED = true; + + private static final HashSet<String> registeredModules = new HashSet<>(); + private static String registeredModulesString = "goog.exo.core"; + + private ExoPlayerLibraryInfo() {} // Prevents instantiation. + + /** + * Returns a string consisting of registered module names separated by ", ". + */ + public static synchronized String registeredModules() { + return registeredModulesString; + } + + /** + * Registers a module to be returned in the {@link #registeredModules()} string. + * + * @param name The name of the module being registered. + */ + public static synchronized void registerModule(String name) { + if (registeredModules.add(name)) { + registeredModulesString = registeredModulesString + ", " + name; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Format.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Format.java new file mode 100644 index 0000000000..9d7518f6f0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Format.java @@ -0,0 +1,1750 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.ColorInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Representation of a media format. + */ +public final class Format implements Parcelable { + + /** + * A value for various fields to indicate that the field's value is unknown or not applicable. + */ + public static final int NO_VALUE = -1; + + /** + * A value for {@link #subsampleOffsetUs} to indicate that subsample timestamps are relative to + * the timestamps of their parent samples. + */ + public static final long OFFSET_SAMPLE_RELATIVE = Long.MAX_VALUE; + + /** An identifier for the format, or null if unknown or not applicable. */ + @Nullable public final String id; + /** The human readable label, or null if unknown or not applicable. */ + @Nullable public final String label; + /** Track selection flags. */ + @C.SelectionFlags public final int selectionFlags; + /** Track role flags. */ + @C.RoleFlags public final int roleFlags; + /** + * The average bandwidth in bits per second, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int bitrate; + /** Codecs of the format as described in RFC 6381, or null if unknown or not applicable. */ + @Nullable public final String codecs; + /** Metadata, or null if unknown or not applicable. */ + @Nullable public final Metadata metadata; + + // Container specific. + + /** The mime type of the container, or null if unknown or not applicable. */ + @Nullable public final String containerMimeType; + + // Elementary stream specific. + + /** + * The mime type of the elementary stream (i.e. the individual samples), or null if unknown or not + * applicable. + */ + @Nullable public final String sampleMimeType; + /** + * The maximum size of a buffer of data (typically one sample), or {@link #NO_VALUE} if unknown or + * not applicable. + */ + public final int maxInputSize; + /** + * Initialization data that must be provided to the decoder. Will not be null, but may be empty + * if initialization data is not required. + */ + public final List<byte[]> initializationData; + /** DRM initialization data if the stream is protected, or null otherwise. */ + @Nullable public final DrmInitData drmInitData; + + /** + * For samples that contain subsamples, this is an offset that should be added to subsample + * timestamps. A value of {@link #OFFSET_SAMPLE_RELATIVE} indicates that subsample timestamps are + * relative to the timestamps of their parent samples. + */ + public final long subsampleOffsetUs; + + // Video specific. + + /** + * The width of the video in pixels, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int width; + /** + * The height of the video in pixels, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int height; + /** + * The frame rate in frames per second, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final float frameRate; + /** + * The clockwise rotation that should be applied to the video for it to be rendered in the correct + * orientation, or 0 if unknown or not applicable. Only 0, 90, 180 and 270 are supported. + */ + public final int rotationDegrees; + /** The width to height ratio of pixels in the video, or 1.0 if unknown or not applicable. */ + public final float pixelWidthHeightRatio; + /** + * The stereo layout for 360/3D/VR video, or {@link #NO_VALUE} if not applicable. Valid stereo + * modes are {@link C#STEREO_MODE_MONO}, {@link C#STEREO_MODE_TOP_BOTTOM}, {@link + * C#STEREO_MODE_LEFT_RIGHT}, {@link C#STEREO_MODE_STEREO_MESH}. + */ + @C.StereoMode + public final int stereoMode; + /** The projection data for 360/VR video, or null if not applicable. */ + @Nullable public final byte[] projectionData; + /** The color metadata associated with the video, helps with accurate color reproduction. */ + @Nullable public final ColorInfo colorInfo; + + // Audio specific. + + /** + * The number of audio channels, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int channelCount; + /** + * The audio sampling rate in Hz, or {@link #NO_VALUE} if unknown or not applicable. + */ + public final int sampleRate; + /** The {@link C.PcmEncoding} for PCM audio. Set to {@link #NO_VALUE} for other media types. */ + public final @C.PcmEncoding int pcmEncoding; + /** + * The number of frames to trim from the start of the decoded audio stream, or 0 if not + * applicable. + */ + public final int encoderDelay; + /** + * The number of frames to trim from the end of the decoded audio stream, or 0 if not applicable. + */ + public final int encoderPadding; + + // Audio and text specific. + + /** The language as an IETF BCP 47 conformant tag, or null if unknown or not applicable. */ + @Nullable public final String language; + /** + * The Accessibility channel, or {@link #NO_VALUE} if not known or applicable. + */ + public final int accessibilityChannel; + + // Provided by source. + + /** + * The type of the {@link ExoMediaCrypto} provided by the media source, if the media source can + * acquire a {@link DrmSession} for {@link #drmInitData}. Null if the media source cannot acquire + * a session for {@link #drmInitData}, or if not applicable. + */ + @Nullable public final Class<? extends ExoMediaCrypto> exoMediaCryptoType; + + // Lazily initialized hashcode. + private int hashCode; + + // Video. + + /** + * @deprecated Use {@link #createVideoContainerFormat(String, String, String, String, String, + * Metadata, int, int, int, float, List, int, int)} instead. + */ + @Deprecated + public static Format createVideoContainerFormat( + @Nullable String id, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int width, + int height, + float frameRate, + @Nullable List<byte[]> initializationData, + @C.SelectionFlags int selectionFlags) { + return createVideoContainerFormat( + id, + /* label= */ null, + containerMimeType, + sampleMimeType, + codecs, + /* metadata= */ null, + bitrate, + width, + height, + frameRate, + initializationData, + selectionFlags, + /* roleFlags= */ 0); + } + + public static Format createVideoContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + @Nullable Metadata metadata, + int bitrate, + int width, + int height, + float frameRate, + @Nullable List<byte[]> initializationData, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + initializationData, + /* drmInitData= */ null, + OFFSET_SAMPLE_RELATIVE, + width, + height, + frameRate, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + /* language= */ null, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + public static Format createVideoSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int width, + int height, + float frameRate, + @Nullable List<byte[]> initializationData, + @Nullable DrmInitData drmInitData) { + return createVideoSampleFormat( + id, + sampleMimeType, + codecs, + bitrate, + maxInputSize, + width, + height, + frameRate, + initializationData, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + drmInitData); + } + + public static Format createVideoSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int width, + int height, + float frameRate, + @Nullable List<byte[]> initializationData, + int rotationDegrees, + float pixelWidthHeightRatio, + @Nullable DrmInitData drmInitData) { + return createVideoSampleFormat( + id, + sampleMimeType, + codecs, + bitrate, + maxInputSize, + width, + height, + frameRate, + initializationData, + rotationDegrees, + pixelWidthHeightRatio, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + drmInitData); + } + + public static Format createVideoSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int width, + int height, + float frameRate, + @Nullable List<byte[]> initializationData, + int rotationDegrees, + float pixelWidthHeightRatio, + @Nullable byte[] projectionData, + @C.StereoMode int stereoMode, + @Nullable ColorInfo colorInfo, + @Nullable DrmInitData drmInitData) { + return new Format( + id, + /* label= */ null, + /* selectionFlags= */ 0, + /* roleFlags= */ 0, + bitrate, + codecs, + /* metadata= */ null, + /* containerMimeType= */ null, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + OFFSET_SAMPLE_RELATIVE, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + /* language= */ null, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + // Audio. + + /** + * @deprecated Use {@link #createAudioContainerFormat(String, String, String, String, String, + * Metadata, int, int, int, List, int, int, String)} instead. + */ + @Deprecated + public static Format createAudioContainerFormat( + @Nullable String id, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int channelCount, + int sampleRate, + @Nullable List<byte[]> initializationData, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return createAudioContainerFormat( + id, + /* label= */ null, + containerMimeType, + sampleMimeType, + codecs, + /* metadata= */ null, + bitrate, + channelCount, + sampleRate, + initializationData, + selectionFlags, + /* roleFlags= */ 0, + language); + } + + public static Format createAudioContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + @Nullable Metadata metadata, + int bitrate, + int channelCount, + int sampleRate, + @Nullable List<byte[]> initializationData, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + initializationData, + /* drmInitData= */ null, + OFFSET_SAMPLE_RELATIVE, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + channelCount, + sampleRate, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + language, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + public static Format createAudioSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int channelCount, + int sampleRate, + @Nullable List<byte[]> initializationData, + @Nullable DrmInitData drmInitData, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return createAudioSampleFormat( + id, + sampleMimeType, + codecs, + bitrate, + maxInputSize, + channelCount, + sampleRate, + /* pcmEncoding= */ NO_VALUE, + initializationData, + drmInitData, + selectionFlags, + language); + } + + public static Format createAudioSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int channelCount, + int sampleRate, + @C.PcmEncoding int pcmEncoding, + @Nullable List<byte[]> initializationData, + @Nullable DrmInitData drmInitData, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return createAudioSampleFormat( + id, + sampleMimeType, + codecs, + bitrate, + maxInputSize, + channelCount, + sampleRate, + pcmEncoding, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + initializationData, + drmInitData, + selectionFlags, + language, + /* metadata= */ null); + } + + public static Format createAudioSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + int maxInputSize, + int channelCount, + int sampleRate, + @C.PcmEncoding int pcmEncoding, + int encoderDelay, + int encoderPadding, + @Nullable List<byte[]> initializationData, + @Nullable DrmInitData drmInitData, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + @Nullable Metadata metadata) { + return new Format( + id, + /* label= */ null, + selectionFlags, + /* roleFlags= */ 0, + bitrate, + codecs, + metadata, + /* containerMimeType= */ null, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + OFFSET_SAMPLE_RELATIVE, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + // Text. + + public static Format createTextContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language) { + return createTextContainerFormat( + id, + label, + containerMimeType, + sampleMimeType, + codecs, + bitrate, + selectionFlags, + roleFlags, + language, + /* accessibilityChannel= */ NO_VALUE); + } + + public static Format createTextContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language, + int accessibilityChannel) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + /* metadata= */ null, + containerMimeType, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + /* initializationData= */ null, + /* drmInitData= */ null, + OFFSET_SAMPLE_RELATIVE, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + language, + accessibilityChannel, + /* exoMediaCryptoType= */ null); + } + + public static Format createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return createTextSampleFormat(id, sampleMimeType, selectionFlags, language, null); + } + + public static Format createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + @Nullable DrmInitData drmInitData) { + return createTextSampleFormat( + id, + sampleMimeType, + /* codecs= */ null, + /* bitrate= */ NO_VALUE, + selectionFlags, + language, + NO_VALUE, + drmInitData, + OFFSET_SAMPLE_RELATIVE, + Collections.emptyList()); + } + + public static Format createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + int accessibilityChannel, + @Nullable DrmInitData drmInitData) { + return createTextSampleFormat( + id, + sampleMimeType, + codecs, + bitrate, + selectionFlags, + language, + accessibilityChannel, + drmInitData, + OFFSET_SAMPLE_RELATIVE, + Collections.emptyList()); + } + + public static Format createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + @Nullable DrmInitData drmInitData, + long subsampleOffsetUs) { + return createTextSampleFormat( + id, + sampleMimeType, + codecs, + bitrate, + selectionFlags, + language, + /* accessibilityChannel= */ NO_VALUE, + drmInitData, + subsampleOffsetUs, + Collections.emptyList()); + } + + public static Format createTextSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @Nullable String language, + int accessibilityChannel, + @Nullable DrmInitData drmInitData, + long subsampleOffsetUs, + @Nullable List<byte[]> initializationData) { + return new Format( + id, + /* label= */ null, + selectionFlags, + /* roleFlags= */ 0, + bitrate, + codecs, + /* metadata= */ null, + /* containerMimeType= */ null, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + initializationData, + drmInitData, + subsampleOffsetUs, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + language, + accessibilityChannel, + /* exoMediaCryptoType= */ null); + } + + // Image. + + public static Format createImageSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @Nullable List<byte[]> initializationData, + @Nullable String language, + @Nullable DrmInitData drmInitData) { + return new Format( + id, + /* label= */ null, + selectionFlags, + /* roleFlags= */ 0, + bitrate, + codecs, + /* metadata=*/ null, + /* containerMimeType= */ null, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + initializationData, + drmInitData, + OFFSET_SAMPLE_RELATIVE, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + language, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + // Generic. + + /** + * @deprecated Use {@link #createContainerFormat(String, String, String, String, String, int, int, + * int, String)} instead. + */ + @Deprecated + public static Format createContainerFormat( + @Nullable String id, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + return createContainerFormat( + id, + /* label= */ null, + containerMimeType, + sampleMimeType, + codecs, + bitrate, + selectionFlags, + /* roleFlags= */ 0, + language); + } + + public static Format createContainerFormat( + @Nullable String id, + @Nullable String label, + @Nullable String containerMimeType, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + @Nullable String language) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + /* metadata= */ null, + containerMimeType, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + /* initializationData= */ null, + /* drmInitData= */ null, + OFFSET_SAMPLE_RELATIVE, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + language, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + public static Format createSampleFormat( + @Nullable String id, @Nullable String sampleMimeType, long subsampleOffsetUs) { + return new Format( + id, + /* label= */ null, + /* selectionFlags= */ 0, + /* roleFlags= */ 0, + /* bitrate= */ NO_VALUE, + /* codecs= */ null, + /* metadata= */ null, + /* containerMimeType= */ null, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + /* initializationData= */ null, + /* drmInitData= */ null, + subsampleOffsetUs, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + /* language= */ null, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + public static Format createSampleFormat( + @Nullable String id, + @Nullable String sampleMimeType, + @Nullable String codecs, + int bitrate, + @Nullable DrmInitData drmInitData) { + return new Format( + id, + /* label= */ null, + /* selectionFlags= */ 0, + /* roleFlags= */ 0, + bitrate, + codecs, + /* metadata= */ null, + /* containerMimeType= */ null, + sampleMimeType, + /* maxInputSize= */ NO_VALUE, + /* initializationData= */ null, + drmInitData, + OFFSET_SAMPLE_RELATIVE, + /* width= */ NO_VALUE, + /* height= */ NO_VALUE, + /* frameRate= */ NO_VALUE, + /* rotationDegrees= */ NO_VALUE, + /* pixelWidthHeightRatio= */ NO_VALUE, + /* projectionData= */ null, + /* stereoMode= */ NO_VALUE, + /* colorInfo= */ null, + /* channelCount= */ NO_VALUE, + /* sampleRate= */ NO_VALUE, + /* pcmEncoding= */ NO_VALUE, + /* encoderDelay= */ NO_VALUE, + /* encoderPadding= */ NO_VALUE, + /* language= */ null, + /* accessibilityChannel= */ NO_VALUE, + /* exoMediaCryptoType= */ null); + } + + /* package */ Format( + @Nullable String id, + @Nullable String label, + @C.SelectionFlags int selectionFlags, + @C.RoleFlags int roleFlags, + int bitrate, + @Nullable String codecs, + @Nullable Metadata metadata, + // Container specific. + @Nullable String containerMimeType, + // Elementary stream specific. + @Nullable String sampleMimeType, + int maxInputSize, + @Nullable List<byte[]> initializationData, + @Nullable DrmInitData drmInitData, + long subsampleOffsetUs, + // Video specific. + int width, + int height, + float frameRate, + int rotationDegrees, + float pixelWidthHeightRatio, + @Nullable byte[] projectionData, + @C.StereoMode int stereoMode, + @Nullable ColorInfo colorInfo, + // Audio specific. + int channelCount, + int sampleRate, + @C.PcmEncoding int pcmEncoding, + int encoderDelay, + int encoderPadding, + // Audio and text specific. + @Nullable String language, + int accessibilityChannel, + // Provided by source. + @Nullable Class<? extends ExoMediaCrypto> exoMediaCryptoType) { + this.id = id; + this.label = label; + this.selectionFlags = selectionFlags; + this.roleFlags = roleFlags; + this.bitrate = bitrate; + this.codecs = codecs; + this.metadata = metadata; + // Container specific. + this.containerMimeType = containerMimeType; + // Elementary stream specific. + this.sampleMimeType = sampleMimeType; + this.maxInputSize = maxInputSize; + this.initializationData = + initializationData == null ? Collections.emptyList() : initializationData; + this.drmInitData = drmInitData; + this.subsampleOffsetUs = subsampleOffsetUs; + // Video specific. + this.width = width; + this.height = height; + this.frameRate = frameRate; + this.rotationDegrees = rotationDegrees == Format.NO_VALUE ? 0 : rotationDegrees; + this.pixelWidthHeightRatio = + pixelWidthHeightRatio == Format.NO_VALUE ? 1 : pixelWidthHeightRatio; + this.projectionData = projectionData; + this.stereoMode = stereoMode; + this.colorInfo = colorInfo; + // Audio specific. + this.channelCount = channelCount; + this.sampleRate = sampleRate; + this.pcmEncoding = pcmEncoding; + this.encoderDelay = encoderDelay == Format.NO_VALUE ? 0 : encoderDelay; + this.encoderPadding = encoderPadding == Format.NO_VALUE ? 0 : encoderPadding; + // Audio and text specific. + this.language = Util.normalizeLanguageCode(language); + this.accessibilityChannel = accessibilityChannel; + // Provided by source. + this.exoMediaCryptoType = exoMediaCryptoType; + } + + @SuppressWarnings("ResourceType") + /* package */ Format(Parcel in) { + id = in.readString(); + label = in.readString(); + selectionFlags = in.readInt(); + roleFlags = in.readInt(); + bitrate = in.readInt(); + codecs = in.readString(); + metadata = in.readParcelable(Metadata.class.getClassLoader()); + // Container specific. + containerMimeType = in.readString(); + // Elementary stream specific. + sampleMimeType = in.readString(); + maxInputSize = in.readInt(); + int initializationDataSize = in.readInt(); + initializationData = new ArrayList<>(initializationDataSize); + for (int i = 0; i < initializationDataSize; i++) { + initializationData.add(in.createByteArray()); + } + drmInitData = in.readParcelable(DrmInitData.class.getClassLoader()); + subsampleOffsetUs = in.readLong(); + // Video specific. + width = in.readInt(); + height = in.readInt(); + frameRate = in.readFloat(); + rotationDegrees = in.readInt(); + pixelWidthHeightRatio = in.readFloat(); + boolean hasProjectionData = Util.readBoolean(in); + projectionData = hasProjectionData ? in.createByteArray() : null; + stereoMode = in.readInt(); + colorInfo = in.readParcelable(ColorInfo.class.getClassLoader()); + // Audio specific. + channelCount = in.readInt(); + sampleRate = in.readInt(); + pcmEncoding = in.readInt(); + encoderDelay = in.readInt(); + encoderPadding = in.readInt(); + // Audio and text specific. + language = in.readString(); + accessibilityChannel = in.readInt(); + // Provided by source. + exoMediaCryptoType = null; + } + + public Format copyWithMaxInputSize(int maxInputSize) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithSubsampleOffsetUs(long subsampleOffsetUs) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithLabel(@Nullable String label) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithContainerInfo( + @Nullable String id, + @Nullable String label, + @Nullable String sampleMimeType, + @Nullable String codecs, + @Nullable Metadata metadata, + int bitrate, + int width, + int height, + int channelCount, + @C.SelectionFlags int selectionFlags, + @Nullable String language) { + + if (this.metadata != null) { + metadata = this.metadata.copyWithAppendedEntriesFrom(metadata); + } + + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + @SuppressWarnings("ReferenceEquality") + public Format copyWithManifestFormatInfo(Format manifestFormat) { + if (this == manifestFormat) { + // No need to copy from ourselves. + return this; + } + + int trackType = MimeTypes.getTrackType(sampleMimeType); + + // Use manifest value only. + String id = manifestFormat.id; + + // Prefer manifest values, but fill in from sample format if missing. + String label = manifestFormat.label != null ? manifestFormat.label : this.label; + String language = this.language; + if ((trackType == C.TRACK_TYPE_TEXT || trackType == C.TRACK_TYPE_AUDIO) + && manifestFormat.language != null) { + language = manifestFormat.language; + } + + // Prefer sample format values, but fill in from manifest if missing. + int bitrate = this.bitrate == NO_VALUE ? manifestFormat.bitrate : this.bitrate; + String codecs = this.codecs; + if (codecs == null) { + // The manifest format may be muxed, so filter only codecs of this format's type. If we still + // have more than one codec then we're unable to uniquely identify which codec to fill in. + String codecsOfType = Util.getCodecsOfType(manifestFormat.codecs, trackType); + if (Util.splitCodecs(codecsOfType).length == 1) { + codecs = codecsOfType; + } + } + + Metadata metadata = + this.metadata == null + ? manifestFormat.metadata + : this.metadata.copyWithAppendedEntriesFrom(manifestFormat.metadata); + + float frameRate = this.frameRate; + if (frameRate == NO_VALUE && trackType == C.TRACK_TYPE_VIDEO) { + frameRate = manifestFormat.frameRate; + } + + // Merge manifest and sample format values. + @C.SelectionFlags int selectionFlags = this.selectionFlags | manifestFormat.selectionFlags; + @C.RoleFlags int roleFlags = this.roleFlags | manifestFormat.roleFlags; + DrmInitData drmInitData = + DrmInitData.createSessionCreationData(manifestFormat.drmInitData, this.drmInitData); + + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithGaplessInfo(int encoderDelay, int encoderPadding) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithFrameRate(float frameRate) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithDrmInitData(@Nullable DrmInitData drmInitData) { + return copyWithAdjustments(drmInitData, metadata); + } + + public Format copyWithMetadata(@Nullable Metadata metadata) { + return copyWithAdjustments(drmInitData, metadata); + } + + @SuppressWarnings("ReferenceEquality") + public Format copyWithAdjustments( + @Nullable DrmInitData drmInitData, @Nullable Metadata metadata) { + if (drmInitData == this.drmInitData && metadata == this.metadata) { + return this; + } + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithRotationDegrees(int rotationDegrees) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithBitrate(int bitrate) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithVideoSize(int width, int height) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + public Format copyWithExoMediaCryptoType( + @Nullable Class<? extends ExoMediaCrypto> exoMediaCryptoType) { + return new Format( + id, + label, + selectionFlags, + roleFlags, + bitrate, + codecs, + metadata, + containerMimeType, + sampleMimeType, + maxInputSize, + initializationData, + drmInitData, + subsampleOffsetUs, + width, + height, + frameRate, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + channelCount, + sampleRate, + pcmEncoding, + encoderDelay, + encoderPadding, + language, + accessibilityChannel, + exoMediaCryptoType); + } + + /** + * Returns the number of pixels if this is a video format whose {@link #width} and {@link #height} + * are known, or {@link #NO_VALUE} otherwise + */ + public int getPixelCount() { + return width == NO_VALUE || height == NO_VALUE ? NO_VALUE : (width * height); + } + + @Override + public String toString() { + return "Format(" + + id + + ", " + + label + + ", " + + containerMimeType + + ", " + + sampleMimeType + + ", " + + codecs + + ", " + + bitrate + + ", " + + language + + ", [" + + width + + ", " + + height + + ", " + + frameRate + + "]" + + ", [" + + channelCount + + ", " + + sampleRate + + "])"; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + // Some fields for which hashing is expensive are deliberately omitted. + int result = 17; + result = 31 * result + (id == null ? 0 : id.hashCode()); + result = 31 * result + (label != null ? label.hashCode() : 0); + result = 31 * result + selectionFlags; + result = 31 * result + roleFlags; + result = 31 * result + bitrate; + result = 31 * result + (codecs == null ? 0 : codecs.hashCode()); + result = 31 * result + (metadata == null ? 0 : metadata.hashCode()); + // Container specific. + result = 31 * result + (containerMimeType == null ? 0 : containerMimeType.hashCode()); + // Elementary stream specific. + result = 31 * result + (sampleMimeType == null ? 0 : sampleMimeType.hashCode()); + result = 31 * result + maxInputSize; + // [Omitted] initializationData. + // [Omitted] drmInitData. + result = 31 * result + (int) subsampleOffsetUs; + // Video specific. + result = 31 * result + width; + result = 31 * result + height; + result = 31 * result + Float.floatToIntBits(frameRate); + result = 31 * result + rotationDegrees; + result = 31 * result + Float.floatToIntBits(pixelWidthHeightRatio); + // [Omitted] projectionData. + result = 31 * result + stereoMode; + // [Omitted] colorInfo. + // Audio specific. + result = 31 * result + channelCount; + result = 31 * result + sampleRate; + result = 31 * result + pcmEncoding; + result = 31 * result + encoderDelay; + result = 31 * result + encoderPadding; + // Audio and text specific. + result = 31 * result + (language == null ? 0 : language.hashCode()); + result = 31 * result + accessibilityChannel; + // Provided by source. + result = 31 * result + (exoMediaCryptoType == null ? 0 : exoMediaCryptoType.hashCode()); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Format other = (Format) obj; + if (hashCode != 0 && other.hashCode != 0 && hashCode != other.hashCode) { + return false; + } + // Field equality checks ordered by type, with the cheapest checks first. + return selectionFlags == other.selectionFlags + && roleFlags == other.roleFlags + && bitrate == other.bitrate + && maxInputSize == other.maxInputSize + && subsampleOffsetUs == other.subsampleOffsetUs + && width == other.width + && height == other.height + && rotationDegrees == other.rotationDegrees + && stereoMode == other.stereoMode + && channelCount == other.channelCount + && sampleRate == other.sampleRate + && pcmEncoding == other.pcmEncoding + && encoderDelay == other.encoderDelay + && encoderPadding == other.encoderPadding + && accessibilityChannel == other.accessibilityChannel + && Float.compare(frameRate, other.frameRate) == 0 + && Float.compare(pixelWidthHeightRatio, other.pixelWidthHeightRatio) == 0 + && Util.areEqual(exoMediaCryptoType, other.exoMediaCryptoType) + && Util.areEqual(id, other.id) + && Util.areEqual(label, other.label) + && Util.areEqual(codecs, other.codecs) + && Util.areEqual(containerMimeType, other.containerMimeType) + && Util.areEqual(sampleMimeType, other.sampleMimeType) + && Util.areEqual(language, other.language) + && Arrays.equals(projectionData, other.projectionData) + && Util.areEqual(metadata, other.metadata) + && Util.areEqual(colorInfo, other.colorInfo) + && Util.areEqual(drmInitData, other.drmInitData) + && initializationDataEquals(other); + } + + /** + * Returns whether the {@link #initializationData}s belonging to this format and {@code other} are + * equal. + * + * @param other The other format whose {@link #initializationData} is being compared. + * @return Whether the {@link #initializationData}s belonging to this format and {@code other} are + * equal. + */ + public boolean initializationDataEquals(Format other) { + if (initializationData.size() != other.initializationData.size()) { + return false; + } + for (int i = 0; i < initializationData.size(); i++) { + if (!Arrays.equals(initializationData.get(i), other.initializationData.get(i))) { + return false; + } + } + return true; + } + + // Utility methods + + /** Returns a prettier {@link String} than {@link #toString()}, intended for logging. */ + public static String toLogString(@Nullable Format format) { + if (format == null) { + return "null"; + } + StringBuilder builder = new StringBuilder(); + builder.append("id=").append(format.id).append(", mimeType=").append(format.sampleMimeType); + if (format.bitrate != Format.NO_VALUE) { + builder.append(", bitrate=").append(format.bitrate); + } + if (format.codecs != null) { + builder.append(", codecs=").append(format.codecs); + } + if (format.width != Format.NO_VALUE && format.height != Format.NO_VALUE) { + builder.append(", res=").append(format.width).append("x").append(format.height); + } + if (format.frameRate != Format.NO_VALUE) { + builder.append(", fps=").append(format.frameRate); + } + if (format.channelCount != Format.NO_VALUE) { + builder.append(", channels=").append(format.channelCount); + } + if (format.sampleRate != Format.NO_VALUE) { + builder.append(", sample_rate=").append(format.sampleRate); + } + if (format.language != null) { + builder.append(", language=").append(format.language); + } + if (format.label != null) { + builder.append(", label=").append(format.label); + } + return builder.toString(); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(label); + dest.writeInt(selectionFlags); + dest.writeInt(roleFlags); + dest.writeInt(bitrate); + dest.writeString(codecs); + dest.writeParcelable(metadata, 0); + // Container specific. + dest.writeString(containerMimeType); + // Elementary stream specific. + dest.writeString(sampleMimeType); + dest.writeInt(maxInputSize); + int initializationDataSize = initializationData.size(); + dest.writeInt(initializationDataSize); + for (int i = 0; i < initializationDataSize; i++) { + dest.writeByteArray(initializationData.get(i)); + } + dest.writeParcelable(drmInitData, 0); + dest.writeLong(subsampleOffsetUs); + // Video specific. + dest.writeInt(width); + dest.writeInt(height); + dest.writeFloat(frameRate); + dest.writeInt(rotationDegrees); + dest.writeFloat(pixelWidthHeightRatio); + Util.writeBoolean(dest, projectionData != null); + if (projectionData != null) { + dest.writeByteArray(projectionData); + } + dest.writeInt(stereoMode); + dest.writeParcelable(colorInfo, flags); + // Audio specific. + dest.writeInt(channelCount); + dest.writeInt(sampleRate); + dest.writeInt(pcmEncoding); + dest.writeInt(encoderDelay); + dest.writeInt(encoderPadding); + // Audio and text specific. + dest.writeString(language); + dest.writeInt(accessibilityChannel); + } + + public static final Creator<Format> CREATOR = new Creator<Format>() { + + @Override + public Format createFromParcel(Parcel in) { + return new Format(in); + } + + @Override + public Format[] newArray(int size) { + return new Format[size]; + } + + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/FormatHolder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/FormatHolder.java new file mode 100644 index 0000000000..35e87f1271 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/FormatHolder.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; + +/** + * Holds a {@link Format}. + */ +public final class FormatHolder { + + /** Whether the {@link #format} setter also sets the {@link #drmSession} field. */ + // TODO: Remove once all Renderers and MediaSources have migrated to the new DRM model [Internal + // ref: b/129764794]. + public boolean includesDrmSession; + + /** An accompanying context for decrypting samples in the format. */ + @Nullable public DrmSession<?> drmSession; + + /** The held {@link Format}. */ + @Nullable public Format format; + + /** Clears the holder. */ + public void clear() { + includesDrmSession = false; + drmSession = null; + format = null; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/IllegalSeekPositionException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/IllegalSeekPositionException.java new file mode 100644 index 0000000000..fd1423fc90 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/IllegalSeekPositionException.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +/** + * Thrown when an attempt is made to seek to a position that does not exist in the player's + * {@link Timeline}. + */ +public final class IllegalSeekPositionException extends IllegalStateException { + + /** + * The {@link Timeline} in which the seek was attempted. + */ + public final Timeline timeline; + /** + * The index of the window being seeked to. + */ + public final int windowIndex; + /** + * The seek position in the specified window. + */ + public final long positionMs; + + /** + * @param timeline The {@link Timeline} in which the seek was attempted. + * @param windowIndex The index of the window being seeked to. + * @param positionMs The seek position in the specified window. + */ + public IllegalSeekPositionException(Timeline timeline, int windowIndex, long positionMs) { + this.timeline = timeline; + this.windowIndex = windowIndex; + this.positionMs = positionMs; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/LoadControl.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/LoadControl.java new file mode 100644 index 0000000000..5076018d65 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/LoadControl.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +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.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; + +/** + * Controls buffering of media. + */ +public interface LoadControl { + + /** + * Called by the player when prepared with a new source. + */ + void onPrepared(); + + /** + * Called by the player when a track selection occurs. + * + * @param renderers The renderers. + * @param trackGroups The {@link TrackGroup}s from which the selection was made. + * @param trackSelections The track selections that were made. + */ + void onTracksSelected(Renderer[] renderers, TrackGroupArray trackGroups, + TrackSelectionArray trackSelections); + + /** + * Called by the player when stopped. + */ + void onStopped(); + + /** + * Called by the player when released. + */ + void onReleased(); + + /** + * Returns the {@link Allocator} that should be used to obtain media buffer allocations. + */ + Allocator getAllocator(); + + /** + * Returns the duration of media to retain in the buffer prior to the current playback position, + * for fast backward seeking. + * <p> + * Note: If {@link #retainBackBufferFromKeyframe()} is false then seeking in the back-buffer will + * only be fast if the back-buffer contains a keyframe prior to the seek position. + * <p> + * Note: Implementations should return a single value. Dynamic changes to the back-buffer are not + * currently supported. + * + * @return The duration of media to retain in the buffer prior to the current playback position, + * in microseconds. + */ + long getBackBufferDurationUs(); + + /** + * Returns whether media should be retained from the keyframe before the current playback position + * minus {@link #getBackBufferDurationUs()}, rather than any sample before or at that position. + * <p> + * Warning: Returning true will cause the back-buffer size to depend on the spacing of keyframes + * in the media being played. Returning true is not recommended unless you control the media and + * are comfortable with the back-buffer size exceeding {@link #getBackBufferDurationUs()} by as + * much as the maximum duration between adjacent keyframes in the media. + * <p> + * Note: Implementations should return a single value. Dynamic changes to the back-buffer are not + * currently supported. + * + * @return Whether media should be retained from the keyframe before the current playback position + * minus {@link #getBackBufferDurationUs()}, rather than any sample before or at that position. + */ + boolean retainBackBufferFromKeyframe(); + + /** + * Called by the player to determine whether it should continue to load the source. + * + * @param bufferedDurationUs The duration of media that's currently buffered. + * @param playbackSpeed The current playback speed. + * @return Whether the loading should continue. + */ + boolean shouldContinueLoading(long bufferedDurationUs, float playbackSpeed); + + /** + * Called repeatedly by the player when it's loading the source, has yet to start playback, and + * has the minimum amount of data necessary for playback to be started. The value returned + * determines whether playback is actually started. The load control may opt to return {@code + * false} until some condition has been met (e.g. a certain amount of media is buffered). + * + * @param bufferedDurationUs The duration of media that's currently buffered. + * @param playbackSpeed The current playback speed. + * @param rebuffering Whether the player is rebuffering. A rebuffer is defined to be caused by + * buffer depletion rather than a user action. Hence this parameter is false during initial + * buffering and when buffering as a result of a seek operation. + * @return Whether playback should be allowed to start or resume. + */ + boolean shouldStartPlayback(long bufferedDurationUs, float playbackSpeed, boolean rebuffering); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodHolder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodHolder.java new file mode 100644 index 0000000000..66cb9a1fce --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodHolder.java @@ -0,0 +1,432 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ClippingMediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.EmptySampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +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.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Holds a {@link MediaPeriod} with information required to play it as part of a timeline. */ +/* package */ final class MediaPeriodHolder { + + private static final String TAG = "MediaPeriodHolder"; + + /** The {@link MediaPeriod} wrapped by this class. */ + public final MediaPeriod mediaPeriod; + /** The unique timeline period identifier the media period belongs to. */ + public final Object uid; + /** + * The sample streams for each renderer associated with this period. May contain null elements. + */ + public final @NullableType SampleStream[] sampleStreams; + + /** Whether the media period has finished preparing. */ + public boolean prepared; + /** Whether any of the tracks of this media period are enabled. */ + public boolean hasEnabledTracks; + /** {@link MediaPeriodInfo} about this media period. */ + public MediaPeriodInfo info; + + private final boolean[] mayRetainStreamFlags; + private final RendererCapabilities[] rendererCapabilities; + private final TrackSelector trackSelector; + private final MediaSource mediaSource; + + @Nullable private MediaPeriodHolder next; + private TrackGroupArray trackGroups; + private TrackSelectorResult trackSelectorResult; + private long rendererPositionOffsetUs; + + /** + * Creates a new holder with information required to play it as part of a timeline. + * + * @param rendererCapabilities The renderer capabilities. + * @param rendererPositionOffsetUs The renderer time of the start of the period, in microseconds. + * @param trackSelector The track selector. + * @param allocator The allocator. + * @param mediaSource The media source that produced the media period. + * @param info Information used to identify this media period in its timeline period. + * @param emptyTrackSelectorResult A {@link TrackSelectorResult} with empty selections for each + * renderer. + */ + public MediaPeriodHolder( + RendererCapabilities[] rendererCapabilities, + long rendererPositionOffsetUs, + TrackSelector trackSelector, + Allocator allocator, + MediaSource mediaSource, + MediaPeriodInfo info, + TrackSelectorResult emptyTrackSelectorResult) { + this.rendererCapabilities = rendererCapabilities; + this.rendererPositionOffsetUs = rendererPositionOffsetUs; + this.trackSelector = trackSelector; + this.mediaSource = mediaSource; + this.uid = info.id.periodUid; + this.info = info; + this.trackGroups = TrackGroupArray.EMPTY; + this.trackSelectorResult = emptyTrackSelectorResult; + sampleStreams = new SampleStream[rendererCapabilities.length]; + mayRetainStreamFlags = new boolean[rendererCapabilities.length]; + mediaPeriod = + createMediaPeriod( + info.id, mediaSource, allocator, info.startPositionUs, info.endPositionUs); + } + + /** + * Converts time relative to the start of the period to the respective renderer time using {@link + * #getRendererOffset()}, in microseconds. + */ + public long toRendererTime(long periodTimeUs) { + return periodTimeUs + getRendererOffset(); + } + + /** + * Converts renderer time to the respective time relative to the start of the period using {@link + * #getRendererOffset()}, in microseconds. + */ + public long toPeriodTime(long rendererTimeUs) { + return rendererTimeUs - getRendererOffset(); + } + + /** Returns the renderer time of the start of the period, in microseconds. */ + public long getRendererOffset() { + return rendererPositionOffsetUs; + } + + /** + * Sets the renderer time of the start of the period, in microseconds. + * + * @param rendererPositionOffsetUs The new renderer position offset, in microseconds. + */ + public void setRendererOffset(long rendererPositionOffsetUs) { + this.rendererPositionOffsetUs = rendererPositionOffsetUs; + } + + /** Returns start position of period in renderer time. */ + public long getStartPositionRendererTime() { + return info.startPositionUs + rendererPositionOffsetUs; + } + + /** Returns whether the period is fully buffered. */ + public boolean isFullyBuffered() { + return prepared + && (!hasEnabledTracks || mediaPeriod.getBufferedPositionUs() == C.TIME_END_OF_SOURCE); + } + + /** + * Returns the buffered position in microseconds. If the period is buffered to the end, then the + * period duration is returned. + * + * @return The buffered position in microseconds. + */ + public long getBufferedPositionUs() { + if (!prepared) { + return info.startPositionUs; + } + long bufferedPositionUs = + hasEnabledTracks ? mediaPeriod.getBufferedPositionUs() : C.TIME_END_OF_SOURCE; + return bufferedPositionUs == C.TIME_END_OF_SOURCE ? info.durationUs : bufferedPositionUs; + } + + /** + * Returns the next load time relative to the start of the period, or {@link C#TIME_END_OF_SOURCE} + * if loading has finished. + */ + public long getNextLoadPositionUs() { + return !prepared ? 0 : mediaPeriod.getNextLoadPositionUs(); + } + + /** + * Handles period preparation. + * + * @param playbackSpeed The current playback speed. + * @param timeline The current {@link Timeline}. + * @throws ExoPlaybackException If an error occurs during track selection. + */ + public void handlePrepared(float playbackSpeed, Timeline timeline) throws ExoPlaybackException { + prepared = true; + trackGroups = mediaPeriod.getTrackGroups(); + TrackSelectorResult selectorResult = selectTracks(playbackSpeed, timeline); + long newStartPositionUs = + applyTrackSelection( + selectorResult, info.startPositionUs, /* forceRecreateStreams= */ false); + rendererPositionOffsetUs += info.startPositionUs - newStartPositionUs; + info = info.copyWithStartPositionUs(newStartPositionUs); + } + + /** + * Reevaluates the buffer of the media period at the given renderer position. Should only be + * called if this is the loading media period. + * + * @param rendererPositionUs The playing position in renderer time, in microseconds. + */ + public void reevaluateBuffer(long rendererPositionUs) { + Assertions.checkState(isLoadingMediaPeriod()); + if (prepared) { + mediaPeriod.reevaluateBuffer(toPeriodTime(rendererPositionUs)); + } + } + + /** + * Continues loading the media period at the given renderer position. Should only be called if + * this is the loading media period. + * + * @param rendererPositionUs The load position in renderer time, in microseconds. + */ + public void continueLoading(long rendererPositionUs) { + Assertions.checkState(isLoadingMediaPeriod()); + long loadingPeriodPositionUs = toPeriodTime(rendererPositionUs); + mediaPeriod.continueLoading(loadingPeriodPositionUs); + } + + /** + * Selects tracks for the period. Must only be called if {@link #prepared} is {@code true}. + * + * <p>The new track selection needs to be applied with {@link + * #applyTrackSelection(TrackSelectorResult, long, boolean)} before taking effect. + * + * @param playbackSpeed The current playback speed. + * @param timeline The current {@link Timeline}. + * @return The {@link TrackSelectorResult}. + * @throws ExoPlaybackException If an error occurs during track selection. + */ + public TrackSelectorResult selectTracks(float playbackSpeed, Timeline timeline) + throws ExoPlaybackException { + TrackSelectorResult selectorResult = + trackSelector.selectTracks(rendererCapabilities, getTrackGroups(), info.id, timeline); + for (TrackSelection trackSelection : selectorResult.selections.getAll()) { + if (trackSelection != null) { + trackSelection.onPlaybackSpeed(playbackSpeed); + } + } + return selectorResult; + } + + /** + * Applies a {@link TrackSelectorResult} to the period. + * + * @param trackSelectorResult The {@link TrackSelectorResult} to apply. + * @param positionUs The position relative to the start of the period at which to apply the new + * track selections, in microseconds. + * @param forceRecreateStreams Whether all streams are forced to be recreated. + * @return The actual position relative to the start of the period at which the new track + * selections are applied. + */ + public long applyTrackSelection( + TrackSelectorResult trackSelectorResult, long positionUs, boolean forceRecreateStreams) { + return applyTrackSelection( + trackSelectorResult, + positionUs, + forceRecreateStreams, + new boolean[rendererCapabilities.length]); + } + + /** + * Applies a {@link TrackSelectorResult} to the period. + * + * @param newTrackSelectorResult The {@link TrackSelectorResult} to apply. + * @param positionUs The position relative to the start of the period at which to apply the new + * track selections, in microseconds. + * @param forceRecreateStreams Whether all streams are forced to be recreated. + * @param streamResetFlags Will be populated to indicate which streams have been reset or were + * newly created. + * @return The actual position relative to the start of the period at which the new track + * selections are applied. + */ + public long applyTrackSelection( + TrackSelectorResult newTrackSelectorResult, + long positionUs, + boolean forceRecreateStreams, + boolean[] streamResetFlags) { + for (int i = 0; i < newTrackSelectorResult.length; i++) { + mayRetainStreamFlags[i] = + !forceRecreateStreams && newTrackSelectorResult.isEquivalent(trackSelectorResult, i); + } + + // Undo the effect of previous call to associate no-sample renderers with empty tracks + // so the mediaPeriod receives back whatever it sent us before. + disassociateNoSampleRenderersWithEmptySampleStream(sampleStreams); + disableTrackSelectionsInResult(); + trackSelectorResult = newTrackSelectorResult; + enableTrackSelectionsInResult(); + // Disable streams on the period and get new streams for updated/newly-enabled tracks. + TrackSelectionArray trackSelections = newTrackSelectorResult.selections; + positionUs = + mediaPeriod.selectTracks( + trackSelections.getAll(), + mayRetainStreamFlags, + sampleStreams, + streamResetFlags, + positionUs); + associateNoSampleRenderersWithEmptySampleStream(sampleStreams); + + // Update whether we have enabled tracks and sanity check the expected streams are non-null. + hasEnabledTracks = false; + for (int i = 0; i < sampleStreams.length; i++) { + if (sampleStreams[i] != null) { + Assertions.checkState(newTrackSelectorResult.isRendererEnabled(i)); + // hasEnabledTracks should be true only when non-empty streams exists. + if (rendererCapabilities[i].getTrackType() != C.TRACK_TYPE_NONE) { + hasEnabledTracks = true; + } + } else { + Assertions.checkState(trackSelections.get(i) == null); + } + } + return positionUs; + } + + /** Releases the media period. No other method should be called after the release. */ + public void release() { + disableTrackSelectionsInResult(); + releaseMediaPeriod(info.endPositionUs, mediaSource, mediaPeriod); + } + + /** + * Sets the next media period holder in the queue. + * + * @param nextMediaPeriodHolder The next holder, or null if this will be the new loading media + * period holder at the end of the queue. + */ + public void setNext(@Nullable MediaPeriodHolder nextMediaPeriodHolder) { + if (nextMediaPeriodHolder == next) { + return; + } + disableTrackSelectionsInResult(); + next = nextMediaPeriodHolder; + enableTrackSelectionsInResult(); + } + + /** + * Returns the next media period holder in the queue, or null if this is the last media period + * (and thus the loading media period). + */ + @Nullable + public MediaPeriodHolder getNext() { + return next; + } + + /** Returns the {@link TrackGroupArray} exposed by this media period. */ + public TrackGroupArray getTrackGroups() { + return trackGroups; + } + + /** Returns the {@link TrackSelectorResult} which is currently applied. */ + public TrackSelectorResult getTrackSelectorResult() { + return trackSelectorResult; + } + + private void enableTrackSelectionsInResult() { + if (!isLoadingMediaPeriod()) { + return; + } + for (int i = 0; i < trackSelectorResult.length; i++) { + boolean rendererEnabled = trackSelectorResult.isRendererEnabled(i); + TrackSelection trackSelection = trackSelectorResult.selections.get(i); + if (rendererEnabled && trackSelection != null) { + trackSelection.enable(); + } + } + } + + private void disableTrackSelectionsInResult() { + if (!isLoadingMediaPeriod()) { + return; + } + for (int i = 0; i < trackSelectorResult.length; i++) { + boolean rendererEnabled = trackSelectorResult.isRendererEnabled(i); + TrackSelection trackSelection = trackSelectorResult.selections.get(i); + if (rendererEnabled && trackSelection != null) { + trackSelection.disable(); + } + } + } + + /** + * For each renderer of type {@link C#TRACK_TYPE_NONE}, we will remove the dummy {@link + * EmptySampleStream} that was associated with it. + */ + private void disassociateNoSampleRenderersWithEmptySampleStream( + @NullableType SampleStream[] sampleStreams) { + for (int i = 0; i < rendererCapabilities.length; i++) { + if (rendererCapabilities[i].getTrackType() == C.TRACK_TYPE_NONE) { + sampleStreams[i] = null; + } + } + } + + /** + * For each renderer of type {@link C#TRACK_TYPE_NONE} that was enabled, we will associate it with + * a dummy {@link EmptySampleStream}. + */ + private void associateNoSampleRenderersWithEmptySampleStream( + @NullableType SampleStream[] sampleStreams) { + for (int i = 0; i < rendererCapabilities.length; i++) { + if (rendererCapabilities[i].getTrackType() == C.TRACK_TYPE_NONE + && trackSelectorResult.isRendererEnabled(i)) { + sampleStreams[i] = new EmptySampleStream(); + } + } + } + + private boolean isLoadingMediaPeriod() { + return next == null; + } + + /** Returns a media period corresponding to the given {@code id}. */ + private static MediaPeriod createMediaPeriod( + MediaPeriodId id, + MediaSource mediaSource, + Allocator allocator, + long startPositionUs, + long endPositionUs) { + MediaPeriod mediaPeriod = mediaSource.createPeriod(id, allocator, startPositionUs); + if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { + mediaPeriod = + new ClippingMediaPeriod( + mediaPeriod, /* enableInitialDiscontinuity= */ true, /* startUs= */ 0, endPositionUs); + } + return mediaPeriod; + } + + /** Releases the given {@code mediaPeriod}, logging and suppressing any errors. */ + private static void releaseMediaPeriod( + long endPositionUs, MediaSource mediaSource, MediaPeriod mediaPeriod) { + try { + if (endPositionUs != C.TIME_UNSET && endPositionUs != C.TIME_END_OF_SOURCE) { + mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); + } else { + mediaSource.releasePeriod(mediaPeriod); + } + } catch (RuntimeException e) { + // There's nothing we can do. + Log.e(TAG, "Period release failed.", e); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodInfo.java new file mode 100644 index 0000000000..b240fe0f91 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodInfo.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** Stores the information required to load and play a {@link MediaPeriod}. */ +/* package */ final class MediaPeriodInfo { + + /** The media period's identifier. */ + public final MediaPeriodId id; + /** The start position of the media to play within the media period, in microseconds. */ + public final long startPositionUs; + /** + * If this is an ad, the position to play in the next content media period. {@link C#TIME_UNSET} + * if this is not an ad or the next content media period should be played from its default + * position. + */ + public final long contentPositionUs; + /** + * The end position to which the media period's content is clipped in order to play a following ad + * group, in microseconds, or {@link C#TIME_UNSET} if there is no following ad group or if this + * media period is an ad. The value {@link C#TIME_END_OF_SOURCE} indicates that a postroll ad + * follows at the end of this content media period. + */ + public final long endPositionUs; + /** + * The duration of the media period, like {@link #endPositionUs} but with {@link + * C#TIME_END_OF_SOURCE} and {@link C#TIME_UNSET} resolved to the timeline period duration if + * known. + */ + public final long durationUs; + /** + * Whether this is the last media period in its timeline period (e.g., a postroll ad, or a media + * period corresponding to a timeline period without ads). + */ + public final boolean isLastInTimelinePeriod; + /** + * Whether this is the last media period in the entire timeline. If true, {@link + * #isLastInTimelinePeriod} will also be true. + */ + public final boolean isFinal; + + MediaPeriodInfo( + MediaPeriodId id, + long startPositionUs, + long contentPositionUs, + long endPositionUs, + long durationUs, + boolean isLastInTimelinePeriod, + boolean isFinal) { + this.id = id; + this.startPositionUs = startPositionUs; + this.contentPositionUs = contentPositionUs; + this.endPositionUs = endPositionUs; + this.durationUs = durationUs; + this.isLastInTimelinePeriod = isLastInTimelinePeriod; + this.isFinal = isFinal; + } + + /** + * Returns a copy of this instance with the start position set to the specified value. May return + * the same instance if nothing changed. + */ + public MediaPeriodInfo copyWithStartPositionUs(long startPositionUs) { + return startPositionUs == this.startPositionUs + ? this + : new MediaPeriodInfo( + id, + startPositionUs, + contentPositionUs, + endPositionUs, + durationUs, + isLastInTimelinePeriod, + isFinal); + } + + /** + * Returns a copy of this instance with the content position set to the specified value. May + * return the same instance if nothing changed. + */ + public MediaPeriodInfo copyWithContentPositionUs(long contentPositionUs) { + return contentPositionUs == this.contentPositionUs + ? this + : new MediaPeriodInfo( + id, + startPositionUs, + contentPositionUs, + endPositionUs, + durationUs, + isLastInTimelinePeriod, + isFinal); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MediaPeriodInfo that = (MediaPeriodInfo) o; + return startPositionUs == that.startPositionUs + && contentPositionUs == that.contentPositionUs + && endPositionUs == that.endPositionUs + && durationUs == that.durationUs + && isLastInTimelinePeriod == that.isLastInTimelinePeriod + && isFinal == that.isFinal + && Util.areEqual(id, that.id); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + (int) startPositionUs; + result = 31 * result + (int) contentPositionUs; + result = 31 * result + (int) endPositionUs; + result = 31 * result + (int) durationUs; + result = 31 * result + (isLastInTimelinePeriod ? 1 : 0); + result = 31 * result + (isFinal ? 1 : 0); + return result; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodQueue.java new file mode 100644 index 0000000000..941fb61848 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/MediaPeriodQueue.java @@ -0,0 +1,743 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.RepeatMode; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectorResult; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Holds a queue of media periods, from the currently playing media period at the front to the + * loading media period at the end of the queue, with methods for controlling loading and updating + * the queue. Also has a reference to the media period currently being read. + */ +/* package */ final class MediaPeriodQueue { + + /** + * Limits the maximum number of periods to buffer ahead of the current playing period. The + * buffering policy normally prevents buffering too far ahead, but the policy could allow too many + * small periods to be buffered if the period count were not limited. + */ + private static final int MAXIMUM_BUFFER_AHEAD_PERIODS = 100; + + private final Timeline.Period period; + private final Timeline.Window window; + + private long nextWindowSequenceNumber; + private Timeline timeline; + private @RepeatMode int repeatMode; + private boolean shuffleModeEnabled; + @Nullable private MediaPeriodHolder playing; + @Nullable private MediaPeriodHolder reading; + @Nullable private MediaPeriodHolder loading; + private int length; + @Nullable private Object oldFrontPeriodUid; + private long oldFrontPeriodWindowSequenceNumber; + + /** Creates a new media period queue. */ + public MediaPeriodQueue() { + period = new Timeline.Period(); + window = new Timeline.Window(); + timeline = Timeline.EMPTY; + } + + /** + * Sets the {@link Timeline}. Call {@link #updateQueuedPeriods(long, long)} to update the queued + * media periods to take into account the new timeline. + */ + public void setTimeline(Timeline timeline) { + this.timeline = timeline; + } + + /** + * Sets the {@link RepeatMode} and returns whether the repeat mode change has been fully handled. + * If not, it is necessary to seek to the current playback position. + */ + public boolean updateRepeatMode(@RepeatMode int repeatMode) { + this.repeatMode = repeatMode; + return updateForPlaybackModeChange(); + } + + /** + * Sets whether shuffling is enabled and returns whether the shuffle mode change has been fully + * handled. If not, it is necessary to seek to the current playback position. + */ + public boolean updateShuffleModeEnabled(boolean shuffleModeEnabled) { + this.shuffleModeEnabled = shuffleModeEnabled; + return updateForPlaybackModeChange(); + } + + /** Returns whether {@code mediaPeriod} is the current loading media period. */ + public boolean isLoading(MediaPeriod mediaPeriod) { + return loading != null && loading.mediaPeriod == mediaPeriod; + } + + /** + * If there is a loading period, reevaluates its buffer. + * + * @param rendererPositionUs The current renderer position. + */ + public void reevaluateBuffer(long rendererPositionUs) { + if (loading != null) { + loading.reevaluateBuffer(rendererPositionUs); + } + } + + /** Returns whether a new loading media period should be enqueued, if available. */ + public boolean shouldLoadNextMediaPeriod() { + return loading == null + || (!loading.info.isFinal + && loading.isFullyBuffered() + && loading.info.durationUs != C.TIME_UNSET + && length < MAXIMUM_BUFFER_AHEAD_PERIODS); + } + + /** + * Returns the {@link MediaPeriodInfo} for the next media period to load. + * + * @param rendererPositionUs The current renderer position. + * @param playbackInfo The current playback information. + * @return The {@link MediaPeriodInfo} for the next media period to load, or {@code null} if not + * yet known. + */ + public @Nullable MediaPeriodInfo getNextMediaPeriodInfo( + long rendererPositionUs, PlaybackInfo playbackInfo) { + return loading == null + ? getFirstMediaPeriodInfo(playbackInfo) + : getFollowingMediaPeriodInfo(loading, rendererPositionUs); + } + + /** + * Enqueues a new media period holder based on the specified information as the new loading media + * period, and returns it. + * + * @param rendererCapabilities The renderer capabilities. + * @param trackSelector The track selector. + * @param allocator The allocator. + * @param mediaSource The media source that produced the media period. + * @param info Information used to identify this media period in its timeline period. + * @param emptyTrackSelectorResult A {@link TrackSelectorResult} with empty selections for each + * renderer. + */ + public MediaPeriodHolder enqueueNextMediaPeriodHolder( + RendererCapabilities[] rendererCapabilities, + TrackSelector trackSelector, + Allocator allocator, + MediaSource mediaSource, + MediaPeriodInfo info, + TrackSelectorResult emptyTrackSelectorResult) { + long rendererPositionOffsetUs = + loading == null + ? (info.id.isAd() && info.contentPositionUs != C.TIME_UNSET + ? info.contentPositionUs + : 0) + : (loading.getRendererOffset() + loading.info.durationUs - info.startPositionUs); + MediaPeriodHolder newPeriodHolder = + new MediaPeriodHolder( + rendererCapabilities, + rendererPositionOffsetUs, + trackSelector, + allocator, + mediaSource, + info, + emptyTrackSelectorResult); + if (loading != null) { + loading.setNext(newPeriodHolder); + } else { + playing = newPeriodHolder; + reading = newPeriodHolder; + } + oldFrontPeriodUid = null; + loading = newPeriodHolder; + length++; + return newPeriodHolder; + } + + /** + * Returns the loading period holder which is at the end of the queue, or null if the queue is + * empty. + */ + @Nullable + public MediaPeriodHolder getLoadingPeriod() { + return loading; + } + + /** + * Returns the playing period holder which is at the front of the queue, or null if the queue is + * empty. + */ + @Nullable + public MediaPeriodHolder getPlayingPeriod() { + return playing; + } + + /** Returns the reading period holder, or null if the queue is empty. */ + @Nullable + public MediaPeriodHolder getReadingPeriod() { + return reading; + } + + /** + * Continues reading from the next period holder in the queue. + * + * @return The updated reading period holder. + */ + public MediaPeriodHolder advanceReadingPeriod() { + Assertions.checkState(reading != null && reading.getNext() != null); + reading = reading.getNext(); + return reading; + } + + /** + * Dequeues the playing period holder from the front of the queue and advances the playing period + * holder to be the next item in the queue. + * + * @return The updated playing period holder, or null if the queue is or becomes empty. + */ + @Nullable + public MediaPeriodHolder advancePlayingPeriod() { + if (playing == null) { + return null; + } + if (playing == reading) { + reading = playing.getNext(); + } + playing.release(); + length--; + if (length == 0) { + loading = null; + oldFrontPeriodUid = playing.uid; + oldFrontPeriodWindowSequenceNumber = playing.info.id.windowSequenceNumber; + } + playing = playing.getNext(); + return playing; + } + + /** + * Removes all period holders after the given period holder. This process may also remove the + * currently reading period holder. If that is the case, the reading period holder is set to be + * the same as the playing period holder at the front of the queue. + * + * @param mediaPeriodHolder The media period holder that shall be the new end of the queue. + * @return Whether the reading period has been removed. + */ + public boolean removeAfter(MediaPeriodHolder mediaPeriodHolder) { + Assertions.checkState(mediaPeriodHolder != null); + boolean removedReading = false; + loading = mediaPeriodHolder; + while (mediaPeriodHolder.getNext() != null) { + mediaPeriodHolder = mediaPeriodHolder.getNext(); + if (mediaPeriodHolder == reading) { + reading = playing; + removedReading = true; + } + mediaPeriodHolder.release(); + length--; + } + loading.setNext(null); + return removedReading; + } + + /** + * Clears the queue. + * + * @param keepFrontPeriodUid Whether the queue should keep the id of the media period in the front + * of queue (typically the playing one) for later reuse. + */ + public void clear(boolean keepFrontPeriodUid) { + MediaPeriodHolder front = playing; + if (front != null) { + oldFrontPeriodUid = keepFrontPeriodUid ? front.uid : null; + oldFrontPeriodWindowSequenceNumber = front.info.id.windowSequenceNumber; + removeAfter(front); + front.release(); + } else if (!keepFrontPeriodUid) { + oldFrontPeriodUid = null; + } + playing = null; + loading = null; + reading = null; + length = 0; + } + + /** + * Updates media periods in the queue to take into account the latest timeline, and returns + * whether the timeline change has been fully handled. If not, it is necessary to seek to the + * current playback position. The method assumes that the first media period in the queue is still + * consistent with the new timeline. + * + * @param rendererPositionUs The current renderer position in microseconds. + * @param maxRendererReadPositionUs The maximum renderer position up to which renderers have read + * the current reading media period in microseconds, or {@link C#TIME_END_OF_SOURCE} if they + * have read to the end. + * @return Whether the timeline change has been handled completely. + */ + public boolean updateQueuedPeriods(long rendererPositionUs, long maxRendererReadPositionUs) { + // TODO: Merge this into setTimeline so that the queue gets updated as soon as the new timeline + // is set, once all cases handled by ExoPlayerImplInternal.handleSourceInfoRefreshed can be + // handled here. + MediaPeriodHolder previousPeriodHolder = null; + MediaPeriodHolder periodHolder = playing; + while (periodHolder != null) { + MediaPeriodInfo oldPeriodInfo = periodHolder.info; + + // Get period info based on new timeline. + MediaPeriodInfo newPeriodInfo; + if (previousPeriodHolder == null) { + // The id and start position of the first period have already been verified by + // ExoPlayerImplInternal.handleSourceInfoRefreshed. Just update duration, isLastInTimeline + // and isLastInPeriod flags. + newPeriodInfo = getUpdatedMediaPeriodInfo(oldPeriodInfo); + } else { + newPeriodInfo = getFollowingMediaPeriodInfo(previousPeriodHolder, rendererPositionUs); + if (newPeriodInfo == null) { + // We've loaded a next media period that is not in the new timeline. + return !removeAfter(previousPeriodHolder); + } + if (!canKeepMediaPeriodHolder(oldPeriodInfo, newPeriodInfo)) { + // The new media period has a different id or start position. + return !removeAfter(previousPeriodHolder); + } + } + + // Use new period info, but keep old content position. + periodHolder.info = newPeriodInfo.copyWithContentPositionUs(oldPeriodInfo.contentPositionUs); + + if (!areDurationsCompatible(oldPeriodInfo.durationUs, newPeriodInfo.durationUs)) { + // The period duration changed. Remove all subsequent periods and check whether we read + // beyond the new duration. + long newDurationInRendererTime = + newPeriodInfo.durationUs == C.TIME_UNSET + ? Long.MAX_VALUE + : periodHolder.toRendererTime(newPeriodInfo.durationUs); + boolean isReadingAndReadBeyondNewDuration = + periodHolder == reading + && (maxRendererReadPositionUs == C.TIME_END_OF_SOURCE + || maxRendererReadPositionUs >= newDurationInRendererTime); + boolean readingPeriodRemoved = removeAfter(periodHolder); + return !readingPeriodRemoved && !isReadingAndReadBeyondNewDuration; + } + + previousPeriodHolder = periodHolder; + periodHolder = periodHolder.getNext(); + } + return true; + } + + /** + * Returns new media period info based on specified {@code mediaPeriodInfo} but taking into + * account the current timeline. This method must only be called if the period is still part of + * the current timeline. + * + * @param info Media period info for a media period based on an old timeline. + * @return The updated media period info for the current timeline. + */ + public MediaPeriodInfo getUpdatedMediaPeriodInfo(MediaPeriodInfo info) { + MediaPeriodId id = info.id; + boolean isLastInPeriod = isLastInPeriod(id); + boolean isLastInTimeline = isLastInTimeline(id, isLastInPeriod); + timeline.getPeriodByUid(info.id.periodUid, period); + long durationUs = + id.isAd() + ? period.getAdDurationUs(id.adGroupIndex, id.adIndexInAdGroup) + : (info.endPositionUs == C.TIME_UNSET || info.endPositionUs == C.TIME_END_OF_SOURCE + ? period.getDurationUs() + : info.endPositionUs); + return new MediaPeriodInfo( + id, + info.startPositionUs, + info.contentPositionUs, + info.endPositionUs, + durationUs, + isLastInPeriod, + isLastInTimeline); + } + + /** + * Resolves the specified timeline period and position to a {@link MediaPeriodId} that should be + * played, returning an identifier for an ad group if one needs to be played before the specified + * position, or an identifier for a content media period if not. + * + * @param periodUid The uid of the timeline period to play. + * @param positionUs The next content position in the period to play. + * @return The identifier for the first media period to play, taking into account unplayed ads. + */ + public MediaPeriodId resolveMediaPeriodIdForAds(Object periodUid, long positionUs) { + long windowSequenceNumber = resolvePeriodIndexToWindowSequenceNumber(periodUid); + return resolveMediaPeriodIdForAds(periodUid, positionUs, windowSequenceNumber); + } + + // Internal methods. + + /** + * Resolves the specified timeline period and position to a {@link MediaPeriodId} that should be + * played, returning an identifier for an ad group if one needs to be played before the specified + * position, or an identifier for a content media period if not. + * + * @param periodUid The uid of the timeline period to play. + * @param positionUs The next content position in the period to play. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this period is part of. + * @return The identifier for the first media period to play, taking into account unplayed ads. + */ + private MediaPeriodId resolveMediaPeriodIdForAds( + Object periodUid, long positionUs, long windowSequenceNumber) { + timeline.getPeriodByUid(periodUid, period); + int adGroupIndex = period.getAdGroupIndexForPositionUs(positionUs); + if (adGroupIndex == C.INDEX_UNSET) { + int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(positionUs); + return new MediaPeriodId(periodUid, windowSequenceNumber, nextAdGroupIndex); + } else { + int adIndexInAdGroup = period.getFirstAdIndexToPlay(adGroupIndex); + return new MediaPeriodId(periodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber); + } + } + + /** + * Resolves the specified period uid to a corresponding window sequence number. Either by reusing + * the window sequence number of an existing matching media period or by creating a new window + * sequence number. + * + * @param periodUid The uid of the timeline period. + * @return A window sequence number for a media period created for this timeline period. + */ + private long resolvePeriodIndexToWindowSequenceNumber(Object periodUid) { + int windowIndex = timeline.getPeriodByUid(periodUid, period).windowIndex; + if (oldFrontPeriodUid != null) { + int oldFrontPeriodIndex = timeline.getIndexOfPeriod(oldFrontPeriodUid); + if (oldFrontPeriodIndex != C.INDEX_UNSET) { + int oldFrontWindowIndex = timeline.getPeriod(oldFrontPeriodIndex, period).windowIndex; + if (oldFrontWindowIndex == windowIndex) { + // Try to match old front uid after the queue has been cleared. + return oldFrontPeriodWindowSequenceNumber; + } + } + } + MediaPeriodHolder mediaPeriodHolder = playing; + while (mediaPeriodHolder != null) { + if (mediaPeriodHolder.uid.equals(periodUid)) { + // Reuse window sequence number of first exact period match. + return mediaPeriodHolder.info.id.windowSequenceNumber; + } + mediaPeriodHolder = mediaPeriodHolder.getNext(); + } + mediaPeriodHolder = playing; + while (mediaPeriodHolder != null) { + int indexOfHolderInTimeline = timeline.getIndexOfPeriod(mediaPeriodHolder.uid); + if (indexOfHolderInTimeline != C.INDEX_UNSET) { + int holderWindowIndex = timeline.getPeriod(indexOfHolderInTimeline, period).windowIndex; + if (holderWindowIndex == windowIndex) { + // As an alternative, try to match other periods of the same window. + return mediaPeriodHolder.info.id.windowSequenceNumber; + } + } + mediaPeriodHolder = mediaPeriodHolder.getNext(); + } + // If no match is found, create new sequence number. + long windowSequenceNumber = nextWindowSequenceNumber++; + if (playing == null) { + // If the queue is empty, save it as old front uid to allow later reuse. + oldFrontPeriodUid = periodUid; + oldFrontPeriodWindowSequenceNumber = windowSequenceNumber; + } + return windowSequenceNumber; + } + + /** + * Returns whether a period described by {@code oldInfo} can be kept for playing the media period + * described by {@code newInfo}. + */ + private boolean canKeepMediaPeriodHolder(MediaPeriodInfo oldInfo, MediaPeriodInfo newInfo) { + return oldInfo.startPositionUs == newInfo.startPositionUs && oldInfo.id.equals(newInfo.id); + } + + /** + * Returns whether a duration change of a period is compatible with keeping the following periods. + */ + private boolean areDurationsCompatible(long previousDurationUs, long newDurationUs) { + return previousDurationUs == C.TIME_UNSET || previousDurationUs == newDurationUs; + } + + /** + * Updates the queue for any playback mode change, and returns whether the change was fully + * handled. If not, it is necessary to seek to the current playback position. + */ + private boolean updateForPlaybackModeChange() { + // Find the last existing period holder that matches the new period order. + MediaPeriodHolder lastValidPeriodHolder = playing; + if (lastValidPeriodHolder == null) { + return true; + } + int currentPeriodIndex = timeline.getIndexOfPeriod(lastValidPeriodHolder.uid); + while (true) { + int nextPeriodIndex = + timeline.getNextPeriodIndex( + currentPeriodIndex, period, window, repeatMode, shuffleModeEnabled); + while (lastValidPeriodHolder.getNext() != null + && !lastValidPeriodHolder.info.isLastInTimelinePeriod) { + lastValidPeriodHolder = lastValidPeriodHolder.getNext(); + } + + MediaPeriodHolder nextMediaPeriodHolder = lastValidPeriodHolder.getNext(); + if (nextPeriodIndex == C.INDEX_UNSET || nextMediaPeriodHolder == null) { + break; + } + int nextPeriodHolderPeriodIndex = timeline.getIndexOfPeriod(nextMediaPeriodHolder.uid); + if (nextPeriodHolderPeriodIndex != nextPeriodIndex) { + break; + } + lastValidPeriodHolder = nextMediaPeriodHolder; + currentPeriodIndex = nextPeriodIndex; + } + + // Release any period holders that don't match the new period order. + boolean readingPeriodRemoved = removeAfter(lastValidPeriodHolder); + + // Update the period info for the last holder, as it may now be the last period in the timeline. + lastValidPeriodHolder.info = getUpdatedMediaPeriodInfo(lastValidPeriodHolder.info); + + // If renderers may have read from a period that's been removed, it is necessary to restart. + return !readingPeriodRemoved; + } + + /** + * Returns the first {@link MediaPeriodInfo} to play, based on the specified playback position. + */ + private MediaPeriodInfo getFirstMediaPeriodInfo(PlaybackInfo playbackInfo) { + return getMediaPeriodInfo( + playbackInfo.periodId, playbackInfo.contentPositionUs, playbackInfo.startPositionUs); + } + + /** + * Returns the {@link MediaPeriodInfo} for the media period following {@code mediaPeriodHolder}'s + * media period. + * + * @param mediaPeriodHolder The media period holder. + * @param rendererPositionUs The current renderer position in microseconds. + * @return The following media period's info, or {@code null} if it is not yet possible to get the + * next media period info. + */ + private @Nullable MediaPeriodInfo getFollowingMediaPeriodInfo( + MediaPeriodHolder mediaPeriodHolder, long rendererPositionUs) { + // TODO: This method is called repeatedly from ExoPlayerImplInternal.maybeUpdateLoadingPeriod + // but if the timeline is not ready to provide the next period it can't return a non-null value + // until the timeline is updated. Store whether the next timeline period is ready when the + // timeline is updated, to avoid repeatedly checking the same timeline. + MediaPeriodInfo mediaPeriodInfo = mediaPeriodHolder.info; + // The expected delay until playback transitions to the new period is equal the duration of + // media that's currently buffered (assuming no interruptions). This is used to project forward + // the start position for transitions to new windows. + long bufferedDurationUs = + mediaPeriodHolder.getRendererOffset() + mediaPeriodInfo.durationUs - rendererPositionUs; + if (mediaPeriodInfo.isLastInTimelinePeriod) { + int currentPeriodIndex = timeline.getIndexOfPeriod(mediaPeriodInfo.id.periodUid); + int nextPeriodIndex = + timeline.getNextPeriodIndex( + currentPeriodIndex, period, window, repeatMode, shuffleModeEnabled); + if (nextPeriodIndex == C.INDEX_UNSET) { + // We can't create a next period yet. + return null; + } + + long startPositionUs; + long contentPositionUs; + int nextWindowIndex = + timeline.getPeriod(nextPeriodIndex, period, /* setIds= */ true).windowIndex; + Object nextPeriodUid = period.uid; + long windowSequenceNumber = mediaPeriodInfo.id.windowSequenceNumber; + if (timeline.getWindow(nextWindowIndex, window).firstPeriodIndex == nextPeriodIndex) { + // We're starting to buffer a new window. When playback transitions to this window we'll + // want it to be from its default start position, so project the default start position + // forward by the duration of the buffer, and start buffering from this point. + contentPositionUs = C.TIME_UNSET; + Pair<Object, Long> defaultPosition = + timeline.getPeriodPosition( + window, + period, + nextWindowIndex, + /* windowPositionUs= */ C.TIME_UNSET, + /* defaultPositionProjectionUs= */ Math.max(0, bufferedDurationUs)); + if (defaultPosition == null) { + return null; + } + nextPeriodUid = defaultPosition.first; + startPositionUs = defaultPosition.second; + MediaPeriodHolder nextMediaPeriodHolder = mediaPeriodHolder.getNext(); + if (nextMediaPeriodHolder != null && nextMediaPeriodHolder.uid.equals(nextPeriodUid)) { + windowSequenceNumber = nextMediaPeriodHolder.info.id.windowSequenceNumber; + } else { + windowSequenceNumber = nextWindowSequenceNumber++; + } + } else { + // We're starting to buffer a new period within the same window. + startPositionUs = 0; + contentPositionUs = 0; + } + MediaPeriodId periodId = + resolveMediaPeriodIdForAds(nextPeriodUid, startPositionUs, windowSequenceNumber); + return getMediaPeriodInfo(periodId, contentPositionUs, startPositionUs); + } + + MediaPeriodId currentPeriodId = mediaPeriodInfo.id; + timeline.getPeriodByUid(currentPeriodId.periodUid, period); + if (currentPeriodId.isAd()) { + int adGroupIndex = currentPeriodId.adGroupIndex; + int adCountInCurrentAdGroup = period.getAdCountInAdGroup(adGroupIndex); + if (adCountInCurrentAdGroup == C.LENGTH_UNSET) { + return null; + } + int nextAdIndexInAdGroup = + period.getNextAdIndexToPlay(adGroupIndex, currentPeriodId.adIndexInAdGroup); + if (nextAdIndexInAdGroup < adCountInCurrentAdGroup) { + // Play the next ad in the ad group if it's available. + return !period.isAdAvailable(adGroupIndex, nextAdIndexInAdGroup) + ? null + : getMediaPeriodInfoForAd( + currentPeriodId.periodUid, + adGroupIndex, + nextAdIndexInAdGroup, + mediaPeriodInfo.contentPositionUs, + currentPeriodId.windowSequenceNumber); + } else { + // Play content from the ad group position. + long startPositionUs = mediaPeriodInfo.contentPositionUs; + if (startPositionUs == C.TIME_UNSET) { + // If we're transitioning from an ad group to content starting from its default position, + // project the start position forward as if this were a transition to a new window. + Pair<Object, Long> defaultPosition = + timeline.getPeriodPosition( + window, + period, + period.windowIndex, + /* windowPositionUs= */ C.TIME_UNSET, + /* defaultPositionProjectionUs= */ Math.max(0, bufferedDurationUs)); + if (defaultPosition == null) { + return null; + } + startPositionUs = defaultPosition.second; + } + return getMediaPeriodInfoForContent( + currentPeriodId.periodUid, startPositionUs, currentPeriodId.windowSequenceNumber); + } + } else { + // Play the next ad group if it's available. + int nextAdGroupIndex = period.getAdGroupIndexForPositionUs(mediaPeriodInfo.endPositionUs); + if (nextAdGroupIndex == C.INDEX_UNSET) { + // The next ad group can't be played. Play content from the previous end position instead. + return getMediaPeriodInfoForContent( + currentPeriodId.periodUid, + /* startPositionUs= */ mediaPeriodInfo.durationUs, + currentPeriodId.windowSequenceNumber); + } + int adIndexInAdGroup = period.getFirstAdIndexToPlay(nextAdGroupIndex); + return !period.isAdAvailable(nextAdGroupIndex, adIndexInAdGroup) + ? null + : getMediaPeriodInfoForAd( + currentPeriodId.periodUid, + nextAdGroupIndex, + adIndexInAdGroup, + /* contentPositionUs= */ mediaPeriodInfo.durationUs, + currentPeriodId.windowSequenceNumber); + } + } + + private MediaPeriodInfo getMediaPeriodInfo( + MediaPeriodId id, long contentPositionUs, long startPositionUs) { + timeline.getPeriodByUid(id.periodUid, period); + if (id.isAd()) { + if (!period.isAdAvailable(id.adGroupIndex, id.adIndexInAdGroup)) { + return null; + } + return getMediaPeriodInfoForAd( + id.periodUid, + id.adGroupIndex, + id.adIndexInAdGroup, + contentPositionUs, + id.windowSequenceNumber); + } else { + return getMediaPeriodInfoForContent(id.periodUid, startPositionUs, id.windowSequenceNumber); + } + } + + private MediaPeriodInfo getMediaPeriodInfoForAd( + Object periodUid, + int adGroupIndex, + int adIndexInAdGroup, + long contentPositionUs, + long windowSequenceNumber) { + MediaPeriodId id = + new MediaPeriodId(periodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber); + long durationUs = + timeline + .getPeriodByUid(id.periodUid, period) + .getAdDurationUs(id.adGroupIndex, id.adIndexInAdGroup); + long startPositionUs = + adIndexInAdGroup == period.getFirstAdIndexToPlay(adGroupIndex) + ? period.getAdResumePositionUs() + : 0; + return new MediaPeriodInfo( + id, + startPositionUs, + contentPositionUs, + /* endPositionUs= */ C.TIME_UNSET, + durationUs, + /* isLastInTimelinePeriod= */ false, + /* isFinal= */ false); + } + + private MediaPeriodInfo getMediaPeriodInfoForContent( + Object periodUid, long startPositionUs, long windowSequenceNumber) { + int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(startPositionUs); + MediaPeriodId id = new MediaPeriodId(periodUid, windowSequenceNumber, nextAdGroupIndex); + boolean isLastInPeriod = isLastInPeriod(id); + boolean isLastInTimeline = isLastInTimeline(id, isLastInPeriod); + long endPositionUs = + nextAdGroupIndex != C.INDEX_UNSET + ? period.getAdGroupTimeUs(nextAdGroupIndex) + : C.TIME_UNSET; + long durationUs = + endPositionUs == C.TIME_UNSET || endPositionUs == C.TIME_END_OF_SOURCE + ? period.durationUs + : endPositionUs; + return new MediaPeriodInfo( + id, + startPositionUs, + /* contentPositionUs= */ C.TIME_UNSET, + endPositionUs, + durationUs, + isLastInPeriod, + isLastInTimeline); + } + + private boolean isLastInPeriod(MediaPeriodId id) { + return !id.isAd() && id.nextAdGroupIndex == C.INDEX_UNSET; + } + + private boolean isLastInTimeline(MediaPeriodId id, boolean isLastMediaPeriodInPeriod) { + int periodIndex = timeline.getIndexOfPeriod(id.periodUid); + int windowIndex = timeline.getPeriod(periodIndex, period).windowIndex; + return !timeline.getWindow(windowIndex, window).isDynamic + && timeline.isLastPeriod(periodIndex, period, window, repeatMode, shuffleModeEnabled) + && isLastMediaPeriodInPeriod; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/NoSampleRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/NoSampleRenderer.java new file mode 100644 index 0000000000..c4662f1544 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/NoSampleRenderer.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MediaClock; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link Renderer} implementation whose track type is {@link C#TRACK_TYPE_NONE} and does not + * consume data from its {@link SampleStream}. + */ +public abstract class NoSampleRenderer implements Renderer, RendererCapabilities { + + @MonotonicNonNull private RendererConfiguration configuration; + private int index; + private int state; + @Nullable private SampleStream stream; + private boolean streamIsFinal; + + @Override + public final int getTrackType() { + return C.TRACK_TYPE_NONE; + } + + @Override + public final RendererCapabilities getCapabilities() { + return this; + } + + @Override + public final void setIndex(int index) { + this.index = index; + } + + @Override + @Nullable + public MediaClock getMediaClock() { + return null; + } + + @Override + public final int getState() { + return state; + } + + /** + * Replaces the {@link SampleStream} that will be associated with this renderer. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_DISABLED}. + * + * @param configuration The renderer configuration. + * @param formats The enabled formats. Should be empty. + * @param stream The {@link SampleStream} from which the renderer should consume. + * @param positionUs The player's current position. + * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @param offsetUs The offset that should be subtracted from {@code positionUs} + * to get the playback position with respect to the media. + * @throws ExoPlaybackException If an error occurs. + */ + @Override + public final void enable(RendererConfiguration configuration, Format[] formats, + SampleStream stream, long positionUs, boolean joining, long offsetUs) + throws ExoPlaybackException { + Assertions.checkState(state == STATE_DISABLED); + this.configuration = configuration; + state = STATE_ENABLED; + onEnabled(joining); + replaceStream(formats, stream, offsetUs); + onPositionReset(positionUs, joining); + } + + @Override + public final void start() throws ExoPlaybackException { + Assertions.checkState(state == STATE_ENABLED); + state = STATE_STARTED; + onStarted(); + } + + /** + * Replaces the {@link SampleStream} that will be associated with this renderer. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @param formats The enabled formats. Should be empty. + * @param stream The {@link SampleStream} to be associated with this renderer. + * @param offsetUs The offset that should be subtracted from {@code positionUs} in + * {@link #render(long, long)} to get the playback position with respect to the media. + * @throws ExoPlaybackException If an error occurs. + */ + @Override + public final void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + throws ExoPlaybackException { + Assertions.checkState(!streamIsFinal); + this.stream = stream; + onRendererOffsetChanged(offsetUs); + } + + @Override + @Nullable + public final SampleStream getStream() { + return stream; + } + + @Override + public final boolean hasReadStreamToEnd() { + return true; + } + + @Override + public long getReadingPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public final void setCurrentStreamFinal() { + streamIsFinal = true; + } + + @Override + public final boolean isCurrentStreamFinal() { + return streamIsFinal; + } + + @Override + public final void maybeThrowStreamError() throws IOException { + } + + @Override + public final void resetPosition(long positionUs) throws ExoPlaybackException { + streamIsFinal = false; + onPositionReset(positionUs, false); + } + + @Override + public final void stop() throws ExoPlaybackException { + Assertions.checkState(state == STATE_STARTED); + state = STATE_ENABLED; + onStopped(); + } + + @Override + public final void disable() { + Assertions.checkState(state == STATE_ENABLED); + state = STATE_DISABLED; + stream = null; + streamIsFinal = false; + onDisabled(); + } + + @Override + public final void reset() { + Assertions.checkState(state == STATE_DISABLED); + onReset(); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public boolean isEnded() { + return true; + } + + // RendererCapabilities implementation. + + @Override + @Capabilities + public int supportsFormat(Format format) throws ExoPlaybackException { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + + @Override + @AdaptiveSupport + public int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException { + return ADAPTIVE_NOT_SUPPORTED; + } + + // PlayerMessage.Target implementation. + + @Override + public void handleMessage(int what, @Nullable Object object) throws ExoPlaybackException { + // Do nothing. + } + + // Methods to be overridden by subclasses. + + /** + * Called when the renderer is enabled. + * <p> + * The default implementation is a no-op. + * + * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @throws ExoPlaybackException If an error occurs. + */ + protected void onEnabled(boolean joining) throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer's offset has been changed. + * <p> + * The default implementation is a no-op. + * + * @param offsetUs The offset that should be subtracted from {@code positionUs} in + * {@link #render(long, long)} to get the playback position with respect to the media. + * @throws ExoPlaybackException If an error occurs. + */ + protected void onRendererOffsetChanged(long offsetUs) throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the position is reset. This occurs when the renderer is enabled after + * {@link #onRendererOffsetChanged(long)} has been called, and also when a position + * discontinuity is encountered. + * <p> + * The default implementation is a no-op. + * + * @param positionUs The new playback position in microseconds. + * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @throws ExoPlaybackException If an error occurs. + */ + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer is started. + * <p> + * The default implementation is a no-op. + * + * @throws ExoPlaybackException If an error occurs. + */ + protected void onStarted() throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer is stopped. + * <p> + * The default implementation is a no-op. + * + * @throws ExoPlaybackException If an error occurs. + */ + protected void onStopped() throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called when the renderer is disabled. + * <p> + * The default implementation is a no-op. + */ + protected void onDisabled() { + // Do nothing. + } + + /** + * Called when the renderer is reset. + * + * <p>The default implementation is a no-op. + */ + protected void onReset() { + // Do nothing. + } + + // Methods to be called by subclasses. + + /** + * Returns the configuration set when the renderer was most recently enabled, or {@code null} if + * the renderer has never been enabled. + */ + @Nullable + protected final RendererConfiguration getConfiguration() { + return configuration; + } + + /** + * Returns the index of the renderer within the player. + */ + protected final int getIndex() { + return index; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ParserException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ParserException.java new file mode 100644 index 0000000000..abbe6e8fee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/ParserException.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import java.io.IOException; + +/** + * Thrown when an error occurs parsing media data and metadata. + */ +public class ParserException extends IOException { + + public ParserException() { + super(); + } + + /** + * @param message The detail message for the exception. + */ + public ParserException(String message) { + super(message); + } + + /** + * @param cause The cause for the exception. + */ + public ParserException(Throwable cause) { + super(cause); + } + + /** + * @param message The detail message for the exception. + * @param cause The cause for the exception. + */ + public ParserException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackInfo.java new file mode 100644 index 0000000000..c743e35661 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackInfo.java @@ -0,0 +1,358 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectorResult; + +/** + * Information about an ongoing playback. + */ +/* package */ final class PlaybackInfo { + + /** + * Dummy media period id used while the timeline is empty and no period id is specified. This id + * is used when playback infos are created with {@link #createDummy(long, TrackSelectorResult)}. + */ + private static final MediaPeriodId DUMMY_MEDIA_PERIOD_ID = + new MediaPeriodId(/* periodUid= */ new Object()); + + /** The current {@link Timeline}. */ + public final Timeline timeline; + /** The {@link MediaPeriodId} of the currently playing media period in the {@link #timeline}. */ + public final MediaPeriodId periodId; + /** + * The start position at which playback started in {@link #periodId} relative to the start of the + * associated period in the {@link #timeline}, in microseconds. Note that this value changes for + * each position discontinuity. + */ + public final long startPositionUs; + /** + * If {@link #periodId} refers to an ad, the position of the suspended content relative to the + * start of the associated period in the {@link #timeline}, in microseconds. {@link C#TIME_UNSET} + * if {@link #periodId} does not refer to an ad or if the suspended content should be played from + * its default position. + */ + public final long contentPositionUs; + /** The current playback state. One of the {@link Player}.STATE_ constants. */ + @Player.State public final int playbackState; + /** The current playback error, or null if this is not an error state. */ + @Nullable public final ExoPlaybackException playbackError; + /** Whether the player is currently loading. */ + public final boolean isLoading; + /** The currently available track groups. */ + public final TrackGroupArray trackGroups; + /** The result of the current track selection. */ + public final TrackSelectorResult trackSelectorResult; + /** The {@link MediaPeriodId} of the currently loading media period in the {@link #timeline}. */ + public final MediaPeriodId loadingMediaPeriodId; + + /** + * Position up to which media is buffered in {@link #loadingMediaPeriodId) relative to the start + * of the associated period in the {@link #timeline}, in microseconds. + */ + public volatile long bufferedPositionUs; + /** + * Total duration of buffered media from {@link #positionUs} to {@link #bufferedPositionUs} + * including all ads. + */ + public volatile long totalBufferedDurationUs; + /** + * Current playback position in {@link #periodId} relative to the start of the associated period + * in the {@link #timeline}, in microseconds. + */ + public volatile long positionUs; + + /** + * Creates empty dummy playback info which can be used for masking as long as no real playback + * info is available. + * + * @param startPositionUs The start position at which playback should start, in microseconds. + * @param emptyTrackSelectorResult An empty track selector result with null entries for each + * renderer. + * @return A dummy playback info. + */ + public static PlaybackInfo createDummy( + long startPositionUs, TrackSelectorResult emptyTrackSelectorResult) { + return new PlaybackInfo( + Timeline.EMPTY, + DUMMY_MEDIA_PERIOD_ID, + startPositionUs, + /* contentPositionUs= */ C.TIME_UNSET, + Player.STATE_IDLE, + /* playbackError= */ null, + /* isLoading= */ false, + TrackGroupArray.EMPTY, + emptyTrackSelectorResult, + DUMMY_MEDIA_PERIOD_ID, + startPositionUs, + /* totalBufferedDurationUs= */ 0, + startPositionUs); + } + + /** + * Create playback info. + * + * @param timeline See {@link #timeline}. + * @param periodId See {@link #periodId}. + * @param startPositionUs See {@link #startPositionUs}. + * @param contentPositionUs See {@link #contentPositionUs}. + * @param playbackState See {@link #playbackState}. + * @param isLoading See {@link #isLoading}. + * @param trackGroups See {@link #trackGroups}. + * @param trackSelectorResult See {@link #trackSelectorResult}. + * @param loadingMediaPeriodId See {@link #loadingMediaPeriodId}. + * @param bufferedPositionUs See {@link #bufferedPositionUs}. + * @param totalBufferedDurationUs See {@link #totalBufferedDurationUs}. + * @param positionUs See {@link #positionUs}. + */ + public PlaybackInfo( + Timeline timeline, + MediaPeriodId periodId, + long startPositionUs, + long contentPositionUs, + @Player.State int playbackState, + @Nullable ExoPlaybackException playbackError, + boolean isLoading, + TrackGroupArray trackGroups, + TrackSelectorResult trackSelectorResult, + MediaPeriodId loadingMediaPeriodId, + long bufferedPositionUs, + long totalBufferedDurationUs, + long positionUs) { + this.timeline = timeline; + this.periodId = periodId; + this.startPositionUs = startPositionUs; + this.contentPositionUs = contentPositionUs; + this.playbackState = playbackState; + this.playbackError = playbackError; + this.isLoading = isLoading; + this.trackGroups = trackGroups; + this.trackSelectorResult = trackSelectorResult; + this.loadingMediaPeriodId = loadingMediaPeriodId; + this.bufferedPositionUs = bufferedPositionUs; + this.totalBufferedDurationUs = totalBufferedDurationUs; + this.positionUs = positionUs; + } + + /** + * Returns dummy media period id for the first-to-be-played period of the current timeline. + * + * @param shuffleModeEnabled Whether shuffle mode is enabled. + * @param window A writable {@link Timeline.Window}. + * @param period A writable {@link Timeline.Period}. + * @return A dummy media period id for the first-to-be-played period of the current timeline. + */ + public MediaPeriodId getDummyFirstMediaPeriodId( + boolean shuffleModeEnabled, Timeline.Window window, Timeline.Period period) { + if (timeline.isEmpty()) { + return DUMMY_MEDIA_PERIOD_ID; + } + int firstWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled); + int firstPeriodIndex = timeline.getWindow(firstWindowIndex, window).firstPeriodIndex; + int currentPeriodIndex = timeline.getIndexOfPeriod(periodId.periodUid); + long windowSequenceNumber = C.INDEX_UNSET; + if (currentPeriodIndex != C.INDEX_UNSET) { + int currentWindowIndex = timeline.getPeriod(currentPeriodIndex, period).windowIndex; + if (firstWindowIndex == currentWindowIndex) { + // Keep window sequence number if the new position is still in the same window. + windowSequenceNumber = periodId.windowSequenceNumber; + } + } + return new MediaPeriodId(timeline.getUidOfPeriod(firstPeriodIndex), windowSequenceNumber); + } + + /** + * Copies playback info with new playing position. + * + * @param periodId New playing media period. See {@link #periodId}. + * @param positionUs New position. See {@link #positionUs}. + * @param contentPositionUs New content position. See {@link #contentPositionUs}. Value is ignored + * if {@code periodId.isAd()} is true. + * @param totalBufferedDurationUs New buffered duration. See {@link #totalBufferedDurationUs}. + * @return Copied playback info with new playing position. + */ + @CheckResult + public PlaybackInfo copyWithNewPosition( + MediaPeriodId periodId, + long positionUs, + long contentPositionUs, + long totalBufferedDurationUs) { + return new PlaybackInfo( + timeline, + periodId, + positionUs, + periodId.isAd() ? contentPositionUs : C.TIME_UNSET, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } + + /** + * Copies playback info with the new timeline. + * + * @param timeline New timeline. See {@link #timeline}. + * @return Copied playback info with the new timeline. + */ + @CheckResult + public PlaybackInfo copyWithTimeline(Timeline timeline) { + return new PlaybackInfo( + timeline, + periodId, + startPositionUs, + contentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } + + /** + * Copies playback info with new playback state. + * + * @param playbackState New playback state. See {@link #playbackState}. + * @return Copied playback info with new playback state. + */ + @CheckResult + public PlaybackInfo copyWithPlaybackState(int playbackState) { + return new PlaybackInfo( + timeline, + periodId, + startPositionUs, + contentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } + + /** + * Copies playback info with a playback error. + * + * @param playbackError The error. See {@link #playbackError}. + * @return Copied playback info with the playback error. + */ + @CheckResult + public PlaybackInfo copyWithPlaybackError(@Nullable ExoPlaybackException playbackError) { + return new PlaybackInfo( + timeline, + periodId, + startPositionUs, + contentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } + + /** + * Copies playback info with new loading state. + * + * @param isLoading New loading state. See {@link #isLoading}. + * @return Copied playback info with new loading state. + */ + @CheckResult + public PlaybackInfo copyWithIsLoading(boolean isLoading) { + return new PlaybackInfo( + timeline, + periodId, + startPositionUs, + contentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } + + /** + * Copies playback info with new track information. + * + * @param trackGroups New track groups. See {@link #trackGroups}. + * @param trackSelectorResult New track selector result. See {@link #trackSelectorResult}. + * @return Copied playback info with new track information. + */ + @CheckResult + public PlaybackInfo copyWithTrackInfo( + TrackGroupArray trackGroups, TrackSelectorResult trackSelectorResult) { + return new PlaybackInfo( + timeline, + periodId, + startPositionUs, + contentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } + + /** + * Copies playback info with new loading media period. + * + * @param loadingMediaPeriodId New loading media period id. See {@link #loadingMediaPeriodId}. + * @return Copied playback info with new loading media period. + */ + @CheckResult + public PlaybackInfo copyWithLoadingMediaPeriodId(MediaPeriodId loadingMediaPeriodId) { + return new PlaybackInfo( + timeline, + periodId, + startPositionUs, + contentPositionUs, + playbackState, + playbackError, + isLoading, + trackGroups, + trackSelectorResult, + loadingMediaPeriodId, + bufferedPositionUs, + totalBufferedDurationUs, + positionUs); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackParameters.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackParameters.java new file mode 100644 index 0000000000..fd47117aba --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackParameters.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * The parameters that apply to playback. + */ +public final class PlaybackParameters { + + /** + * The default playback parameters: real-time playback with no pitch modification or silence + * skipping. + */ + public static final PlaybackParameters DEFAULT = new PlaybackParameters(/* speed= */ 1f); + + /** The factor by which playback will be sped up. */ + public final float speed; + + /** The factor by which the audio pitch will be scaled. */ + public final float pitch; + + /** Whether to skip silence in the input. */ + public final boolean skipSilence; + + private final int scaledUsPerMs; + + /** + * Creates new playback parameters that set the playback speed. + * + * @param speed The factor by which playback will be sped up. Must be greater than zero. + */ + public PlaybackParameters(float speed) { + this(speed, /* pitch= */ 1f, /* skipSilence= */ false); + } + + /** + * Creates new playback parameters that set the playback speed and audio pitch scaling factor. + * + * @param speed The factor by which playback will be sped up. Must be greater than zero. + * @param pitch The factor by which the audio pitch will be scaled. Must be greater than zero. + */ + public PlaybackParameters(float speed, float pitch) { + this(speed, pitch, /* skipSilence= */ false); + } + + /** + * Creates new playback parameters that set the playback speed, audio pitch scaling factor and + * whether to skip silence in the audio stream. + * + * @param speed The factor by which playback will be sped up. Must be greater than zero. + * @param pitch The factor by which the audio pitch will be scaled. Must be greater than zero. + * @param skipSilence Whether to skip silences in the audio stream. + */ + public PlaybackParameters(float speed, float pitch, boolean skipSilence) { + Assertions.checkArgument(speed > 0); + Assertions.checkArgument(pitch > 0); + this.speed = speed; + this.pitch = pitch; + this.skipSilence = skipSilence; + scaledUsPerMs = Math.round(speed * 1000f); + } + + /** + * Returns the media time in microseconds that will elapse in {@code timeMs} milliseconds of + * wallclock time. + * + * @param timeMs The time to scale, in milliseconds. + * @return The scaled time, in microseconds. + */ + public long getMediaTimeUsForPlayoutTimeMs(long timeMs) { + return timeMs * scaledUsPerMs; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + PlaybackParameters other = (PlaybackParameters) obj; + return this.speed == other.speed + && this.pitch == other.pitch + && this.skipSilence == other.skipSilence; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + Float.floatToRawIntBits(speed); + result = 31 * result + Float.floatToRawIntBits(pitch); + result = 31 * result + (skipSilence ? 1 : 0); + return result; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackPreparer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackPreparer.java new file mode 100644 index 0000000000..831a28aa47 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlaybackPreparer.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +/** Called to prepare a playback. */ +public interface PlaybackPreparer { + + /** Called to prepare a playback. */ + void preparePlayback(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Player.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Player.java new file mode 100644 index 0000000000..89059dc2ea --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Player.java @@ -0,0 +1,1040 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.Looper; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C.VideoScalingMode; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AuxEffectInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.TextOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoFrameMetadataListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical.CameraMotionListener; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A media player interface defining traditional high-level functionality, such as the ability to + * play, pause, seek and query properties of the currently playing media. + * <p> + * Some important properties of media players that implement this interface are: + * <ul> + * <li>They can provide a {@link Timeline} representing the structure of the media being played, + * which can be obtained by calling {@link #getCurrentTimeline()}.</li> + * <li>They can provide a {@link TrackGroupArray} defining the currently available tracks, + * which can be obtained by calling {@link #getCurrentTrackGroups()}.</li> + * <li>They contain a number of renderers, each of which is able to render tracks of a single + * type (e.g. audio, video or text). The number of renderers and their respective track types + * can be obtained by calling {@link #getRendererCount()} and {@link #getRendererType(int)}. + * </li> + * <li>They can provide a {@link TrackSelectionArray} defining which of the currently available + * tracks are selected to be rendered by each renderer. This can be obtained by calling + * {@link #getCurrentTrackSelections()}}.</li> + * </ul> + */ +public interface Player { + + /** The audio component of a {@link Player}. */ + interface AudioComponent { + + /** + * Adds a listener to receive audio events. + * + * @param listener The listener to register. + */ + void addAudioListener(AudioListener listener); + + /** + * Removes a listener of audio events. + * + * @param listener The listener to unregister. + */ + void removeAudioListener(AudioListener listener); + + /** + * Sets the attributes for audio playback, used by the underlying audio track. If not set, the + * default audio attributes will be used. They are suitable for general media playback. + * + * <p>Setting the audio attributes during playback may introduce a short gap in audio output as + * the audio track is recreated. A new audio session id will also be generated. + * + * <p>If tunneling is enabled by the track selector, the specified audio attributes will be + * ignored, but they will take effect if audio is later played without tunneling. + * + * <p>If the device is running a build before platform API version 21, audio attributes cannot + * be set directly on the underlying audio track. In this case, the usage will be mapped onto an + * equivalent stream type using {@link Util#getStreamTypeForAudioUsage(int)}. + * + * @param audioAttributes The attributes to use for audio playback. + * @deprecated Use {@link AudioComponent#setAudioAttributes(AudioAttributes, boolean)}. + */ + @Deprecated + void setAudioAttributes(AudioAttributes audioAttributes); + + /** + * Sets the attributes for audio playback, used by the underlying audio track. If not set, the + * default audio attributes will be used. They are suitable for general media playback. + * + * <p>Setting the audio attributes during playback may introduce a short gap in audio output as + * the audio track is recreated. A new audio session id will also be generated. + * + * <p>If tunneling is enabled by the track selector, the specified audio attributes will be + * ignored, but they will take effect if audio is later played without tunneling. + * + * <p>If the device is running a build before platform API version 21, audio attributes cannot + * be set directly on the underlying audio track. In this case, the usage will be mapped onto an + * equivalent stream type using {@link Util#getStreamTypeForAudioUsage(int)}. + * + * <p>If audio focus should be handled, the {@link AudioAttributes#usage} must be {@link + * C#USAGE_MEDIA} or {@link C#USAGE_GAME}. Other usages will throw an {@link + * IllegalArgumentException}. + * + * @param audioAttributes The attributes to use for audio playback. + * @param handleAudioFocus True if the player should handle audio focus, false otherwise. + */ + void setAudioAttributes(AudioAttributes audioAttributes, boolean handleAudioFocus); + + /** Returns the attributes for audio playback. */ + AudioAttributes getAudioAttributes(); + + /** Returns the audio session identifier, or {@link C#AUDIO_SESSION_ID_UNSET} if not set. */ + int getAudioSessionId(); + + /** Sets information on an auxiliary audio effect to attach to the underlying audio track. */ + void setAuxEffectInfo(AuxEffectInfo auxEffectInfo); + + /** Detaches any previously attached auxiliary audio effect from the underlying audio track. */ + void clearAuxEffectInfo(); + + /** + * Sets the audio volume, with 0 being silence and 1 being unity gain. + * + * @param audioVolume The audio volume. + */ + void setVolume(float audioVolume); + + /** Returns the audio volume, with 0 being silence and 1 being unity gain. */ + float getVolume(); + } + + /** The video component of a {@link Player}. */ + interface VideoComponent { + + /** + * Sets the {@link VideoScalingMode}. + * + * @param videoScalingMode The {@link VideoScalingMode}. + */ + void setVideoScalingMode(@VideoScalingMode int videoScalingMode); + + /** Returns the {@link VideoScalingMode}. */ + @VideoScalingMode + int getVideoScalingMode(); + + /** + * Adds a listener to receive video events. + * + * @param listener The listener to register. + */ + void addVideoListener(VideoListener listener); + + /** + * Removes a listener of video events. + * + * @param listener The listener to unregister. + */ + void removeVideoListener(VideoListener listener); + + /** + * Sets a listener to receive video frame metadata events. + * + * <p>This method is intended to be called by the same component that sets the {@link Surface} + * onto which video will be rendered. If using ExoPlayer's standard UI components, this method + * should not be called directly from application code. + * + * @param listener The listener. + */ + void setVideoFrameMetadataListener(VideoFrameMetadataListener listener); + + /** + * Clears the listener which receives video frame metadata events if it matches the one passed. + * Else does nothing. + * + * @param listener The listener to clear. + */ + void clearVideoFrameMetadataListener(VideoFrameMetadataListener listener); + + /** + * Sets a listener of camera motion events. + * + * @param listener The listener. + */ + void setCameraMotionListener(CameraMotionListener listener); + + /** + * Clears the listener which receives camera motion events if it matches the one passed. Else + * does nothing. + * + * @param listener The listener to clear. + */ + void clearCameraMotionListener(CameraMotionListener listener); + + /** + * Clears any {@link Surface}, {@link SurfaceHolder}, {@link SurfaceView} or {@link TextureView} + * currently set on the player. + */ + void clearVideoSurface(); + + /** + * Clears the {@link Surface} onto which video is being rendered if it matches the one passed. + * Else does nothing. + * + * @param surface The surface to clear. + */ + void clearVideoSurface(@Nullable Surface surface); + + /** + * Sets the {@link Surface} onto which video will be rendered. The caller is responsible for + * tracking the lifecycle of the surface, and must clear the surface by calling {@code + * setVideoSurface(null)} if the surface is destroyed. + * + * <p>If the surface is held by a {@link SurfaceView}, {@link TextureView} or {@link + * SurfaceHolder} then it's recommended to use {@link #setVideoSurfaceView(SurfaceView)}, {@link + * #setVideoTextureView(TextureView)} or {@link #setVideoSurfaceHolder(SurfaceHolder)} rather + * than this method, since passing the holder allows the player to track the lifecycle of the + * surface automatically. + * + * @param surface The {@link Surface}. + */ + void setVideoSurface(@Nullable Surface surface); + + /** + * Sets the {@link SurfaceHolder} that holds the {@link Surface} onto which video will be + * rendered. The player will track the lifecycle of the surface automatically. + * + * @param surfaceHolder The surface holder. + */ + void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder); + + /** + * Clears the {@link SurfaceHolder} that holds the {@link Surface} onto which video is being + * rendered if it matches the one passed. Else does nothing. + * + * @param surfaceHolder The surface holder to clear. + */ + void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder); + + /** + * Sets the {@link SurfaceView} onto which video will be rendered. The player will track the + * lifecycle of the surface automatically. + * + * @param surfaceView The surface view. + */ + void setVideoSurfaceView(@Nullable SurfaceView surfaceView); + + /** + * Clears the {@link SurfaceView} onto which video is being rendered if it matches the one + * passed. Else does nothing. + * + * @param surfaceView The texture view to clear. + */ + void clearVideoSurfaceView(@Nullable SurfaceView surfaceView); + + /** + * Sets the {@link TextureView} onto which video will be rendered. The player will track the + * lifecycle of the surface automatically. + * + * @param textureView The texture view. + */ + void setVideoTextureView(@Nullable TextureView textureView); + + /** + * Clears the {@link TextureView} onto which video is being rendered if it matches the one + * passed. Else does nothing. + * + * @param textureView The texture view to clear. + */ + void clearVideoTextureView(@Nullable TextureView textureView); + + /** + * Sets the video decoder output buffer renderer. This is intended for use only with extension + * renderers that accept {@link C#MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER}. For most use + * cases, an output surface or view should be passed via {@link #setVideoSurface(Surface)} or + * {@link #setVideoSurfaceView(SurfaceView)} instead. + * + * @param videoDecoderOutputBufferRenderer The video decoder output buffer renderer, or {@code + * null} to clear the output buffer renderer. + */ + void setVideoDecoderOutputBufferRenderer( + @Nullable VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer); + + /** Clears the video decoder output buffer renderer. */ + void clearVideoDecoderOutputBufferRenderer(); + + /** + * Clears the video decoder output buffer renderer if it matches the one passed. Else does + * nothing. + * + * @param videoDecoderOutputBufferRenderer The video decoder output buffer renderer to clear. + */ + void clearVideoDecoderOutputBufferRenderer( + @Nullable VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer); + } + + /** The text component of a {@link Player}. */ + interface TextComponent { + + /** + * Registers an output to receive text events. + * + * @param listener The output to register. + */ + void addTextOutput(TextOutput listener); + + /** + * Removes a text output. + * + * @param listener The output to remove. + */ + void removeTextOutput(TextOutput listener); + } + + /** The metadata component of a {@link Player}. */ + interface MetadataComponent { + + /** + * Adds a {@link MetadataOutput} to receive metadata. + * + * @param output The output to register. + */ + void addMetadataOutput(MetadataOutput output); + + /** + * Removes a {@link MetadataOutput}. + * + * @param output The output to remove. + */ + void removeMetadataOutput(MetadataOutput output); + } + + /** + * Listener of changes in player state. All methods have no-op default implementations to allow + * selective overrides. + */ + interface EventListener { + + /** + * Called when the timeline has been refreshed. + * + * <p>Note that if the timeline has changed then a position discontinuity may also have + * occurred. For example, the current period index may have changed as a result of periods being + * added or removed from the timeline. This will <em>not</em> be reported via a separate call to + * {@link #onPositionDiscontinuity(int)}. + * + * @param timeline The latest timeline. Never null, but may be empty. + * @param reason The {@link TimelineChangeReason} responsible for this timeline change. + */ + @SuppressWarnings("deprecation") + default void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) { + Object manifest = null; + if (timeline.getWindowCount() == 1) { + // Legacy behavior was to report the manifest for single window timelines only. + Timeline.Window window = new Timeline.Window(); + manifest = timeline.getWindow(0, window).manifest; + } + // Call deprecated version. + onTimelineChanged(timeline, manifest, reason); + } + + /** + * Called when the timeline and/or manifest has been refreshed. + * + * <p>Note that if the timeline has changed then a position discontinuity may also have + * occurred. For example, the current period index may have changed as a result of periods being + * added or removed from the timeline. This will <em>not</em> be reported via a separate call to + * {@link #onPositionDiscontinuity(int)}. + * + * @param timeline The latest timeline. Never null, but may be empty. + * @param manifest The latest manifest. May be null. + * @param reason The {@link TimelineChangeReason} responsible for this timeline change. + * @deprecated Use {@link #onTimelineChanged(Timeline, int)} instead. The manifest can be + * accessed by using {@link #getCurrentManifest()} or {@code timeline.getWindow(windowIndex, + * window).manifest} for a given window index. + */ + @Deprecated + default void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) {} + + /** + * Called when the available or selected tracks change. + * + * @param trackGroups The available tracks. Never null, but may be of length zero. + * @param trackSelections The track selections for each renderer. Never null and always of + * length {@link #getRendererCount()}, but may contain null elements. + */ + default void onTracksChanged( + TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {} + + /** + * Called when the player starts or stops loading the source. + * + * @param isLoading Whether the source is currently being loaded. + */ + default void onLoadingChanged(boolean isLoading) {} + + /** + * Called when the value returned from either {@link #getPlayWhenReady()} or {@link + * #getPlaybackState()} changes. + * + * @param playWhenReady Whether playback will proceed when ready. + * @param playbackState The new {@link State playback state}. + */ + default void onPlayerStateChanged(boolean playWhenReady, @State int playbackState) {} + + /** + * Called when the value returned from {@link #getPlaybackSuppressionReason()} changes. + * + * @param playbackSuppressionReason The current {@link PlaybackSuppressionReason}. + */ + default void onPlaybackSuppressionReasonChanged( + @PlaybackSuppressionReason int playbackSuppressionReason) {} + + /** + * Called when the value of {@link #isPlaying()} changes. + * + * @param isPlaying Whether the player is playing. + */ + default void onIsPlayingChanged(boolean isPlaying) {} + + /** + * Called when the value of {@link #getRepeatMode()} changes. + * + * @param repeatMode The {@link RepeatMode} used for playback. + */ + default void onRepeatModeChanged(@RepeatMode int repeatMode) {} + + /** + * Called when the value of {@link #getShuffleModeEnabled()} changes. + * + * @param shuffleModeEnabled Whether shuffling of windows is enabled. + */ + default void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) {} + + /** + * Called when an error occurs. The playback state will transition to {@link #STATE_IDLE} + * immediately after this method is called. The player instance can still be used, and {@link + * #release()} must still be called on the player should it no longer be required. + * + * @param error The error. + */ + default void onPlayerError(ExoPlaybackException error) {} + + /** + * Called when a position discontinuity occurs without a change to the timeline. A position + * discontinuity occurs when the current window or period index changes (as a result of playback + * transitioning from one period in the timeline to the next), or when the playback position + * jumps within the period currently being played (as a result of a seek being performed, or + * when the source introduces a discontinuity internally). + * + * <p>When a position discontinuity occurs as a result of a change to the timeline this method + * is <em>not</em> called. {@link #onTimelineChanged(Timeline, int)} is called in this case. + * + * @param reason The {@link DiscontinuityReason} responsible for the discontinuity. + */ + default void onPositionDiscontinuity(@DiscontinuityReason int reason) {} + + /** + * Called when the current playback parameters change. The playback parameters may change due to + * a call to {@link #setPlaybackParameters(PlaybackParameters)}, or the player itself may change + * them (for example, if audio playback switches to passthrough mode, where speed adjustment is + * no longer possible). + * + * @param playbackParameters The playback parameters. + */ + default void onPlaybackParametersChanged(PlaybackParameters playbackParameters) {} + + /** + * Called when all pending seek requests have been processed by the player. This is guaranteed + * to happen after any necessary changes to the player state were reported to {@link + * #onPlayerStateChanged(boolean, int)}. + */ + default void onSeekProcessed() {} + } + + /** + * @deprecated Use {@link EventListener} interface directly for selective overrides as all methods + * are implemented as no-op default methods. + */ + @Deprecated + abstract class DefaultEventListener implements EventListener { + + @Override + public void onTimelineChanged(Timeline timeline, @TimelineChangeReason int reason) { + Object manifest = null; + if (timeline.getWindowCount() == 1) { + // Legacy behavior was to report the manifest for single window timelines only. + Timeline.Window window = new Timeline.Window(); + manifest = timeline.getWindow(0, window).manifest; + } + // Call deprecated version. + onTimelineChanged(timeline, manifest, reason); + } + + @Override + @SuppressWarnings("deprecation") + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason) { + // Call deprecated version. Otherwise, do nothing. + onTimelineChanged(timeline, manifest); + } + + /** @deprecated Use {@link EventListener#onTimelineChanged(Timeline, int)} instead. */ + @Deprecated + public void onTimelineChanged(Timeline timeline, @Nullable Object manifest) { + // Do nothing. + } + } + + /** + * Playback state. One of {@link #STATE_IDLE}, {@link #STATE_BUFFERING}, {@link #STATE_READY} or + * {@link #STATE_ENDED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_IDLE, STATE_BUFFERING, STATE_READY, STATE_ENDED}) + @interface State {} + /** + * The player does not have any media to play. + */ + int STATE_IDLE = 1; + /** + * The player is not able to immediately play from its current position. This state typically + * occurs when more data needs to be loaded. + */ + int STATE_BUFFERING = 2; + /** + * The player is able to immediately play from its current position. The player will be playing if + * {@link #getPlayWhenReady()} is true, and paused otherwise. + */ + int STATE_READY = 3; + /** + * The player has finished playing the media. + */ + int STATE_ENDED = 4; + + /** + * Reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code true}. One + * of {@link #PLAYBACK_SUPPRESSION_REASON_NONE} or {@link + * #PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + PLAYBACK_SUPPRESSION_REASON_NONE, + PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS + }) + @interface PlaybackSuppressionReason {} + /** Playback is not suppressed. */ + int PLAYBACK_SUPPRESSION_REASON_NONE = 0; + /** Playback is suppressed due to transient audio focus loss. */ + int PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS = 1; + + /** + * Repeat modes for playback. One of {@link #REPEAT_MODE_OFF}, {@link #REPEAT_MODE_ONE} or {@link + * #REPEAT_MODE_ALL}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({REPEAT_MODE_OFF, REPEAT_MODE_ONE, REPEAT_MODE_ALL}) + @interface RepeatMode {} + /** + * Normal playback without repetition. + */ + int REPEAT_MODE_OFF = 0; + /** + * "Repeat One" mode to repeat the currently playing window infinitely. + */ + int REPEAT_MODE_ONE = 1; + /** + * "Repeat All" mode to repeat the entire timeline infinitely. + */ + int REPEAT_MODE_ALL = 2; + + /** + * Reasons for position discontinuities. One of {@link #DISCONTINUITY_REASON_PERIOD_TRANSITION}, + * {@link #DISCONTINUITY_REASON_SEEK}, {@link #DISCONTINUITY_REASON_SEEK_ADJUSTMENT}, {@link + * #DISCONTINUITY_REASON_AD_INSERTION} or {@link #DISCONTINUITY_REASON_INTERNAL}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + DISCONTINUITY_REASON_PERIOD_TRANSITION, + DISCONTINUITY_REASON_SEEK, + DISCONTINUITY_REASON_SEEK_ADJUSTMENT, + DISCONTINUITY_REASON_AD_INSERTION, + DISCONTINUITY_REASON_INTERNAL + }) + @interface DiscontinuityReason {} + /** + * Automatic playback transition from one period in the timeline to the next. The period index may + * be the same as it was before the discontinuity in case the current period is repeated. + */ + int DISCONTINUITY_REASON_PERIOD_TRANSITION = 0; + /** Seek within the current period or to another period. */ + int DISCONTINUITY_REASON_SEEK = 1; + /** + * Seek adjustment due to being unable to seek to the requested position or because the seek was + * permitted to be inexact. + */ + int DISCONTINUITY_REASON_SEEK_ADJUSTMENT = 2; + /** Discontinuity to or from an ad within one period in the timeline. */ + int DISCONTINUITY_REASON_AD_INSERTION = 3; + /** Discontinuity introduced internally by the source. */ + int DISCONTINUITY_REASON_INTERNAL = 4; + + /** + * Reasons for timeline changes. One of {@link #TIMELINE_CHANGE_REASON_PREPARED}, {@link + * #TIMELINE_CHANGE_REASON_RESET} or {@link #TIMELINE_CHANGE_REASON_DYNAMIC}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TIMELINE_CHANGE_REASON_PREPARED, + TIMELINE_CHANGE_REASON_RESET, + TIMELINE_CHANGE_REASON_DYNAMIC + }) + @interface TimelineChangeReason {} + /** Timeline and manifest changed as a result of a player initialization with new media. */ + int TIMELINE_CHANGE_REASON_PREPARED = 0; + /** Timeline and manifest changed as a result of a player reset. */ + int TIMELINE_CHANGE_REASON_RESET = 1; + /** + * Timeline or manifest changed as a result of an dynamic update introduced by the played media. + */ + int TIMELINE_CHANGE_REASON_DYNAMIC = 2; + + /** Returns the component of this player for audio output, or null if audio is not supported. */ + @Nullable + AudioComponent getAudioComponent(); + + /** Returns the component of this player for video output, or null if video is not supported. */ + @Nullable + VideoComponent getVideoComponent(); + + /** Returns the component of this player for text output, or null if text is not supported. */ + @Nullable + TextComponent getTextComponent(); + + /** + * Returns the component of this player for metadata output, or null if metadata is not supported. + */ + @Nullable + MetadataComponent getMetadataComponent(); + + /** + * Returns the {@link Looper} associated with the application thread that's used to access the + * player and on which player events are received. + */ + Looper getApplicationLooper(); + + /** + * Register a listener to receive events from the player. The listener's methods will be called on + * the thread that was used to construct the player. However, if the thread used to construct the + * player does not have a {@link Looper}, then the listener will be called on the main thread. + * + * @param listener The listener to register. + */ + void addListener(EventListener listener); + + /** + * Unregister a listener. The listener will no longer receive events from the player. + * + * @param listener The listener to unregister. + */ + void removeListener(EventListener listener); + + /** + * Returns the current {@link State playback state} of the player. + * + * @return The current {@link State playback state}. + */ + @State + int getPlaybackState(); + + /** + * Returns the reason why playback is suppressed even though {@link #getPlayWhenReady()} is {@code + * true}, or {@link #PLAYBACK_SUPPRESSION_REASON_NONE} if playback is not suppressed. + * + * @return The current {@link PlaybackSuppressionReason playback suppression reason}. + */ + @PlaybackSuppressionReason + int getPlaybackSuppressionReason(); + + /** + * Returns whether the player is playing, i.e. {@link #getContentPosition()} is advancing. + * + * <p>If {@code false}, then at least one of the following is true: + * + * <ul> + * <li>The {@link #getPlaybackState() playback state} is not {@link #STATE_READY ready}. + * <li>There is no {@link #getPlayWhenReady() intention to play}. + * <li>Playback is {@link #getPlaybackSuppressionReason() suppressed for other reasons}. + * </ul> + * + * @return Whether the player is playing. + */ + boolean isPlaying(); + + /** + * Returns the error that caused playback to fail. This is the same error that will have been + * reported via {@link Player.EventListener#onPlayerError(ExoPlaybackException)} at the time of + * failure. It can be queried using this method until {@code stop(true)} is called or the player + * is re-prepared. + * + * <p>Note that this method will always return {@code null} if {@link #getPlaybackState()} is not + * {@link #STATE_IDLE}. + * + * @return The error, or {@code null}. + */ + @Nullable + ExoPlaybackException getPlaybackError(); + + /** + * Sets whether playback should proceed when {@link #getPlaybackState()} == {@link #STATE_READY}. + * <p> + * If the player is already in the ready state then this method can be used to pause and resume + * playback. + * + * @param playWhenReady Whether playback should proceed when ready. + */ + void setPlayWhenReady(boolean playWhenReady); + + /** + * Whether playback will proceed when {@link #getPlaybackState()} == {@link #STATE_READY}. + * + * @return Whether playback will proceed when ready. + */ + boolean getPlayWhenReady(); + + /** + * Sets the {@link RepeatMode} to be used for playback. + * + * @param repeatMode The repeat mode. + */ + void setRepeatMode(@RepeatMode int repeatMode); + + /** + * Returns the current {@link RepeatMode} used for playback. + * + * @return The current repeat mode. + */ + @RepeatMode int getRepeatMode(); + + /** + * Sets whether shuffling of windows is enabled. + * + * @param shuffleModeEnabled Whether shuffling is enabled. + */ + void setShuffleModeEnabled(boolean shuffleModeEnabled); + + /** + * Returns whether shuffling of windows is enabled. + */ + boolean getShuffleModeEnabled(); + + /** + * Whether the player is currently loading the source. + * + * @return Whether the player is currently loading the source. + */ + boolean isLoading(); + + /** + * Seeks to the default position associated with the current window. The position can depend on + * the type of media being played. For live streams it will typically be the live edge of the + * window. For other streams it will typically be the start of the window. + */ + void seekToDefaultPosition(); + + /** + * Seeks to the default position associated with the specified window. The position can depend on + * the type of media being played. For live streams it will typically be the live edge of the + * window. For other streams it will typically be the start of the window. + * + * @param windowIndex The index of the window whose associated default position should be seeked + * to. + */ + void seekToDefaultPosition(int windowIndex); + + /** + * Seeks to a position specified in milliseconds in the current window. + * + * @param positionMs The seek position in the current window, or {@link C#TIME_UNSET} to seek to + * the window's default position. + */ + void seekTo(long positionMs); + + /** + * Seeks to a position specified in milliseconds in the specified window. + * + * @param windowIndex The index of the window. + * @param positionMs The seek position in the specified window, or {@link C#TIME_UNSET} to seek to + * the window's default position. + * @throws IllegalSeekPositionException If the player has a non-empty timeline and the provided + * {@code windowIndex} is not within the bounds of the current timeline. + */ + void seekTo(int windowIndex, long positionMs); + + /** + * Returns whether a previous window exists, which may depend on the current repeat mode and + * whether shuffle mode is enabled. + */ + boolean hasPrevious(); + + /** + * Seeks to the default position of the previous window in the timeline, which may depend on the + * current repeat mode and whether shuffle mode is enabled. Does nothing if {@link #hasPrevious()} + * is {@code false}. + */ + void previous(); + + /** + * Returns whether a next window exists, which may depend on the current repeat mode and whether + * shuffle mode is enabled. + */ + boolean hasNext(); + + /** + * Seeks to the default position of the next window in the timeline, which may depend on the + * current repeat mode and whether shuffle mode is enabled. Does nothing if {@link #hasNext()} is + * {@code false}. + */ + void next(); + + /** + * Attempts to set the playback parameters. Passing {@code null} sets the parameters to the + * default, {@link PlaybackParameters#DEFAULT}, which means there is no speed or pitch adjustment. + * + * <p>Playback parameters changes may cause the player to buffer. {@link + * EventListener#onPlaybackParametersChanged(PlaybackParameters)} will be called whenever the + * currently active playback parameters change. + * + * @param playbackParameters The playback parameters, or {@code null} to use the defaults. + */ + void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters); + + /** + * Returns the currently active playback parameters. + * + * @see EventListener#onPlaybackParametersChanged(PlaybackParameters) + */ + PlaybackParameters getPlaybackParameters(); + + /** + * Stops playback without resetting the player. Use {@code setPlayWhenReady(false)} rather than + * this method if the intention is to pause playback. + * + * <p>Calling this method will cause the playback state to transition to {@link #STATE_IDLE}. The + * player instance can still be used, and {@link #release()} must still be called on the player if + * it's no longer required. + * + * <p>Calling this method does not reset the playback position. + */ + void stop(); + + /** + * Stops playback and optionally resets the player. Use {@code setPlayWhenReady(false)} rather + * than this method if the intention is to pause playback. + * + * <p>Calling this method will cause the playback state to transition to {@link #STATE_IDLE}. The + * player instance can still be used, and {@link #release()} must still be called on the player if + * it's no longer required. + * + * @param reset Whether the player should be reset. + */ + void stop(boolean reset); + + /** + * Releases the player. This method must be called when the player is no longer required. The + * player must not be used after calling this method. + */ + void release(); + + /** + * Returns the number of renderers. + */ + int getRendererCount(); + + /** + * Returns the track type that the renderer at a given index handles. + * + * @see Renderer#getTrackType() + * @param index The index of the renderer. + * @return One of the {@code TRACK_TYPE_*} constants defined in {@link C}. + */ + int getRendererType(int index); + + /** + * Returns the available track groups. + */ + TrackGroupArray getCurrentTrackGroups(); + + /** + * Returns the current track selections for each renderer. + */ + TrackSelectionArray getCurrentTrackSelections(); + + /** + * Returns the current manifest. The type depends on the type of media being played. May be null. + */ + @Nullable Object getCurrentManifest(); + + /** + * Returns the current {@link Timeline}. Never null, but may be empty. + */ + Timeline getCurrentTimeline(); + + /** + * Returns the index of the period currently being played. + */ + int getCurrentPeriodIndex(); + + /** + * Returns the index of the window currently being played. + */ + int getCurrentWindowIndex(); + + /** + * Returns the index of the next timeline window to be played, which may depend on the current + * repeat mode and whether shuffle mode is enabled. Returns {@link C#INDEX_UNSET} if the window + * currently being played is the last window. + */ + int getNextWindowIndex(); + + /** + * Returns the index of the previous timeline window to be played, which may depend on the current + * repeat mode and whether shuffle mode is enabled. Returns {@link C#INDEX_UNSET} if the window + * currently being played is the first window. + */ + int getPreviousWindowIndex(); + + /** + * Returns the tag of the currently playing window in the timeline. May be null if no tag is set + * or the timeline is not yet available. + */ + @Nullable Object getCurrentTag(); + + /** + * Returns the duration of the current content window or ad in milliseconds, or {@link + * C#TIME_UNSET} if the duration is not known. + */ + long getDuration(); + + /** Returns the playback position in the current content window or ad, in milliseconds. */ + long getCurrentPosition(); + + /** + * Returns an estimate of the position in the current content window or ad up to which data is + * buffered, in milliseconds. + */ + long getBufferedPosition(); + + /** + * Returns an estimate of the percentage in the current content window or ad up to which data is + * buffered, or 0 if no estimate is available. + */ + int getBufferedPercentage(); + + /** + * Returns an estimate of the total buffered duration from the current position, in milliseconds. + * This includes pre-buffered data for subsequent ads and windows. + */ + long getTotalBufferedDuration(); + + /** + * Returns whether the current window is dynamic, or {@code false} if the {@link Timeline} is + * empty. + * + * @see Timeline.Window#isDynamic + */ + boolean isCurrentWindowDynamic(); + + /** + * Returns whether the current window is live, or {@code false} if the {@link Timeline} is empty. + * + * @see Timeline.Window#isLive + */ + boolean isCurrentWindowLive(); + + /** + * Returns whether the current window is seekable, or {@code false} if the {@link Timeline} is + * empty. + * + * @see Timeline.Window#isSeekable + */ + boolean isCurrentWindowSeekable(); + + /** + * Returns whether the player is currently playing an ad. + */ + boolean isPlayingAd(); + + /** + * If {@link #isPlayingAd()} returns true, returns the index of the ad group in the period + * currently being played. Returns {@link C#INDEX_UNSET} otherwise. + */ + int getCurrentAdGroupIndex(); + + /** + * If {@link #isPlayingAd()} returns true, returns the index of the ad in its ad group. Returns + * {@link C#INDEX_UNSET} otherwise. + */ + int getCurrentAdIndexInAdGroup(); + + /** + * If {@link #isPlayingAd()} returns {@code true}, returns the duration of the current content + * window in milliseconds, or {@link C#TIME_UNSET} if the duration is not known. If there is no ad + * playing, the returned duration is the same as that returned by {@link #getDuration()}. + */ + long getContentDuration(); + + /** + * If {@link #isPlayingAd()} returns {@code true}, returns the content position that will be + * played once all ads in the ad group have finished playing, in milliseconds. If there is no ad + * playing, the returned position is the same as that returned by {@link #getCurrentPosition()}. + */ + long getContentPosition(); + + /** + * If {@link #isPlayingAd()} returns {@code true}, returns an estimate of the content position in + * the current content window up to which data is buffered, in milliseconds. If there is no ad + * playing, the returned position is the same as that returned by {@link #getBufferedPosition()}. + */ + long getContentBufferedPosition(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlayerMessage.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlayerMessage.java new file mode 100644 index 0000000000..69740220e5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/PlayerMessage.java @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Defines a player message which can be sent with a {@link Sender} and received by a {@link + * Target}. + */ +public final class PlayerMessage { + + /** A target for messages. */ + public interface Target { + + /** + * Handles a message delivered to the target. + * + * @param messageType The message type. + * @param payload The message payload. + * @throws ExoPlaybackException If an error occurred whilst handling the message. Should only be + * thrown by targets that handle messages on the playback thread. + */ + void handleMessage(int messageType, @Nullable Object payload) throws ExoPlaybackException; + } + + /** A sender for messages. */ + public interface Sender { + + /** + * Sends a message. + * + * @param message The message to be sent. + */ + void sendMessage(PlayerMessage message); + } + + private final Target target; + private final Sender sender; + private final Timeline timeline; + + private int type; + @Nullable private Object payload; + private Handler handler; + private int windowIndex; + private long positionMs; + private boolean deleteAfterDelivery; + private boolean isSent; + private boolean isDelivered; + private boolean isProcessed; + private boolean isCanceled; + + /** + * Creates a new message. + * + * @param sender The {@link Sender} used to send the message. + * @param target The {@link Target} the message is sent to. + * @param timeline The timeline used when setting the position with {@link #setPosition(long)}. If + * set to {@link Timeline#EMPTY}, any position can be specified. + * @param defaultWindowIndex The default window index in the {@code timeline} when no other window + * index is specified. + * @param defaultHandler The default handler to send the message on when no other handler is + * specified. + */ + public PlayerMessage( + Sender sender, + Target target, + Timeline timeline, + int defaultWindowIndex, + Handler defaultHandler) { + this.sender = sender; + this.target = target; + this.timeline = timeline; + this.handler = defaultHandler; + this.windowIndex = defaultWindowIndex; + this.positionMs = C.TIME_UNSET; + this.deleteAfterDelivery = true; + } + + /** Returns the timeline used for setting the position with {@link #setPosition(long)}. */ + public Timeline getTimeline() { + return timeline; + } + + /** Returns the target the message is sent to. */ + public Target getTarget() { + return target; + } + + /** + * Sets the message type forwarded to {@link Target#handleMessage(int, Object)}. + * + * @param messageType The message type. + * @return This message. + * @throws IllegalStateException If {@link #send()} has already been called. + */ + public PlayerMessage setType(int messageType) { + Assertions.checkState(!isSent); + this.type = messageType; + return this; + } + + /** Returns the message type forwarded to {@link Target#handleMessage(int, Object)}. */ + public int getType() { + return type; + } + + /** + * Sets the message payload forwarded to {@link Target#handleMessage(int, Object)}. + * + * @param payload The message payload. + * @return This message. + * @throws IllegalStateException If {@link #send()} has already been called. + */ + public PlayerMessage setPayload(@Nullable Object payload) { + Assertions.checkState(!isSent); + this.payload = payload; + return this; + } + + /** Returns the message payload forwarded to {@link Target#handleMessage(int, Object)}. */ + @Nullable + public Object getPayload() { + return payload; + } + + /** + * Sets the handler the message is delivered on. + * + * @param handler A {@link Handler}. + * @return This message. + * @throws IllegalStateException If {@link #send()} has already been called. + */ + public PlayerMessage setHandler(Handler handler) { + Assertions.checkState(!isSent); + this.handler = handler; + return this; + } + + /** Returns the handler the message is delivered on. */ + public Handler getHandler() { + return handler; + } + + /** + * Returns position in window at {@link #getWindowIndex()} at which the message will be delivered, + * in milliseconds. If {@link C#TIME_UNSET}, the message will be delivered immediately. + */ + public long getPositionMs() { + return positionMs; + } + + /** + * Sets a position in the current window at which the message will be delivered. + * + * @param positionMs The position in the current window at which the message will be sent, in + * milliseconds. + * @return This message. + * @throws IllegalStateException If {@link #send()} has already been called. + */ + public PlayerMessage setPosition(long positionMs) { + Assertions.checkState(!isSent); + this.positionMs = positionMs; + return this; + } + + /** + * Sets a position in a window at which the message will be delivered. + * + * @param windowIndex The index of the window at which the message will be sent. + * @param positionMs The position in the window with index {@code windowIndex} at which the + * message will be sent, in milliseconds. + * @return This message. + * @throws IllegalSeekPositionException If the timeline returned by {@link #getTimeline()} is not + * empty and the provided window index is not within the bounds of the timeline. + * @throws IllegalStateException If {@link #send()} has already been called. + */ + public PlayerMessage setPosition(int windowIndex, long positionMs) { + Assertions.checkState(!isSent); + Assertions.checkArgument(positionMs != C.TIME_UNSET); + if (windowIndex < 0 || (!timeline.isEmpty() && windowIndex >= timeline.getWindowCount())) { + throw new IllegalSeekPositionException(timeline, windowIndex, positionMs); + } + this.windowIndex = windowIndex; + this.positionMs = positionMs; + return this; + } + + /** Returns window index at which the message will be delivered. */ + public int getWindowIndex() { + return windowIndex; + } + + /** + * Sets whether the message will be deleted after delivery. If false, the message will be resent + * if playback reaches the specified position again. Only allowed to be false if a position is set + * with {@link #setPosition(long)}. + * + * @param deleteAfterDelivery Whether the message is deleted after delivery. + * @return This message. + * @throws IllegalStateException If {@link #send()} has already been called. + */ + public PlayerMessage setDeleteAfterDelivery(boolean deleteAfterDelivery) { + Assertions.checkState(!isSent); + this.deleteAfterDelivery = deleteAfterDelivery; + return this; + } + + /** Returns whether the message will be deleted after delivery. */ + public boolean getDeleteAfterDelivery() { + return deleteAfterDelivery; + } + + /** + * Sends the message. If the target throws an {@link ExoPlaybackException} then it is propagated + * out of the player as an error using {@link + * Player.EventListener#onPlayerError(ExoPlaybackException)}. + * + * @return This message. + * @throws IllegalStateException If this message has already been sent. + */ + public PlayerMessage send() { + Assertions.checkState(!isSent); + if (positionMs == C.TIME_UNSET) { + Assertions.checkArgument(deleteAfterDelivery); + } + isSent = true; + sender.sendMessage(this); + return this; + } + + /** + * Cancels the message delivery. + * + * @return This message. + * @throws IllegalStateException If this method is called before {@link #send()}. + */ + public synchronized PlayerMessage cancel() { + Assertions.checkState(isSent); + isCanceled = true; + markAsProcessed(/* isDelivered= */ false); + return this; + } + + /** Returns whether the message delivery has been canceled. */ + public synchronized boolean isCanceled() { + return isCanceled; + } + + /** + * Blocks until after the message has been delivered or the player is no longer able to deliver + * the message. + * + * <p>Note that this method can't be called if the current thread is the same thread used by the + * message handler set with {@link #setHandler(Handler)} as it would cause a deadlock. + * + * @return Whether the message was delivered successfully. + * @throws IllegalStateException If this method is called before {@link #send()}. + * @throws IllegalStateException If this method is called on the same thread used by the message + * handler set with {@link #setHandler(Handler)}. + * @throws InterruptedException If the current thread is interrupted while waiting for the message + * to be delivered. + */ + public synchronized boolean blockUntilDelivered() throws InterruptedException { + Assertions.checkState(isSent); + Assertions.checkState(handler.getLooper().getThread() != Thread.currentThread()); + while (!isProcessed) { + wait(); + } + return isDelivered; + } + + /** + * Marks the message as processed. Should only be called by a {@link Sender} and may be called + * multiple times. + * + * @param isDelivered Whether the message has been delivered to its target. The message is + * considered as being delivered when this method has been called with {@code isDelivered} set + * to true at least once. + */ + public synchronized void markAsProcessed(boolean isDelivered) { + this.isDelivered |= isDelivered; + isProcessed = true; + notifyAll(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Renderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Renderer.java new file mode 100644 index 0000000000..d06afb5d3c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Renderer.java @@ -0,0 +1,306 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MediaClock; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Renders media read from a {@link SampleStream}. + * + * <p>Internally, a renderer's lifecycle is managed by the owning {@link ExoPlayer}. The renderer is + * transitioned through various states as the overall playback state and enabled tracks change. The + * valid state transitions are shown below, annotated with the methods that are called during each + * transition. + * + * <p style="align:center"><img src="doc-files/renderer-states.svg" alt="Renderer state + * transitions"> + */ +public interface Renderer extends PlayerMessage.Target { + + /** + * The renderer states. One of {@link #STATE_DISABLED}, {@link #STATE_ENABLED} or {@link + * #STATE_STARTED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_DISABLED, STATE_ENABLED, STATE_STARTED}) + @interface State {} + /** + * The renderer is disabled. A renderer in this state may hold resources that it requires for + * rendering (e.g. media decoders), for use if it's subsequently enabled. {@link #reset()} can be + * called to force the renderer to release these resources. + */ + int STATE_DISABLED = 0; + /** + * The renderer is enabled but not started. A renderer in this state may render media at the + * current position (e.g. an initial video frame), but the position will not advance. A renderer + * in this state will typically hold resources that it requires for rendering (e.g. media + * decoders). + */ + int STATE_ENABLED = 1; + /** + * The renderer is started. Calls to {@link #render(long, long)} will cause media to be rendered. + */ + int STATE_STARTED = 2; + + /** + * Returns the track type that the {@link Renderer} handles. For example, a video renderer will + * return {@link C#TRACK_TYPE_VIDEO}, an audio renderer will return {@link C#TRACK_TYPE_AUDIO}, a + * text renderer will return {@link C#TRACK_TYPE_TEXT}, and so on. + * + * @return One of the {@code TRACK_TYPE_*} constants defined in {@link C}. + */ + int getTrackType(); + + /** + * Returns the capabilities of the renderer. + * + * @return The capabilities of the renderer. + */ + RendererCapabilities getCapabilities(); + + /** + * Sets the index of this renderer within the player. + * + * @param index The renderer index. + */ + void setIndex(int index); + + /** + * If the renderer advances its own playback position then this method returns a corresponding + * {@link MediaClock}. If provided, the player will use the returned {@link MediaClock} as its + * source of time during playback. A player may have at most one renderer that returns a {@link + * MediaClock} from this method. + * + * @return The {@link MediaClock} tracking the playback position of the renderer, or null. + */ + @Nullable + MediaClock getMediaClock(); + + /** + * Returns the current state of the renderer. + * + * @return The current state. One of {@link #STATE_DISABLED}, {@link #STATE_ENABLED} and {@link + * #STATE_STARTED}. + */ + @State + int getState(); + + /** + * Enables the renderer to consume from the specified {@link SampleStream}. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_DISABLED}. + * + * @param configuration The renderer configuration. + * @param formats The enabled formats. + * @param stream The {@link SampleStream} from which the renderer should consume. + * @param positionUs The player's current position. + * @param joining Whether this renderer is being enabled to join an ongoing playback. + * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} + * before they are rendered. + * @throws ExoPlaybackException If an error occurs. + */ + void enable(RendererConfiguration configuration, Format[] formats, SampleStream stream, + long positionUs, boolean joining, long offsetUs) throws ExoPlaybackException; + + /** + * Starts the renderer, meaning that calls to {@link #render(long, long)} will cause media to be + * rendered. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}. + * + * @throws ExoPlaybackException If an error occurs. + */ + void start() throws ExoPlaybackException; + + /** + * Replaces the {@link SampleStream} from which samples will be consumed. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @param formats The enabled formats. + * @param stream The {@link SampleStream} from which the renderer should consume. + * @param offsetUs The offset to be added to timestamps of buffers read from {@code stream} before + * they are rendered. + * @throws ExoPlaybackException If an error occurs. + */ + void replaceStream(Format[] formats, SampleStream stream, long offsetUs) + throws ExoPlaybackException; + + /** Returns the {@link SampleStream} being consumed, or null if the renderer is disabled. */ + @Nullable + SampleStream getStream(); + + /** + * Returns whether the renderer has read the current {@link SampleStream} to the end. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + */ + boolean hasReadStreamToEnd(); + + /** + * Returns the playback position up to which the renderer has read samples from the current {@link + * SampleStream}, in microseconds, or {@link C#TIME_END_OF_SOURCE} if the renderer has read the + * current {@link SampleStream} to the end. + * + * <p>This method may be called when the renderer is in the following states: {@link + * #STATE_ENABLED}, {@link #STATE_STARTED}. + */ + long getReadingPositionUs(); + + /** + * Signals to the renderer that the current {@link SampleStream} will be the final one supplied + * before it is next disabled or reset. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + */ + void setCurrentStreamFinal(); + + /** + * Returns whether the current {@link SampleStream} will be the final one supplied before the + * renderer is next disabled or reset. + */ + boolean isCurrentStreamFinal(); + + /** + * Throws an error that's preventing the renderer from reading from its {@link SampleStream}. Does + * nothing if no such error exists. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @throws IOException An error that's preventing the renderer from making progress or buffering + * more data. + */ + void maybeThrowStreamError() throws IOException; + + /** + * Signals to the renderer that a position discontinuity has occurred. + * <p> + * After a position discontinuity, the renderer's {@link SampleStream} is guaranteed to provide + * samples starting from a key frame. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @param positionUs The new playback position in microseconds. + * @throws ExoPlaybackException If an error occurs handling the reset. + */ + void resetPosition(long positionUs) throws ExoPlaybackException; + + /** + * Sets the operating rate of this renderer, where 1 is the default rate, 2 is twice the default + * rate, 0.5 is half the default rate and so on. The operating rate is a hint to the renderer of + * the speed at which playback will proceed, and may be used for resource planning. + * + * <p>The default implementation is a no-op. + * + * @param operatingRate The operating rate. + * @throws ExoPlaybackException If an error occurs handling the operating rate. + */ + default void setOperatingRate(float operatingRate) throws ExoPlaybackException {} + + /** + * Incrementally renders the {@link SampleStream}. + * <p> + * If the renderer is in the {@link #STATE_ENABLED} state then each call to this method will do + * work toward being ready to render the {@link SampleStream} when the renderer is started. It may + * also render the very start of the media, for example the first frame of a video stream. If the + * renderer is in the {@link #STATE_STARTED} state then calls to this method will render the + * {@link SampleStream} in sync with the specified media positions. + * <p> + * This method should return quickly, and should not block if the renderer is unable to make + * useful progress. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @param positionUs The current media time in microseconds, measured at the start of the + * current iteration of the rendering loop. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + * @throws ExoPlaybackException If an error occurs. + */ + void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException; + + /** + * Whether the renderer is able to immediately render media from the current position. + * <p> + * If the renderer is in the {@link #STATE_STARTED} state then returning true indicates that the + * renderer has everything that it needs to continue playback. Returning false indicates that + * the player should pause until the renderer is ready. + * <p> + * If the renderer is in the {@link #STATE_ENABLED} state then returning true indicates that the + * renderer is ready for playback to be started. Returning false indicates that it is not. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @return Whether the renderer is ready to render media. + */ + boolean isReady(); + + /** + * Whether the renderer is ready for the {@link ExoPlayer} instance to transition to + * {@link Player#STATE_ENDED}. The player will make this transition as soon as {@code true} is + * returned by all of its {@link Renderer}s. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}, {@link #STATE_STARTED}. + * + * @return Whether the renderer is ready for the player to transition to the ended state. + */ + boolean isEnded(); + + /** + * Stops the renderer, transitioning it to the {@link #STATE_ENABLED} state. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_STARTED}. + * + * @throws ExoPlaybackException If an error occurs. + */ + void stop() throws ExoPlaybackException; + + /** + * Disable the renderer, transitioning it to the {@link #STATE_DISABLED} state. + * <p> + * This method may be called when the renderer is in the following states: + * {@link #STATE_ENABLED}. + */ + void disable(); + + /** + * Forces the renderer to give up any resources (e.g. media decoders) that it may be holding. If + * the renderer is not holding any resources, the call is a no-op. + * + * <p>This method may be called when the renderer is in the following states: {@link + * #STATE_DISABLED}. + */ + void reset(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RendererCapabilities.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RendererCapabilities.java new file mode 100644 index 0000000000..6f34afc7b8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RendererCapabilities.java @@ -0,0 +1,293 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.annotation.SuppressLint; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Defines the capabilities of a {@link Renderer}. + */ +public interface RendererCapabilities { + + /** + * Level of renderer support for a format. One of {@link #FORMAT_HANDLED}, {@link + * #FORMAT_EXCEEDS_CAPABILITIES}, {@link #FORMAT_UNSUPPORTED_DRM}, {@link + * #FORMAT_UNSUPPORTED_SUBTYPE} or {@link #FORMAT_UNSUPPORTED_TYPE}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + FORMAT_HANDLED, + FORMAT_EXCEEDS_CAPABILITIES, + FORMAT_UNSUPPORTED_DRM, + FORMAT_UNSUPPORTED_SUBTYPE, + FORMAT_UNSUPPORTED_TYPE + }) + @interface FormatSupport {} + + /** A mask to apply to {@link Capabilities} to obtain the {@link FormatSupport} only. */ + int FORMAT_SUPPORT_MASK = 0b111; + /** + * The {@link Renderer} is capable of rendering the format. + */ + int FORMAT_HANDLED = 0b100; + /** + * The {@link Renderer} is capable of rendering formats with the same mime type, but the + * properties of the format exceed the renderer's capabilities. There is a chance the renderer + * will be able to play the format in practice because some renderers report their capabilities + * conservatively, but the expected outcome is that playback will fail. + * <p> + * Example: The {@link Renderer} is capable of rendering H264 and the format's mime type is + * {@link MimeTypes#VIDEO_H264}, but the format's resolution exceeds the maximum limit supported + * by the underlying H264 decoder. + */ + int FORMAT_EXCEEDS_CAPABILITIES = 0b011; + /** + * The {@link Renderer} is capable of rendering formats with the same mime type, but is not + * capable of rendering the format because the format's drm protection is not supported. + * <p> + * Example: The {@link Renderer} is capable of rendering H264 and the format's mime type is + * {@link MimeTypes#VIDEO_H264}, but the format indicates PlayReady drm protection where-as the + * renderer only supports Widevine. + */ + int FORMAT_UNSUPPORTED_DRM = 0b010; + /** + * The {@link 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. + * <p> + * Example: The {@link Renderer} is a general purpose audio renderer and the format's + * mime type matches audio/[subtype], but there does not exist a suitable decoder for [subtype]. + */ + int FORMAT_UNSUPPORTED_SUBTYPE = 0b001; + /** + * The {@link 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. + * <p> + * Example: The {@link Renderer} is a general purpose video renderer, but the format has an + * audio mime type. + */ + int FORMAT_UNSUPPORTED_TYPE = 0b000; + + /** + * Level of renderer support for adaptive format switches. One of {@link #ADAPTIVE_SEAMLESS}, + * {@link #ADAPTIVE_NOT_SEAMLESS} or {@link #ADAPTIVE_NOT_SUPPORTED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ADAPTIVE_SEAMLESS, ADAPTIVE_NOT_SEAMLESS, ADAPTIVE_NOT_SUPPORTED}) + @interface AdaptiveSupport {} + + /** A mask to apply to {@link Capabilities} to obtain the {@link AdaptiveSupport} only. */ + int ADAPTIVE_SUPPORT_MASK = 0b11000; + /** + * The {@link Renderer} can seamlessly adapt between formats. + */ + int ADAPTIVE_SEAMLESS = 0b10000; + /** + * The {@link Renderer} can adapt between formats, but may suffer a brief discontinuity + * (~50-100ms) when adaptation occurs. + */ + int ADAPTIVE_NOT_SEAMLESS = 0b01000; + /** + * The {@link Renderer} does not support adaptation between formats. + */ + int ADAPTIVE_NOT_SUPPORTED = 0b00000; + + /** + * Level of renderer support for tunneling. One of {@link #TUNNELING_SUPPORTED} or {@link + * #TUNNELING_NOT_SUPPORTED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TUNNELING_SUPPORTED, TUNNELING_NOT_SUPPORTED}) + @interface TunnelingSupport {} + + /** A mask to apply to {@link Capabilities} to obtain the {@link TunnelingSupport} only. */ + int TUNNELING_SUPPORT_MASK = 0b100000; + /** + * The {@link Renderer} supports tunneled output. + */ + int TUNNELING_SUPPORTED = 0b100000; + /** + * The {@link Renderer} does not support tunneled output. + */ + int TUNNELING_NOT_SUPPORTED = 0b000000; + + /** + * Combined renderer capabilities. + * + * <p>This is a bitwise OR of {@link FormatSupport}, {@link AdaptiveSupport} and {@link + * TunnelingSupport}. Use {@link #getFormatSupport(int)}, {@link #getAdaptiveSupport(int)} or + * {@link #getTunnelingSupport(int)} to obtain the individual flags. And use {@link #create(int)} + * or {@link #create(int, int, int)} to create the combined capabilities. + * + * <p>Possible values: + * + * <ul> + * <li>{@link FormatSupport}: The level of support for the format itself. One of {@link + * #FORMAT_HANDLED}, {@link #FORMAT_EXCEEDS_CAPABILITIES}, {@link #FORMAT_UNSUPPORTED_DRM}, + * {@link #FORMAT_UNSUPPORTED_SUBTYPE} and {@link #FORMAT_UNSUPPORTED_TYPE}. + * <li>{@link AdaptiveSupport}: The level of support for adapting from the format to another + * format of the same mime type. One of {@link #ADAPTIVE_SEAMLESS}, {@link + * #ADAPTIVE_NOT_SEAMLESS} and {@link #ADAPTIVE_NOT_SUPPORTED}. Only set if the level of + * support for the format itself is {@link #FORMAT_HANDLED} or {@link + * #FORMAT_EXCEEDS_CAPABILITIES}. + * <li>{@link TunnelingSupport}: The level of support for tunneling. One of {@link + * #TUNNELING_SUPPORTED} and {@link #TUNNELING_NOT_SUPPORTED}. Only set if the level of + * support for the format itself is {@link #FORMAT_HANDLED} or {@link + * #FORMAT_EXCEEDS_CAPABILITIES}. + * </ul> + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + // Intentionally empty to prevent assignment or comparison with individual flags without masking. + @IntDef({}) + @interface Capabilities {} + + /** + * Returns {@link Capabilities} for the given {@link FormatSupport}. + * + * <p>The {@link AdaptiveSupport} is set to {@link #ADAPTIVE_NOT_SUPPORTED} and {{@link + * TunnelingSupport} is set to {@link #TUNNELING_NOT_SUPPORTED}. + * + * @param formatSupport The {@link FormatSupport}. + * @return The combined {@link Capabilities} of the given {@link FormatSupport}, {@link + * #ADAPTIVE_NOT_SUPPORTED} and {@link #TUNNELING_NOT_SUPPORTED}. + */ + @Capabilities + static int create(@FormatSupport int formatSupport) { + return create(formatSupport, ADAPTIVE_NOT_SUPPORTED, TUNNELING_NOT_SUPPORTED); + } + + /** + * Returns {@link Capabilities} combining the given {@link FormatSupport}, {@link AdaptiveSupport} + * and {@link TunnelingSupport}. + * + * @param formatSupport The {@link FormatSupport}. + * @param adaptiveSupport The {@link AdaptiveSupport}. + * @param tunnelingSupport The {@link TunnelingSupport}. + * @return The combined {@link Capabilities}. + */ + // Suppression needed for IntDef casting. + @SuppressLint("WrongConstant") + @Capabilities + static int create( + @FormatSupport int formatSupport, + @AdaptiveSupport int adaptiveSupport, + @TunnelingSupport int tunnelingSupport) { + return formatSupport | adaptiveSupport | tunnelingSupport; + } + + /** + * Returns the {@link FormatSupport} from the combined {@link Capabilities}. + * + * @param supportFlags The combined {@link Capabilities}. + * @return The {@link FormatSupport} only. + */ + // Suppression needed for IntDef casting. + @SuppressLint("WrongConstant") + @FormatSupport + static int getFormatSupport(@Capabilities int supportFlags) { + return supportFlags & FORMAT_SUPPORT_MASK; + } + + /** + * Returns the {@link AdaptiveSupport} from the combined {@link Capabilities}. + * + * @param supportFlags The combined {@link Capabilities}. + * @return The {@link AdaptiveSupport} only. + */ + // Suppression needed for IntDef casting. + @SuppressLint("WrongConstant") + @AdaptiveSupport + static int getAdaptiveSupport(@Capabilities int supportFlags) { + return supportFlags & ADAPTIVE_SUPPORT_MASK; + } + + /** + * Returns the {@link TunnelingSupport} from the combined {@link Capabilities}. + * + * @param supportFlags The combined {@link Capabilities}. + * @return The {@link TunnelingSupport} only. + */ + // Suppression needed for IntDef casting. + @SuppressLint("WrongConstant") + @TunnelingSupport + static int getTunnelingSupport(@Capabilities int supportFlags) { + return supportFlags & TUNNELING_SUPPORT_MASK; + } + + /** + * Returns string representation of a {@link FormatSupport} flag. + * + * @param formatSupport A {@link FormatSupport} flag. + * @return A string representation of the flag. + */ + static String getFormatSupportString(@FormatSupport int formatSupport) { + switch (formatSupport) { + case RendererCapabilities.FORMAT_HANDLED: + return "YES"; + case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES: + return "NO_EXCEEDS_CAPABILITIES"; + case RendererCapabilities.FORMAT_UNSUPPORTED_DRM: + return "NO_UNSUPPORTED_DRM"; + case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE: + return "NO_UNSUPPORTED_TYPE"; + case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE: + return "NO"; + default: + throw new IllegalStateException(); + } + } + + /** + * Returns the track type that the {@link Renderer} handles. For example, a video renderer will + * return {@link C#TRACK_TYPE_VIDEO}, an audio renderer will return {@link C#TRACK_TYPE_AUDIO}, a + * text renderer will return {@link C#TRACK_TYPE_TEXT}, and so on. + * + * @see Renderer#getTrackType() + * @return One of the {@code TRACK_TYPE_*} constants defined in {@link C}. + */ + int getTrackType(); + + /** + * Returns the extent to which the {@link Renderer} supports a given format. + * + * @param format The format. + * @return The {@link Capabilities} for this format. + * @throws ExoPlaybackException If an error occurs. + */ + @Capabilities + int supportsFormat(Format format) throws ExoPlaybackException; + + /** + * Returns the extent to which the {@link Renderer} supports adapting between supported formats + * that have different MIME types. + * + * @return The {@link AdaptiveSupport} for adapting between supported formats that have different + * MIME types. + * @throws ExoPlaybackException If an error occurs. + */ + @AdaptiveSupport + int supportsMixedMimeTypeAdaptation() throws ExoPlaybackException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RendererConfiguration.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RendererConfiguration.java new file mode 100644 index 0000000000..d12e2b9fb6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RendererConfiguration.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; + +/** + * The configuration of a {@link Renderer}. + */ +public final class RendererConfiguration { + + /** + * The default configuration. + */ + public static final RendererConfiguration DEFAULT = + new RendererConfiguration(C.AUDIO_SESSION_ID_UNSET); + + /** + * The audio session id to use for tunneling, or {@link C#AUDIO_SESSION_ID_UNSET} if tunneling + * should not be enabled. + */ + public final int tunnelingAudioSessionId; + + /** + * @param tunnelingAudioSessionId The audio session id to use for tunneling, or + * {@link C#AUDIO_SESSION_ID_UNSET} if tunneling should not be enabled. + */ + public RendererConfiguration(int tunnelingAudioSessionId) { + this.tunnelingAudioSessionId = tunnelingAudioSessionId; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + RendererConfiguration other = (RendererConfiguration) obj; + return tunnelingAudioSessionId == other.tunnelingAudioSessionId; + } + + @Override + public int hashCode() { + return tunnelingAudioSessionId; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RenderersFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RenderersFactory.java new file mode 100644 index 0000000000..ed46d27fa3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/RenderersFactory.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.TextOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener; + +/** + * Builds {@link Renderer} instances for use by a {@link SimpleExoPlayer}. + */ +public interface RenderersFactory { + + /** + * Builds the {@link Renderer} instances for a {@link SimpleExoPlayer}. + * + * @param eventHandler A handler to use when invoking event listeners and outputs. + * @param videoRendererEventListener An event listener for video renderers. + * @param audioRendererEventListener An event listener for audio renderers. + * @param textRendererOutput An output for text renderers. + * @param metadataRendererOutput An output for metadata renderers. + * @param drmSessionManager A drm session manager used by renderers. + * @return The {@link Renderer instances}. + */ + Renderer[] createRenderers( + Handler eventHandler, + VideoRendererEventListener videoRendererEventListener, + AudioRendererEventListener audioRendererEventListener, + TextOutput textRendererOutput, + MetadataOutput metadataRendererOutput, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/SeekParameters.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/SeekParameters.java new file mode 100644 index 0000000000..03c1d0165d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/SeekParameters.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Parameters that apply to seeking. + * + * <p>The predefined {@link #EXACT}, {@link #CLOSEST_SYNC}, {@link #PREVIOUS_SYNC} and {@link + * #NEXT_SYNC} parameters are suitable for most use cases. Seeking to sync points is typically + * faster but less accurate than exact seeking. + * + * <p>In the general case, an instance specifies a maximum tolerance before ({@link + * #toleranceBeforeUs}) and after ({@link #toleranceAfterUs}) a requested seek position ({@code x}). + * If one or more sync points falls within the window {@code [x - toleranceBeforeUs, x + + * toleranceAfterUs]} then the seek will be performed to the sync point within the window that's + * closest to {@code x}. If no sync point falls within the window then the seek will be performed to + * {@code x - toleranceBeforeUs}. Internally the player may need to seek to an earlier sync point + * and discard media until this position is reached. + */ +public final class SeekParameters { + + /** Parameters for exact seeking. */ + public static final SeekParameters EXACT = new SeekParameters(0, 0); + /** Parameters for seeking to the closest sync point. */ + public static final SeekParameters CLOSEST_SYNC = + new SeekParameters(Long.MAX_VALUE, Long.MAX_VALUE); + /** Parameters for seeking to the sync point immediately before a requested seek position. */ + public static final SeekParameters PREVIOUS_SYNC = new SeekParameters(Long.MAX_VALUE, 0); + /** Parameters for seeking to the sync point immediately after a requested seek position. */ + public static final SeekParameters NEXT_SYNC = new SeekParameters(0, Long.MAX_VALUE); + /** Default parameters. */ + public static final SeekParameters DEFAULT = EXACT; + + /** + * The maximum time that the actual position seeked to may precede the requested seek position, in + * microseconds. + */ + public final long toleranceBeforeUs; + /** + * The maximum time that the actual position seeked to may exceed the requested seek position, in + * microseconds. + */ + public final long toleranceAfterUs; + + /** + * @param toleranceBeforeUs The maximum time that the actual position seeked to may precede the + * requested seek position, in microseconds. Must be non-negative. + * @param toleranceAfterUs The maximum time that the actual position seeked to may exceed the + * requested seek position, in microseconds. Must be non-negative. + */ + public SeekParameters(long toleranceBeforeUs, long toleranceAfterUs) { + Assertions.checkArgument(toleranceBeforeUs >= 0); + Assertions.checkArgument(toleranceAfterUs >= 0); + this.toleranceBeforeUs = toleranceBeforeUs; + this.toleranceAfterUs = toleranceAfterUs; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + SeekParameters other = (SeekParameters) obj; + return toleranceBeforeUs == other.toleranceBeforeUs + && toleranceAfterUs == other.toleranceAfterUs; + } + + @Override + public int hashCode() { + return (31 * (int) toleranceBeforeUs) + (int) toleranceAfterUs; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/SimpleExoPlayer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/SimpleExoPlayer.java new file mode 100644 index 0000000000..7b632ed051 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/SimpleExoPlayer.java @@ -0,0 +1,1845 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Rect; +import android.graphics.SurfaceTexture; +import android.media.MediaCodec; +import android.media.PlaybackParams; +import android.os.Handler; +import android.os.Looper; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsCollector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AuxEffectInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DefaultDrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.TextOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoDecoderOutputBufferRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoFrameMetadataListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical.CameraMotionListener; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * An {@link ExoPlayer} implementation that uses default {@link Renderer} components. Instances can + * be obtained from {@link SimpleExoPlayer.Builder}. + */ +public class SimpleExoPlayer extends BasePlayer + implements ExoPlayer, + Player.AudioComponent, + Player.VideoComponent, + Player.TextComponent, + Player.MetadataComponent { + + /** @deprecated Use {@link org.mozilla.thirdparty.com.google.android.exoplayer2video.VideoListener}. */ + @Deprecated + public interface VideoListener extends org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener {} + + /** + * A builder for {@link SimpleExoPlayer} instances. + * + * <p>See {@link #Builder(Context)} for the list of default values. + */ + public static final class Builder { + + private final Context context; + private final RenderersFactory renderersFactory; + + private Clock clock; + private TrackSelector trackSelector; + private LoadControl loadControl; + private BandwidthMeter bandwidthMeter; + private AnalyticsCollector analyticsCollector; + private Looper looper; + private boolean useLazyPreparation; + private boolean buildCalled; + + /** + * Creates a builder. + * + * <p>Use {@link #Builder(Context, RenderersFactory)} instead, if you intend to provide a custom + * {@link RenderersFactory}. This is to ensure that ProGuard or R8 can remove ExoPlayer's {@link + * DefaultRenderersFactory} from the APK. + * + * <p>The builder uses the following default values: + * + * <ul> + * <li>{@link RenderersFactory}: {@link DefaultRenderersFactory} + * <li>{@link TrackSelector}: {@link DefaultTrackSelector} + * <li>{@link LoadControl}: {@link DefaultLoadControl} + * <li>{@link BandwidthMeter}: {@link DefaultBandwidthMeter#getSingletonInstance(Context)} + * <li>{@link Looper}: The {@link Looper} associated with the current thread, or the {@link + * Looper} of the application's main thread if the current thread doesn't have a {@link + * Looper} + * <li>{@link AnalyticsCollector}: {@link AnalyticsCollector} with {@link Clock#DEFAULT} + * <li>{@code useLazyPreparation}: {@code true} + * <li>{@link Clock}: {@link Clock#DEFAULT} + * </ul> + * + * @param context A {@link Context}. + */ + public Builder(Context context) { + this(context, new DefaultRenderersFactory(context)); + } + + /** + * Creates a builder with a custom {@link RenderersFactory}. + * + * <p>See {@link #Builder(Context)} for a list of default values. + * + * @param context A {@link Context}. + * @param renderersFactory A factory for creating {@link Renderer Renderers} to be used by the + * player. + */ + public Builder(Context context, RenderersFactory renderersFactory) { + this( + context, + renderersFactory, + new DefaultTrackSelector(context), + new DefaultLoadControl(), + DefaultBandwidthMeter.getSingletonInstance(context), + Util.getLooper(), + new AnalyticsCollector(Clock.DEFAULT), + /* useLazyPreparation= */ true, + Clock.DEFAULT); + } + + /** + * Creates a builder with the specified custom components. + * + * <p>Note that this constructor is only useful if you try to ensure that ExoPlayer's default + * components can be removed by ProGuard or R8. For most components except renderers, there is + * only a marginal benefit of doing that. + * + * @param context A {@link Context}. + * @param renderersFactory A factory for creating {@link Renderer Renderers} to be used by the + * player. + * @param trackSelector A {@link TrackSelector}. + * @param loadControl A {@link LoadControl}. + * @param bandwidthMeter A {@link BandwidthMeter}. + * @param looper A {@link Looper} that must be used for all calls to the player. + * @param analyticsCollector An {@link AnalyticsCollector}. + * @param useLazyPreparation Whether media sources should be initialized lazily. + * @param clock A {@link Clock}. Should always be {@link Clock#DEFAULT}. + */ + public Builder( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + Looper looper, + AnalyticsCollector analyticsCollector, + boolean useLazyPreparation, + Clock clock) { + this.context = context; + this.renderersFactory = renderersFactory; + this.trackSelector = trackSelector; + this.loadControl = loadControl; + this.bandwidthMeter = bandwidthMeter; + this.looper = looper; + this.analyticsCollector = analyticsCollector; + this.useLazyPreparation = useLazyPreparation; + this.clock = clock; + } + + /** + * Sets the {@link TrackSelector} that will be used by the player. + * + * @param trackSelector A {@link TrackSelector}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setTrackSelector(TrackSelector trackSelector) { + Assertions.checkState(!buildCalled); + this.trackSelector = trackSelector; + return this; + } + + /** + * Sets the {@link LoadControl} that will be used by the player. + * + * @param loadControl A {@link LoadControl}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setLoadControl(LoadControl loadControl) { + Assertions.checkState(!buildCalled); + this.loadControl = loadControl; + return this; + } + + /** + * Sets the {@link BandwidthMeter} that will be used by the player. + * + * @param bandwidthMeter A {@link BandwidthMeter}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setBandwidthMeter(BandwidthMeter bandwidthMeter) { + Assertions.checkState(!buildCalled); + this.bandwidthMeter = bandwidthMeter; + return this; + } + + /** + * Sets the {@link Looper} that must be used for all calls to the player and that is used to + * call listeners on. + * + * @param looper A {@link Looper}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setLooper(Looper looper) { + Assertions.checkState(!buildCalled); + this.looper = looper; + return this; + } + + /** + * Sets the {@link AnalyticsCollector} that will collect and forward all player events. + * + * @param analyticsCollector An {@link AnalyticsCollector}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setAnalyticsCollector(AnalyticsCollector analyticsCollector) { + Assertions.checkState(!buildCalled); + this.analyticsCollector = analyticsCollector; + return this; + } + + /** + * Sets whether media sources should be initialized lazily. + * + * <p>If false, all initial preparation steps (e.g., manifest loads) happen immediately. If + * true, these initial preparations are triggered only when the player starts buffering the + * media. + * + * @param useLazyPreparation Whether to use lazy preparation. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public Builder setUseLazyPreparation(boolean useLazyPreparation) { + Assertions.checkState(!buildCalled); + this.useLazyPreparation = useLazyPreparation; + return this; + } + + /** + * Sets the {@link Clock} that will be used by the player. Should only be set for testing + * purposes. + * + * @param clock A {@link Clock}. + * @return This builder. + * @throws IllegalStateException If {@link #build()} has already been called. + */ + @VisibleForTesting + public Builder setClock(Clock clock) { + Assertions.checkState(!buildCalled); + this.clock = clock; + return this; + } + + /** + * Builds a {@link SimpleExoPlayer} instance. + * + * @throws IllegalStateException If {@link #build()} has already been called. + */ + public SimpleExoPlayer build() { + Assertions.checkState(!buildCalled); + buildCalled = true; + return new SimpleExoPlayer( + context, + renderersFactory, + trackSelector, + loadControl, + bandwidthMeter, + analyticsCollector, + clock, + looper); + } + } + + private static final String TAG = "SimpleExoPlayer"; + + protected final Renderer[] renderers; + + private final ExoPlayerImpl player; + private final Handler eventHandler; + private final ComponentListener componentListener; + private final CopyOnWriteArraySet<org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener> + videoListeners; + private final CopyOnWriteArraySet<AudioListener> audioListeners; + private final CopyOnWriteArraySet<TextOutput> textOutputs; + private final CopyOnWriteArraySet<MetadataOutput> metadataOutputs; + private final CopyOnWriteArraySet<VideoRendererEventListener> videoDebugListeners; + private final CopyOnWriteArraySet<AudioRendererEventListener> audioDebugListeners; + private final BandwidthMeter bandwidthMeter; + private final AnalyticsCollector analyticsCollector; + + private final AudioBecomingNoisyManager audioBecomingNoisyManager; + private final AudioFocusManager audioFocusManager; + private final WakeLockManager wakeLockManager; + private final WifiLockManager wifiLockManager; + + @Nullable private Format videoFormat; + @Nullable private Format audioFormat; + + @Nullable private VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer; + @Nullable private Surface surface; + private boolean ownsSurface; + private @C.VideoScalingMode int videoScalingMode; + @Nullable private SurfaceHolder surfaceHolder; + @Nullable private TextureView textureView; + private int surfaceWidth; + private int surfaceHeight; + @Nullable private DecoderCounters videoDecoderCounters; + @Nullable private DecoderCounters audioDecoderCounters; + private int audioSessionId; + private AudioAttributes audioAttributes; + private float audioVolume; + @Nullable private MediaSource mediaSource; + private List<Cue> currentCues; + @Nullable private VideoFrameMetadataListener videoFrameMetadataListener; + @Nullable private CameraMotionListener cameraMotionListener; + private boolean hasNotifiedFullWrongThreadWarning; + @Nullable private PriorityTaskManager priorityTaskManager; + private boolean isPriorityTaskManagerRegistered; + private boolean playerReleased; + + /** + * @param context A {@link Context}. + * @param renderersFactory A factory for creating {@link Renderer}s to be used by the instance. + * @param trackSelector The {@link TrackSelector} that will be used by the instance. + * @param loadControl The {@link LoadControl} that will be used by the instance. + * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. + * @param analyticsCollector A factory for creating the {@link AnalyticsCollector} that will + * collect and forward all player events. + * @param clock The {@link Clock} that will be used by the instance. Should always be {@link + * Clock#DEFAULT}, unless the player is being used from a test. + * @param looper The {@link Looper} which must be used for all calls to the player and which is + * used to call listeners on. + */ + @SuppressWarnings("deprecation") + protected SimpleExoPlayer( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + BandwidthMeter bandwidthMeter, + AnalyticsCollector analyticsCollector, + Clock clock, + Looper looper) { + this( + context, + renderersFactory, + trackSelector, + loadControl, + DrmSessionManager.getDummyDrmSessionManager(), + bandwidthMeter, + analyticsCollector, + clock, + looper); + } + + /** + * @param context A {@link Context}. + * @param renderersFactory A factory for creating {@link Renderer}s to be used by the instance. + * @param trackSelector The {@link TrackSelector} that will be used by the instance. + * @param loadControl The {@link LoadControl} that will be used by the instance. + * @param drmSessionManager An optional {@link DrmSessionManager}. May be null if the instance + * will not be used for DRM protected playbacks. + * @param bandwidthMeter The {@link BandwidthMeter} that will be used by the instance. + * @param analyticsCollector The {@link AnalyticsCollector} that will collect and forward all + * player events. + * @param clock The {@link Clock} that will be used by the instance. Should always be {@link + * Clock#DEFAULT}, unless the player is being used from a test. + * @param looper The {@link Looper} which must be used for all calls to the player and which is + * used to call listeners on. + * @deprecated Use {@link #SimpleExoPlayer(Context, RenderersFactory, TrackSelector, LoadControl, + * BandwidthMeter, AnalyticsCollector, Clock, Looper)} instead, and pass the {@link + * DrmSessionManager} to the {@link MediaSource} factories. + */ + @Deprecated + protected SimpleExoPlayer( + Context context, + RenderersFactory renderersFactory, + TrackSelector trackSelector, + LoadControl loadControl, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + BandwidthMeter bandwidthMeter, + AnalyticsCollector analyticsCollector, + Clock clock, + Looper looper) { + this.bandwidthMeter = bandwidthMeter; + this.analyticsCollector = analyticsCollector; + componentListener = new ComponentListener(); + videoListeners = new CopyOnWriteArraySet<>(); + audioListeners = new CopyOnWriteArraySet<>(); + textOutputs = new CopyOnWriteArraySet<>(); + metadataOutputs = new CopyOnWriteArraySet<>(); + videoDebugListeners = new CopyOnWriteArraySet<>(); + audioDebugListeners = new CopyOnWriteArraySet<>(); + eventHandler = new Handler(looper); + renderers = + renderersFactory.createRenderers( + eventHandler, + componentListener, + componentListener, + componentListener, + componentListener, + drmSessionManager); + + // Set initial values. + audioVolume = 1; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + audioAttributes = AudioAttributes.DEFAULT; + videoScalingMode = C.VIDEO_SCALING_MODE_DEFAULT; + currentCues = Collections.emptyList(); + + // Build the player and associated objects. + player = + new ExoPlayerImpl(renderers, trackSelector, loadControl, bandwidthMeter, clock, looper); + analyticsCollector.setPlayer(player); + player.addListener(analyticsCollector); + player.addListener(componentListener); + videoDebugListeners.add(analyticsCollector); + videoListeners.add(analyticsCollector); + audioDebugListeners.add(analyticsCollector); + audioListeners.add(analyticsCollector); + addMetadataOutput(analyticsCollector); + bandwidthMeter.addEventListener(eventHandler, analyticsCollector); + if (drmSessionManager instanceof DefaultDrmSessionManager) { + ((DefaultDrmSessionManager) drmSessionManager).addListener(eventHandler, analyticsCollector); + } + audioBecomingNoisyManager = + new AudioBecomingNoisyManager(context, eventHandler, componentListener); + audioFocusManager = new AudioFocusManager(context, eventHandler, componentListener); + wakeLockManager = new WakeLockManager(context); + wifiLockManager = new WifiLockManager(context); + } + + @Override + @Nullable + public AudioComponent getAudioComponent() { + return this; + } + + @Override + @Nullable + public VideoComponent getVideoComponent() { + return this; + } + + @Override + @Nullable + public TextComponent getTextComponent() { + return this; + } + + @Override + @Nullable + public MetadataComponent getMetadataComponent() { + return this; + } + + /** + * Sets the video scaling mode. + * + * <p>Note that the scaling mode only applies if a {@link MediaCodec}-based video {@link Renderer} + * is enabled and if the output surface is owned by a {@link android.view.SurfaceView}. + * + * @param videoScalingMode The video scaling mode. + */ + @Override + public void setVideoScalingMode(@C.VideoScalingMode int videoScalingMode) { + verifyApplicationThread(); + this.videoScalingMode = videoScalingMode; + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { + player + .createMessage(renderer) + .setType(C.MSG_SET_SCALING_MODE) + .setPayload(videoScalingMode) + .send(); + } + } + } + + @Override + public @C.VideoScalingMode int getVideoScalingMode() { + return videoScalingMode; + } + + @Override + public void clearVideoSurface() { + verifyApplicationThread(); + removeSurfaceCallbacks(); + setVideoSurfaceInternal(/* surface= */ null, /* ownsSurface= */ false); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + } + + @Override + public void clearVideoSurface(@Nullable Surface surface) { + verifyApplicationThread(); + if (surface != null && surface == this.surface) { + clearVideoSurface(); + } + } + + @Override + public void setVideoSurface(@Nullable Surface surface) { + verifyApplicationThread(); + removeSurfaceCallbacks(); + if (surface != null) { + clearVideoDecoderOutputBufferRenderer(); + } + setVideoSurfaceInternal(surface, /* ownsSurface= */ false); + int newSurfaceSize = surface == null ? 0 : C.LENGTH_UNSET; + maybeNotifySurfaceSizeChanged(/* width= */ newSurfaceSize, /* height= */ newSurfaceSize); + } + + @Override + public void setVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) { + verifyApplicationThread(); + removeSurfaceCallbacks(); + if (surfaceHolder != null) { + clearVideoDecoderOutputBufferRenderer(); + } + this.surfaceHolder = surfaceHolder; + if (surfaceHolder == null) { + setVideoSurfaceInternal(null, /* ownsSurface= */ false); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + } else { + surfaceHolder.addCallback(componentListener); + Surface surface = surfaceHolder.getSurface(); + if (surface != null && surface.isValid()) { + setVideoSurfaceInternal(surface, /* ownsSurface= */ false); + Rect surfaceSize = surfaceHolder.getSurfaceFrame(); + maybeNotifySurfaceSizeChanged(surfaceSize.width(), surfaceSize.height()); + } else { + setVideoSurfaceInternal(/* surface= */ null, /* ownsSurface= */ false); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + } + } + } + + @Override + public void clearVideoSurfaceHolder(@Nullable SurfaceHolder surfaceHolder) { + verifyApplicationThread(); + if (surfaceHolder != null && surfaceHolder == this.surfaceHolder) { + setVideoSurfaceHolder(null); + } + } + + @Override + public void setVideoSurfaceView(@Nullable SurfaceView surfaceView) { + setVideoSurfaceHolder(surfaceView == null ? null : surfaceView.getHolder()); + } + + @Override + public void clearVideoSurfaceView(@Nullable SurfaceView surfaceView) { + clearVideoSurfaceHolder(surfaceView == null ? null : surfaceView.getHolder()); + } + + @Override + public void setVideoTextureView(@Nullable TextureView textureView) { + verifyApplicationThread(); + removeSurfaceCallbacks(); + if (textureView != null) { + clearVideoDecoderOutputBufferRenderer(); + } + this.textureView = textureView; + if (textureView == null) { + setVideoSurfaceInternal(/* surface= */ null, /* ownsSurface= */ true); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + } else { + if (textureView.getSurfaceTextureListener() != null) { + Log.w(TAG, "Replacing existing SurfaceTextureListener."); + } + textureView.setSurfaceTextureListener(componentListener); + SurfaceTexture surfaceTexture = + textureView.isAvailable() ? textureView.getSurfaceTexture() : null; + if (surfaceTexture == null) { + setVideoSurfaceInternal(/* surface= */ null, /* ownsSurface= */ true); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + } else { + setVideoSurfaceInternal(new Surface(surfaceTexture), /* ownsSurface= */ true); + maybeNotifySurfaceSizeChanged(textureView.getWidth(), textureView.getHeight()); + } + } + } + + @Override + public void clearVideoTextureView(@Nullable TextureView textureView) { + verifyApplicationThread(); + if (textureView != null && textureView == this.textureView) { + setVideoTextureView(null); + } + } + + @Override + public void setVideoDecoderOutputBufferRenderer( + @Nullable VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer) { + verifyApplicationThread(); + if (videoDecoderOutputBufferRenderer != null) { + clearVideoSurface(); + } + setVideoDecoderOutputBufferRendererInternal(videoDecoderOutputBufferRenderer); + } + + @Override + public void clearVideoDecoderOutputBufferRenderer() { + verifyApplicationThread(); + setVideoDecoderOutputBufferRendererInternal(/* videoDecoderOutputBufferRenderer= */ null); + } + + @Override + public void clearVideoDecoderOutputBufferRenderer( + @Nullable VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer) { + verifyApplicationThread(); + if (videoDecoderOutputBufferRenderer != null + && videoDecoderOutputBufferRenderer == this.videoDecoderOutputBufferRenderer) { + clearVideoDecoderOutputBufferRenderer(); + } + } + + @Override + public void addAudioListener(AudioListener listener) { + audioListeners.add(listener); + } + + @Override + public void removeAudioListener(AudioListener listener) { + audioListeners.remove(listener); + } + + @Override + public void setAudioAttributes(AudioAttributes audioAttributes) { + setAudioAttributes(audioAttributes, /* handleAudioFocus= */ false); + } + + @Override + public void setAudioAttributes(AudioAttributes audioAttributes, boolean handleAudioFocus) { + verifyApplicationThread(); + if (playerReleased) { + return; + } + if (!Util.areEqual(this.audioAttributes, audioAttributes)) { + this.audioAttributes = audioAttributes; + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { + player + .createMessage(renderer) + .setType(C.MSG_SET_AUDIO_ATTRIBUTES) + .setPayload(audioAttributes) + .send(); + } + } + for (AudioListener audioListener : audioListeners) { + audioListener.onAudioAttributesChanged(audioAttributes); + } + } + + audioFocusManager.setAudioAttributes(handleAudioFocus ? audioAttributes : null); + boolean playWhenReady = getPlayWhenReady(); + @AudioFocusManager.PlayerCommand + int playerCommand = audioFocusManager.updateAudioFocus(playWhenReady, getPlaybackState()); + updatePlayWhenReady(playWhenReady, playerCommand); + } + + @Override + public AudioAttributes getAudioAttributes() { + return audioAttributes; + } + + @Override + public int getAudioSessionId() { + return audioSessionId; + } + + @Override + public void setAuxEffectInfo(AuxEffectInfo auxEffectInfo) { + verifyApplicationThread(); + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { + player + .createMessage(renderer) + .setType(C.MSG_SET_AUX_EFFECT_INFO) + .setPayload(auxEffectInfo) + .send(); + } + } + } + + @Override + public void clearAuxEffectInfo() { + setAuxEffectInfo(new AuxEffectInfo(AuxEffectInfo.NO_AUX_EFFECT_ID, /* sendLevel= */ 0f)); + } + + @Override + public void setVolume(float audioVolume) { + verifyApplicationThread(); + audioVolume = Util.constrainValue(audioVolume, /* min= */ 0, /* max= */ 1); + if (this.audioVolume == audioVolume) { + return; + } + this.audioVolume = audioVolume; + sendVolumeToRenderers(); + for (AudioListener audioListener : audioListeners) { + audioListener.onVolumeChanged(audioVolume); + } + } + + @Override + public float getVolume() { + return audioVolume; + } + + /** + * Sets the stream type for audio playback, used by the underlying audio track. + * + * <p>Setting the stream type during playback may introduce a short gap in audio output as the + * audio track is recreated. A new audio session id will also be generated. + * + * <p>Calling this method overwrites any attributes set previously by calling {@link + * #setAudioAttributes(AudioAttributes)}. + * + * @deprecated Use {@link #setAudioAttributes(AudioAttributes)}. + * @param streamType The stream type for audio playback. + */ + @Deprecated + public void setAudioStreamType(@C.StreamType int streamType) { + @C.AudioUsage int usage = Util.getAudioUsageForStreamType(streamType); + @C.AudioContentType int contentType = Util.getAudioContentTypeForStreamType(streamType); + AudioAttributes audioAttributes = + new AudioAttributes.Builder().setUsage(usage).setContentType(contentType).build(); + setAudioAttributes(audioAttributes); + } + + /** + * Returns the stream type for audio playback. + * + * @deprecated Use {@link #getAudioAttributes()}. + */ + @Deprecated + public @C.StreamType int getAudioStreamType() { + return Util.getStreamTypeForAudioUsage(audioAttributes.usage); + } + + /** Returns the {@link AnalyticsCollector} used for collecting analytics events. */ + public AnalyticsCollector getAnalyticsCollector() { + return analyticsCollector; + } + + /** + * Adds an {@link AnalyticsListener} to receive analytics events. + * + * @param listener The listener to be added. + */ + public void addAnalyticsListener(AnalyticsListener listener) { + verifyApplicationThread(); + analyticsCollector.addListener(listener); + } + + /** + * Removes an {@link AnalyticsListener}. + * + * @param listener The listener to be removed. + */ + public void removeAnalyticsListener(AnalyticsListener listener) { + verifyApplicationThread(); + analyticsCollector.removeListener(listener); + } + + /** + * Sets whether the player should pause automatically when audio is rerouted from a headset to + * device speakers. See the <a + * href="https://developer.android.com/guide/topics/media-apps/volume-and-earphones#becoming-noisy">audio + * becoming noisy</a> documentation for more information. + * + * <p>This feature is not enabled by default. + * + * @param handleAudioBecomingNoisy Whether the player should pause automatically when audio is + * rerouted from a headset to device speakers. + */ + public void setHandleAudioBecomingNoisy(boolean handleAudioBecomingNoisy) { + verifyApplicationThread(); + if (playerReleased) { + return; + } + audioBecomingNoisyManager.setEnabled(handleAudioBecomingNoisy); + } + + /** + * Sets a {@link PriorityTaskManager}, or null to clear a previously set priority task manager. + * + * <p>The priority {@link C#PRIORITY_PLAYBACK} will be set while the player is loading. + * + * @param priorityTaskManager The {@link PriorityTaskManager}, or null to clear a previously set + * priority task manager. + */ + public void setPriorityTaskManager(@Nullable PriorityTaskManager priorityTaskManager) { + verifyApplicationThread(); + if (Util.areEqual(this.priorityTaskManager, priorityTaskManager)) { + return; + } + if (isPriorityTaskManagerRegistered) { + Assertions.checkNotNull(this.priorityTaskManager).remove(C.PRIORITY_PLAYBACK); + } + if (priorityTaskManager != null && isLoading()) { + priorityTaskManager.add(C.PRIORITY_PLAYBACK); + isPriorityTaskManagerRegistered = true; + } else { + isPriorityTaskManagerRegistered = false; + } + this.priorityTaskManager = priorityTaskManager; + } + + /** + * Sets the {@link PlaybackParams} governing audio playback. + * + * @deprecated Use {@link #setPlaybackParameters(PlaybackParameters)}. + * @param params The {@link PlaybackParams}, or null to clear any previously set parameters. + */ + @Deprecated + @TargetApi(23) + public void setPlaybackParams(@Nullable PlaybackParams params) { + PlaybackParameters playbackParameters; + if (params != null) { + params.allowDefaults(); + playbackParameters = new PlaybackParameters(params.getSpeed(), params.getPitch()); + } else { + playbackParameters = null; + } + setPlaybackParameters(playbackParameters); + } + + /** Returns the video format currently being played, or null if no video is being played. */ + @Nullable + public Format getVideoFormat() { + return videoFormat; + } + + /** Returns the audio format currently being played, or null if no audio is being played. */ + @Nullable + public Format getAudioFormat() { + return audioFormat; + } + + /** Returns {@link DecoderCounters} for video, or null if no video is being played. */ + @Nullable + public DecoderCounters getVideoDecoderCounters() { + return videoDecoderCounters; + } + + /** Returns {@link DecoderCounters} for audio, or null if no audio is being played. */ + @Nullable + public DecoderCounters getAudioDecoderCounters() { + return audioDecoderCounters; + } + + @Override + public void addVideoListener(org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener listener) { + videoListeners.add(listener); + } + + @Override + public void removeVideoListener(org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener listener) { + videoListeners.remove(listener); + } + + @Override + public void setVideoFrameMetadataListener(VideoFrameMetadataListener listener) { + verifyApplicationThread(); + videoFrameMetadataListener = listener; + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { + player + .createMessage(renderer) + .setType(C.MSG_SET_VIDEO_FRAME_METADATA_LISTENER) + .setPayload(listener) + .send(); + } + } + } + + @Override + public void clearVideoFrameMetadataListener(VideoFrameMetadataListener listener) { + verifyApplicationThread(); + if (videoFrameMetadataListener != listener) { + return; + } + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { + player + .createMessage(renderer) + .setType(C.MSG_SET_VIDEO_FRAME_METADATA_LISTENER) + .setPayload(null) + .send(); + } + } + } + + @Override + public void setCameraMotionListener(CameraMotionListener listener) { + verifyApplicationThread(); + cameraMotionListener = listener; + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_CAMERA_MOTION) { + player + .createMessage(renderer) + .setType(C.MSG_SET_CAMERA_MOTION_LISTENER) + .setPayload(listener) + .send(); + } + } + } + + @Override + public void clearCameraMotionListener(CameraMotionListener listener) { + verifyApplicationThread(); + if (cameraMotionListener != listener) { + return; + } + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_CAMERA_MOTION) { + player + .createMessage(renderer) + .setType(C.MSG_SET_CAMERA_MOTION_LISTENER) + .setPayload(null) + .send(); + } + } + } + + /** + * Sets a listener to receive video events, removing all existing listeners. + * + * @param listener The listener. + * @deprecated Use {@link #addVideoListener(org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener)}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public void setVideoListener(VideoListener listener) { + videoListeners.clear(); + if (listener != null) { + addVideoListener(listener); + } + } + + /** + * Equivalent to {@link #removeVideoListener(org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener)}. + * + * @param listener The listener to clear. + * @deprecated Use {@link + * #removeVideoListener(org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener)}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public void clearVideoListener(VideoListener listener) { + removeVideoListener(listener); + } + + @Override + public void addTextOutput(TextOutput listener) { + if (!currentCues.isEmpty()) { + listener.onCues(currentCues); + } + textOutputs.add(listener); + } + + @Override + public void removeTextOutput(TextOutput listener) { + textOutputs.remove(listener); + } + + /** + * Sets an output to receive text events, removing all existing outputs. + * + * @param output The output. + * @deprecated Use {@link #addTextOutput(TextOutput)}. + */ + @Deprecated + public void setTextOutput(TextOutput output) { + textOutputs.clear(); + if (output != null) { + addTextOutput(output); + } + } + + /** + * Equivalent to {@link #removeTextOutput(TextOutput)}. + * + * @param output The output to clear. + * @deprecated Use {@link #removeTextOutput(TextOutput)}. + */ + @Deprecated + public void clearTextOutput(TextOutput output) { + removeTextOutput(output); + } + + @Override + public void addMetadataOutput(MetadataOutput listener) { + metadataOutputs.add(listener); + } + + @Override + public void removeMetadataOutput(MetadataOutput listener) { + metadataOutputs.remove(listener); + } + + /** + * Sets an output to receive metadata events, removing all existing outputs. + * + * @param output The output. + * @deprecated Use {@link #addMetadataOutput(MetadataOutput)}. + */ + @Deprecated + public void setMetadataOutput(MetadataOutput output) { + metadataOutputs.retainAll(Collections.singleton(analyticsCollector)); + if (output != null) { + addMetadataOutput(output); + } + } + + /** + * Equivalent to {@link #removeMetadataOutput(MetadataOutput)}. + * + * @param output The output to clear. + * @deprecated Use {@link #removeMetadataOutput(MetadataOutput)}. + */ + @Deprecated + public void clearMetadataOutput(MetadataOutput output) { + removeMetadataOutput(output); + } + + /** + * @deprecated Use {@link #addAnalyticsListener(AnalyticsListener)} to get more detailed debug + * information. + */ + @Deprecated + @SuppressWarnings("deprecation") + public void setVideoDebugListener(VideoRendererEventListener listener) { + videoDebugListeners.retainAll(Collections.singleton(analyticsCollector)); + if (listener != null) { + addVideoDebugListener(listener); + } + } + + /** + * @deprecated Use {@link #addAnalyticsListener(AnalyticsListener)} to get more detailed debug + * information. + */ + @Deprecated + public void addVideoDebugListener(VideoRendererEventListener listener) { + videoDebugListeners.add(listener); + } + + /** + * @deprecated Use {@link #addAnalyticsListener(AnalyticsListener)} and {@link + * #removeAnalyticsListener(AnalyticsListener)} to get more detailed debug information. + */ + @Deprecated + public void removeVideoDebugListener(VideoRendererEventListener listener) { + videoDebugListeners.remove(listener); + } + + /** + * @deprecated Use {@link #addAnalyticsListener(AnalyticsListener)} to get more detailed debug + * information. + */ + @Deprecated + @SuppressWarnings("deprecation") + public void setAudioDebugListener(AudioRendererEventListener listener) { + audioDebugListeners.retainAll(Collections.singleton(analyticsCollector)); + if (listener != null) { + addAudioDebugListener(listener); + } + } + + /** + * @deprecated Use {@link #addAnalyticsListener(AnalyticsListener)} to get more detailed debug + * information. + */ + @Deprecated + public void addAudioDebugListener(AudioRendererEventListener listener) { + audioDebugListeners.add(listener); + } + + /** + * @deprecated Use {@link #addAnalyticsListener(AnalyticsListener)} and {@link + * #removeAnalyticsListener(AnalyticsListener)} to get more detailed debug information. + */ + @Deprecated + public void removeAudioDebugListener(AudioRendererEventListener listener) { + audioDebugListeners.remove(listener); + } + + // ExoPlayer implementation + + @Override + public Looper getPlaybackLooper() { + return player.getPlaybackLooper(); + } + + @Override + public Looper getApplicationLooper() { + return player.getApplicationLooper(); + } + + @Override + public void addListener(Player.EventListener listener) { + verifyApplicationThread(); + player.addListener(listener); + } + + @Override + public void removeListener(Player.EventListener listener) { + verifyApplicationThread(); + player.removeListener(listener); + } + + @Override + @State + public int getPlaybackState() { + verifyApplicationThread(); + return player.getPlaybackState(); + } + + @Override + @PlaybackSuppressionReason + public int getPlaybackSuppressionReason() { + verifyApplicationThread(); + return player.getPlaybackSuppressionReason(); + } + + @Override + @Nullable + public ExoPlaybackException getPlaybackError() { + verifyApplicationThread(); + return player.getPlaybackError(); + } + + @Override + public void retry() { + verifyApplicationThread(); + if (mediaSource != null + && (getPlaybackError() != null || getPlaybackState() == Player.STATE_IDLE)) { + prepare(mediaSource, /* resetPosition= */ false, /* resetState= */ false); + } + } + + @Override + public void prepare(MediaSource mediaSource) { + prepare(mediaSource, /* resetPosition= */ true, /* resetState= */ true); + } + + @Override + public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) { + verifyApplicationThread(); + if (this.mediaSource != null) { + this.mediaSource.removeEventListener(analyticsCollector); + analyticsCollector.resetForNewMediaSource(); + } + this.mediaSource = mediaSource; + mediaSource.addEventListener(eventHandler, analyticsCollector); + boolean playWhenReady = getPlayWhenReady(); + @AudioFocusManager.PlayerCommand + int playerCommand = audioFocusManager.updateAudioFocus(playWhenReady, Player.STATE_BUFFERING); + updatePlayWhenReady(playWhenReady, playerCommand); + player.prepare(mediaSource, resetPosition, resetState); + } + + @Override + public void setPlayWhenReady(boolean playWhenReady) { + verifyApplicationThread(); + @AudioFocusManager.PlayerCommand + int playerCommand = audioFocusManager.updateAudioFocus(playWhenReady, getPlaybackState()); + updatePlayWhenReady(playWhenReady, playerCommand); + } + + @Override + public boolean getPlayWhenReady() { + verifyApplicationThread(); + return player.getPlayWhenReady(); + } + + @Override + public @RepeatMode int getRepeatMode() { + verifyApplicationThread(); + return player.getRepeatMode(); + } + + @Override + public void setRepeatMode(@RepeatMode int repeatMode) { + verifyApplicationThread(); + player.setRepeatMode(repeatMode); + } + + @Override + public void setShuffleModeEnabled(boolean shuffleModeEnabled) { + verifyApplicationThread(); + player.setShuffleModeEnabled(shuffleModeEnabled); + } + + @Override + public boolean getShuffleModeEnabled() { + verifyApplicationThread(); + return player.getShuffleModeEnabled(); + } + + @Override + public boolean isLoading() { + verifyApplicationThread(); + return player.isLoading(); + } + + @Override + public void seekTo(int windowIndex, long positionMs) { + verifyApplicationThread(); + analyticsCollector.notifySeekStarted(); + player.seekTo(windowIndex, positionMs); + } + + @Override + public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) { + verifyApplicationThread(); + player.setPlaybackParameters(playbackParameters); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + verifyApplicationThread(); + return player.getPlaybackParameters(); + } + + @Override + public void setSeekParameters(@Nullable SeekParameters seekParameters) { + verifyApplicationThread(); + player.setSeekParameters(seekParameters); + } + + @Override + public SeekParameters getSeekParameters() { + verifyApplicationThread(); + return player.getSeekParameters(); + } + + @Override + public void setForegroundMode(boolean foregroundMode) { + player.setForegroundMode(foregroundMode); + } + + @Override + public void stop(boolean reset) { + verifyApplicationThread(); + audioFocusManager.updateAudioFocus(getPlayWhenReady(), Player.STATE_IDLE); + player.stop(reset); + if (mediaSource != null) { + mediaSource.removeEventListener(analyticsCollector); + analyticsCollector.resetForNewMediaSource(); + if (reset) { + mediaSource = null; + } + } + currentCues = Collections.emptyList(); + } + + @Override + public void release() { + verifyApplicationThread(); + audioBecomingNoisyManager.setEnabled(false); + wakeLockManager.setStayAwake(false); + wifiLockManager.setStayAwake(false); + audioFocusManager.release(); + player.release(); + removeSurfaceCallbacks(); + if (surface != null) { + if (ownsSurface) { + surface.release(); + } + surface = null; + } + if (mediaSource != null) { + mediaSource.removeEventListener(analyticsCollector); + mediaSource = null; + } + if (isPriorityTaskManagerRegistered) { + Assertions.checkNotNull(priorityTaskManager).remove(C.PRIORITY_PLAYBACK); + isPriorityTaskManagerRegistered = false; + } + bandwidthMeter.removeEventListener(analyticsCollector); + currentCues = Collections.emptyList(); + playerReleased = true; + } + + @Override + public PlayerMessage createMessage(PlayerMessage.Target target) { + verifyApplicationThread(); + return player.createMessage(target); + } + + @Override + public int getRendererCount() { + verifyApplicationThread(); + return player.getRendererCount(); + } + + @Override + public int getRendererType(int index) { + verifyApplicationThread(); + return player.getRendererType(index); + } + + @Override + public TrackGroupArray getCurrentTrackGroups() { + verifyApplicationThread(); + return player.getCurrentTrackGroups(); + } + + @Override + public TrackSelectionArray getCurrentTrackSelections() { + verifyApplicationThread(); + return player.getCurrentTrackSelections(); + } + + @Override + public Timeline getCurrentTimeline() { + verifyApplicationThread(); + return player.getCurrentTimeline(); + } + + @Override + public int getCurrentPeriodIndex() { + verifyApplicationThread(); + return player.getCurrentPeriodIndex(); + } + + @Override + public int getCurrentWindowIndex() { + verifyApplicationThread(); + return player.getCurrentWindowIndex(); + } + + @Override + public long getDuration() { + verifyApplicationThread(); + return player.getDuration(); + } + + @Override + public long getCurrentPosition() { + verifyApplicationThread(); + return player.getCurrentPosition(); + } + + @Override + public long getBufferedPosition() { + verifyApplicationThread(); + return player.getBufferedPosition(); + } + + @Override + public long getTotalBufferedDuration() { + verifyApplicationThread(); + return player.getTotalBufferedDuration(); + } + + @Override + public boolean isPlayingAd() { + verifyApplicationThread(); + return player.isPlayingAd(); + } + + @Override + public int getCurrentAdGroupIndex() { + verifyApplicationThread(); + return player.getCurrentAdGroupIndex(); + } + + @Override + public int getCurrentAdIndexInAdGroup() { + verifyApplicationThread(); + return player.getCurrentAdIndexInAdGroup(); + } + + @Override + public long getContentPosition() { + verifyApplicationThread(); + return player.getContentPosition(); + } + + @Override + public long getContentBufferedPosition() { + verifyApplicationThread(); + return player.getContentBufferedPosition(); + } + + /** + * Sets whether the player should use a {@link android.os.PowerManager.WakeLock} to ensure the + * device stays awake for playback, even when the screen is off. + * + * <p>Enabling this feature requires the {@link android.Manifest.permission#WAKE_LOCK} permission. + * It should be used together with a foreground {@link android.app.Service} for use cases where + * playback can occur when the screen is off (e.g. background audio playback). It is not useful if + * the screen will always be on during playback (e.g. foreground video playback). + * + * <p>This feature is not enabled by default. If enabled, a WakeLock is held whenever the player + * is in the {@link #STATE_READY READY} or {@link #STATE_BUFFERING BUFFERING} states with {@code + * playWhenReady = true}. + * + * @param handleWakeLock Whether the player should use a {@link android.os.PowerManager.WakeLock} + * to ensure the device stays awake for playback, even when the screen is off. + * @deprecated Use {@link #setWakeMode(int)} instead. + */ + @Deprecated + public void setHandleWakeLock(boolean handleWakeLock) { + setWakeMode(handleWakeLock ? C.WAKE_MODE_LOCAL : C.WAKE_MODE_NONE); + } + + /** + * Sets how the player should keep the device awake for playback when the screen is off. + * + * <p>Enabling this feature requires the {@link android.Manifest.permission#WAKE_LOCK} permission. + * It should be used together with a foreground {@link android.app.Service} for use cases where + * playback occurs and the screen is off (e.g. background audio playback). It is not useful when + * the screen will be kept on during playback (e.g. foreground video playback). + * + * <p>When enabled, the locks ({@link android.os.PowerManager.WakeLock} / {@link + * android.net.wifi.WifiManager.WifiLock}) will be held whenever the player is in the {@link + * #STATE_READY} or {@link #STATE_BUFFERING} states with {@code playWhenReady = true}. The locks + * held depends on the specified {@link C.WakeMode}. + * + * @param wakeMode The {@link C.WakeMode} option to keep the device awake during playback. + */ + public void setWakeMode(@C.WakeMode int wakeMode) { + switch (wakeMode) { + case C.WAKE_MODE_NONE: + wakeLockManager.setEnabled(false); + wifiLockManager.setEnabled(false); + break; + case C.WAKE_MODE_LOCAL: + wakeLockManager.setEnabled(true); + wifiLockManager.setEnabled(false); + break; + case C.WAKE_MODE_NETWORK: + wakeLockManager.setEnabled(true); + wifiLockManager.setEnabled(true); + break; + default: + break; + } + } + + // Internal methods. + + private void removeSurfaceCallbacks() { + if (textureView != null) { + if (textureView.getSurfaceTextureListener() != componentListener) { + Log.w(TAG, "SurfaceTextureListener already unset or replaced."); + } else { + textureView.setSurfaceTextureListener(null); + } + textureView = null; + } + if (surfaceHolder != null) { + surfaceHolder.removeCallback(componentListener); + surfaceHolder = null; + } + } + + private void setVideoSurfaceInternal(@Nullable Surface surface, boolean ownsSurface) { + // Note: We don't turn this method into a no-op if the surface is being replaced with itself + // so as to ensure onRenderedFirstFrame callbacks are still called in this case. + List<PlayerMessage> messages = new ArrayList<>(); + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { + messages.add( + player.createMessage(renderer).setType(C.MSG_SET_SURFACE).setPayload(surface).send()); + } + } + if (this.surface != null && this.surface != surface) { + // We're replacing a surface. Block to ensure that it's not accessed after the method returns. + try { + for (PlayerMessage message : messages) { + message.blockUntilDelivered(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + // If we created the previous surface, we are responsible for releasing it. + if (this.ownsSurface) { + this.surface.release(); + } + } + this.surface = surface; + this.ownsSurface = ownsSurface; + } + + private void setVideoDecoderOutputBufferRendererInternal( + @Nullable VideoDecoderOutputBufferRenderer videoDecoderOutputBufferRenderer) { + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_VIDEO) { + player + .createMessage(renderer) + .setType(C.MSG_SET_VIDEO_DECODER_OUTPUT_BUFFER_RENDERER) + .setPayload(videoDecoderOutputBufferRenderer) + .send(); + } + } + this.videoDecoderOutputBufferRenderer = videoDecoderOutputBufferRenderer; + } + + private void maybeNotifySurfaceSizeChanged(int width, int height) { + if (width != surfaceWidth || height != surfaceHeight) { + surfaceWidth = width; + surfaceHeight = height; + for (org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener videoListener : videoListeners) { + videoListener.onSurfaceSizeChanged(width, height); + } + } + } + + private void sendVolumeToRenderers() { + float scaledVolume = audioVolume * audioFocusManager.getVolumeMultiplier(); + for (Renderer renderer : renderers) { + if (renderer.getTrackType() == C.TRACK_TYPE_AUDIO) { + player.createMessage(renderer).setType(C.MSG_SET_VOLUME).setPayload(scaledVolume).send(); + } + } + } + + private void updatePlayWhenReady( + boolean playWhenReady, @AudioFocusManager.PlayerCommand int playerCommand) { + playWhenReady = playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_DO_NOT_PLAY; + @PlaybackSuppressionReason + int playbackSuppressionReason = + playWhenReady && playerCommand != AudioFocusManager.PLAYER_COMMAND_PLAY_WHEN_READY + ? Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS + : Player.PLAYBACK_SUPPRESSION_REASON_NONE; + player.setPlayWhenReady(playWhenReady, playbackSuppressionReason); + } + + private void verifyApplicationThread() { + if (Looper.myLooper() != getApplicationLooper()) { + Log.w( + TAG, + "Player is accessed on the wrong thread. See " + + "https://exoplayer.dev/issues/player-accessed-on-wrong-thread", + hasNotifiedFullWrongThreadWarning ? null : new IllegalStateException()); + hasNotifiedFullWrongThreadWarning = true; + } + } + + private void updateWakeAndWifiLock() { + @State int playbackState = getPlaybackState(); + switch (playbackState) { + case Player.STATE_READY: + case Player.STATE_BUFFERING: + wakeLockManager.setStayAwake(getPlayWhenReady()); + wifiLockManager.setStayAwake(getPlayWhenReady()); + break; + case Player.STATE_ENDED: + case Player.STATE_IDLE: + wakeLockManager.setStayAwake(false); + wifiLockManager.setStayAwake(false); + break; + default: + throw new IllegalStateException(); + } + } + + private final class ComponentListener + implements VideoRendererEventListener, + AudioRendererEventListener, + TextOutput, + MetadataOutput, + SurfaceHolder.Callback, + TextureView.SurfaceTextureListener, + AudioFocusManager.PlayerControl, + AudioBecomingNoisyManager.EventListener, + Player.EventListener { + + // VideoRendererEventListener implementation + + @Override + public void onVideoEnabled(DecoderCounters counters) { + videoDecoderCounters = counters; + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onVideoEnabled(counters); + } + } + + @Override + public void onVideoDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onVideoDecoderInitialized( + decoderName, initializedTimestampMs, initializationDurationMs); + } + } + + @Override + public void onVideoInputFormatChanged(Format format) { + videoFormat = format; + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onVideoInputFormatChanged(format); + } + } + + @Override + public void onDroppedFrames(int count, long elapsed) { + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onDroppedFrames(count, elapsed); + } + } + + @Override + public void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + for (org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener videoListener : videoListeners) { + // Prevent duplicate notification if a listener is both a VideoRendererEventListener and + // a VideoListener, as they have the same method signature. + if (!videoDebugListeners.contains(videoListener)) { + videoListener.onVideoSizeChanged( + width, height, unappliedRotationDegrees, pixelWidthHeightRatio); + } + } + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onVideoSizeChanged( + width, height, unappliedRotationDegrees, pixelWidthHeightRatio); + } + } + + @Override + public void onRenderedFirstFrame(Surface surface) { + if (SimpleExoPlayer.this.surface == surface) { + for (org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener videoListener : videoListeners) { + videoListener.onRenderedFirstFrame(); + } + } + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onRenderedFirstFrame(surface); + } + } + + @Override + public void onVideoDisabled(DecoderCounters counters) { + for (VideoRendererEventListener videoDebugListener : videoDebugListeners) { + videoDebugListener.onVideoDisabled(counters); + } + videoFormat = null; + videoDecoderCounters = null; + } + + // AudioRendererEventListener implementation + + @Override + public void onAudioEnabled(DecoderCounters counters) { + audioDecoderCounters = counters; + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioEnabled(counters); + } + } + + @Override + public void onAudioSessionId(int sessionId) { + if (audioSessionId == sessionId) { + return; + } + audioSessionId = sessionId; + for (AudioListener audioListener : audioListeners) { + // Prevent duplicate notification if a listener is both a AudioRendererEventListener and + // a AudioListener, as they have the same method signature. + if (!audioDebugListeners.contains(audioListener)) { + audioListener.onAudioSessionId(sessionId); + } + } + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioSessionId(sessionId); + } + } + + @Override + public void onAudioDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioDecoderInitialized( + decoderName, initializedTimestampMs, initializationDurationMs); + } + } + + @Override + public void onAudioInputFormatChanged(Format format) { + audioFormat = format; + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioInputFormatChanged(format); + } + } + + @Override + public void onAudioSinkUnderrun( + int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + } + + @Override + public void onAudioDisabled(DecoderCounters counters) { + for (AudioRendererEventListener audioDebugListener : audioDebugListeners) { + audioDebugListener.onAudioDisabled(counters); + } + audioFormat = null; + audioDecoderCounters = null; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + } + + // TextOutput implementation + + @Override + public void onCues(List<Cue> cues) { + currentCues = cues; + for (TextOutput textOutput : textOutputs) { + textOutput.onCues(cues); + } + } + + // MetadataOutput implementation + + @Override + public void onMetadata(Metadata metadata) { + for (MetadataOutput metadataOutput : metadataOutputs) { + metadataOutput.onMetadata(metadata); + } + } + + // SurfaceHolder.Callback implementation + + @Override + public void surfaceCreated(SurfaceHolder holder) { + setVideoSurfaceInternal(holder.getSurface(), false); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + maybeNotifySurfaceSizeChanged(width, height); + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + setVideoSurfaceInternal(/* surface= */ null, /* ownsSurface= */ false); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + } + + // TextureView.SurfaceTextureListener implementation + + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surfaceTexture, int width, int height) { + setVideoSurfaceInternal(new Surface(surfaceTexture), /* ownsSurface= */ true); + maybeNotifySurfaceSizeChanged(width, height); + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surfaceTexture, int width, int height) { + maybeNotifySurfaceSizeChanged(width, height); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surfaceTexture) { + setVideoSurfaceInternal(/* surface= */ null, /* ownsSurface= */ true); + maybeNotifySurfaceSizeChanged(/* width= */ 0, /* height= */ 0); + return true; + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surfaceTexture) { + // Do nothing. + } + + // AudioFocusManager.PlayerControl implementation + + @Override + public void setVolumeMultiplier(float volumeMultiplier) { + sendVolumeToRenderers(); + } + + @Override + public void executePlayerCommand(@AudioFocusManager.PlayerCommand int playerCommand) { + updatePlayWhenReady(getPlayWhenReady(), playerCommand); + } + + // AudioBecomingNoisyManager.EventListener implementation. + + @Override + public void onAudioBecomingNoisy() { + setPlayWhenReady(false); + } + + // Player.EventListener implementation. + + @Override + public void onLoadingChanged(boolean isLoading) { + if (priorityTaskManager != null) { + if (isLoading && !isPriorityTaskManagerRegistered) { + priorityTaskManager.add(C.PRIORITY_PLAYBACK); + isPriorityTaskManagerRegistered = true; + } else if (!isLoading && isPriorityTaskManagerRegistered) { + priorityTaskManager.remove(C.PRIORITY_PLAYBACK); + isPriorityTaskManagerRegistered = false; + } + } + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, @State int playbackState) { + updateWakeAndWifiLock(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Timeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Timeline.java new file mode 100644 index 0000000000..c9e3d16ff7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/Timeline.java @@ -0,0 +1,837 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads.AdPlaybackState; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * A flexible representation of the structure of media. A timeline is able to represent the + * structure of a wide variety of media, from simple cases like a single media file through to + * complex compositions of media such as playlists and streams with inserted ads. Instances are + * immutable. For cases where media is changing dynamically (e.g. live streams), a timeline provides + * a snapshot of the current state. + * + * <p>A timeline consists of {@link Window Windows} and {@link Period Periods}. + * + * <ul> + * <li>A {@link Window} usually corresponds to one playlist item. It may span one or more periods + * and it defines the region within those periods that's currently available for playback. The + * window also provides additional information such as whether seeking is supported within the + * window and the default position, which is the position from which playback will start when + * the player starts playing the window. + * <li>A {@link Period} defines a single logical piece of media, for example a media file. It may + * also define groups of ads inserted into the media, along with information about whether + * those ads have been loaded and played. + * </ul> + * + * <p>The following examples illustrate timelines for various use cases. + * + * <h3 id="single-file">Single media file or on-demand stream</h3> + * + * <p style="align:center"><img src="doc-files/timeline-single-file.svg" alt="Example timeline for a + * single file"> A timeline for a single media file or on-demand stream consists of a single period + * and window. The window spans the whole period, indicating that all parts of the media are + * available for playback. The window's default position is typically at the start of the period + * (indicated by the black dot in the figure above). + * + * <h3>Playlist of media files or on-demand streams</h3> + * + * <p style="align:center"><img src="doc-files/timeline-playlist.svg" alt="Example timeline for a + * playlist of files"> A timeline for a playlist of media files or on-demand streams consists of + * multiple periods, each with its own window. Each window spans the whole of the corresponding + * period, and typically has a default position at the start of the period. The properties of the + * periods and windows (e.g. their durations and whether the window is seekable) will often only + * become known when the player starts buffering the corresponding file or stream. + * + * <h3 id="live-limited">Live stream with limited availability</h3> + * + * <p style="align:center"><img src="doc-files/timeline-live-limited.svg" alt="Example timeline for + * a live stream with limited availability"> A timeline for a live stream consists of a period whose + * duration is unknown, since it's continually extending as more content is broadcast. If content + * only remains available for a limited period of time then the window may start at a non-zero + * position, defining the region of content that can still be played. The window will have {@link + * Window#isLive} set to true to indicate it's a live stream and {@link Window#isDynamic} set to + * true as long as we expect changes to the live window. Its default position is typically near to + * the live edge (indicated by the black dot in the figure above). + * + * <h3>Live stream with indefinite availability</h3> + * + * <p style="align:center"><img src="doc-files/timeline-live-indefinite.svg" alt="Example timeline + * for a live stream with indefinite availability"> A timeline for a live stream with indefinite + * availability is similar to the <a href="#live-limited">Live stream with limited availability</a> + * case, except that the window starts at the beginning of the period to indicate that all of the + * previously broadcast content can still be played. + * + * <h3 id="live-multi-period">Live stream with multiple periods</h3> + * + * <p style="align:center"><img src="doc-files/timeline-live-multi-period.svg" alt="Example timeline + * for a live stream with multiple periods"> This case arises when a live stream is explicitly + * divided into separate periods, for example at content boundaries. This case is similar to the <a + * href="#live-limited">Live stream with limited availability</a> case, except that the window may + * span more than one period. Multiple periods are also possible in the indefinite availability + * case. + * + * <h3>On-demand stream followed by live stream</h3> + * + * <p style="align:center"><img src="doc-files/timeline-advanced.svg" alt="Example timeline for an + * on-demand stream followed by a live stream"> This case is the concatenation of the <a + * href="#single-file">Single media file or on-demand stream</a> and <a href="#multi-period">Live + * stream with multiple periods</a> cases. When playback of the on-demand stream ends, playback of + * the live stream will start from its default position near the live edge. + * + * <h3 id="single-file-midrolls">On-demand stream with mid-roll ads</h3> + * + * <p style="align:center"><img src="doc-files/timeline-single-file-midrolls.svg" alt="Example + * timeline for an on-demand stream with mid-roll ad groups"> This case includes mid-roll ad groups, + * which are defined as part of the timeline's single period. The period can be queried for + * information about the ad groups and the ads they contain. + */ +public abstract class Timeline { + + /** + * Holds information about a window in a {@link Timeline}. A window usually corresponds to one + * playlist item and defines a region of media currently available for playback along with + * additional information such as whether seeking is supported within the window. The figure below + * shows some of the information defined by a window, as well as how this information relates to + * corresponding {@link Period Periods} in the timeline. + * + * <p style="align:center"><img src="doc-files/timeline-window.svg" alt="Information defined by a + * timeline window"> + */ + public static final class Window { + + /** + * A {@link #uid} for a window that must be used for single-window {@link Timeline Timelines}. + */ + public static final Object SINGLE_WINDOW_UID = new Object(); + + /** + * A unique identifier for the window. Single-window {@link Timeline Timelines} must use {@link + * #SINGLE_WINDOW_UID}. + */ + public Object uid; + + /** A tag for the window. Not necessarily unique. */ + @Nullable public Object tag; + + /** The manifest of the window. May be {@code null}. */ + @Nullable public Object manifest; + + /** + * The start time of the presentation to which this window belongs in milliseconds since the + * epoch, or {@link C#TIME_UNSET} if unknown or not applicable. For informational purposes only. + */ + public long presentationStartTimeMs; + + /** + * The window's start time in milliseconds since the epoch, or {@link C#TIME_UNSET} if unknown + * or not applicable. For informational purposes only. + */ + public long windowStartTimeMs; + + /** + * Whether it's possible to seek within this window. + */ + public boolean isSeekable; + + // TODO: Split this to better describe which parts of the window might change. For example it + // should be possible to individually determine whether the start and end positions of the + // window may change relative to the underlying periods. For an example of where it's useful to + // know that the end position is fixed whilst the start position may still change, see: + // https://github.com/google/ExoPlayer/issues/4780. + /** Whether this window may change when the timeline is updated. */ + public boolean isDynamic; + + /** + * Whether the media in this window is live. For informational purposes only. + * + * <p>Check {@link #isDynamic} to know whether this window may still change. + */ + public boolean isLive; + + /** The index of the first period that belongs to this window. */ + public int firstPeriodIndex; + + /** + * The index of the last period that belongs to this window. + */ + public int lastPeriodIndex; + + /** + * The default position relative to the start of the window at which to begin playback, in + * microseconds. May be {@link C#TIME_UNSET} if and only if the window was populated with a + * non-zero default position projection, and if the specified projection cannot be performed + * whilst remaining within the bounds of the window. + */ + public long defaultPositionUs; + + /** + * The duration of this window in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long durationUs; + + /** + * The position of the start of this window relative to the start of the first period belonging + * to it, in microseconds. + */ + public long positionInFirstPeriodUs; + + /** Creates window. */ + public Window() { + uid = SINGLE_WINDOW_UID; + } + + /** Sets the data held by this window. */ + public Window set( + Object uid, + @Nullable Object tag, + @Nullable Object manifest, + long presentationStartTimeMs, + long windowStartTimeMs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + long defaultPositionUs, + long durationUs, + int firstPeriodIndex, + int lastPeriodIndex, + long positionInFirstPeriodUs) { + this.uid = uid; + this.tag = tag; + this.manifest = manifest; + this.presentationStartTimeMs = presentationStartTimeMs; + this.windowStartTimeMs = windowStartTimeMs; + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.isLive = isLive; + this.defaultPositionUs = defaultPositionUs; + this.durationUs = durationUs; + this.firstPeriodIndex = firstPeriodIndex; + this.lastPeriodIndex = lastPeriodIndex; + this.positionInFirstPeriodUs = positionInFirstPeriodUs; + return this; + } + + /** + * Returns the default position relative to the start of the window at which to begin playback, + * in milliseconds. May be {@link C#TIME_UNSET} if and only if the window was populated with a + * non-zero default position projection, and if the specified projection cannot be performed + * whilst remaining within the bounds of the window. + */ + public long getDefaultPositionMs() { + return C.usToMs(defaultPositionUs); + } + + /** + * Returns the default position relative to the start of the window at which to begin playback, + * in microseconds. May be {@link C#TIME_UNSET} if and only if the window was populated with a + * non-zero default position projection, and if the specified projection cannot be performed + * whilst remaining within the bounds of the window. + */ + public long getDefaultPositionUs() { + return defaultPositionUs; + } + + /** + * Returns the duration of the window in milliseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long getDurationMs() { + return C.usToMs(durationUs); + } + + /** + * Returns the duration of this window in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the position of the start of this window relative to the start of the first period + * belonging to it, in milliseconds. + */ + public long getPositionInFirstPeriodMs() { + return C.usToMs(positionInFirstPeriodUs); + } + + /** + * Returns the position of the start of this window relative to the start of the first period + * belonging to it, in microseconds. + */ + public long getPositionInFirstPeriodUs() { + return positionInFirstPeriodUs; + } + + } + + /** + * Holds information about a period in a {@link Timeline}. A period defines a single logical piece + * of media, for example a media file. It may also define groups of ads inserted into the media, + * along with information about whether those ads have been loaded and played. + * + * <p>The figure below shows some of the information defined by a period, as well as how this + * information relates to a corresponding {@link Window} in the timeline. + * + * <p style="align:center"><img src="doc-files/timeline-period.svg" alt="Information defined by a + * period"> + */ + public static final class Period { + + /** + * An identifier for the period. Not necessarily unique. May be null if the ids of the period + * are not required. + */ + @Nullable public Object id; + + /** + * A unique identifier for the period. May be null if the ids of the period are not required. + */ + @Nullable public Object uid; + + /** + * The index of the window to which this period belongs. + */ + public int windowIndex; + + /** + * The duration of this period in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long durationUs; + + private long positionInWindowUs; + private AdPlaybackState adPlaybackState; + + /** Creates a new instance with no ad playback state. */ + public Period() { + adPlaybackState = AdPlaybackState.NONE; + } + + /** + * Sets the data held by this period. + * + * @param id An identifier for the period. Not necessarily unique. May be null if the ids of the + * period are not required. + * @param uid A unique identifier for the period. May be null if the ids of the period are not + * required. + * @param windowIndex The index of the window to which this period belongs. + * @param durationUs The duration of this period in microseconds, or {@link C#TIME_UNSET} if + * unknown. + * @param positionInWindowUs The position of the start of this period relative to the start of + * the window to which it belongs, in milliseconds. May be negative if the start of the + * period is not within the window. + * @return This period, for convenience. + */ + public Period set( + @Nullable Object id, + @Nullable Object uid, + int windowIndex, + long durationUs, + long positionInWindowUs) { + return set(id, uid, windowIndex, durationUs, positionInWindowUs, AdPlaybackState.NONE); + } + + /** + * Sets the data held by this period. + * + * @param id An identifier for the period. Not necessarily unique. May be null if the ids of the + * period are not required. + * @param uid A unique identifier for the period. May be null if the ids of the period are not + * required. + * @param windowIndex The index of the window to which this period belongs. + * @param durationUs The duration of this period in microseconds, or {@link C#TIME_UNSET} if + * unknown. + * @param positionInWindowUs The position of the start of this period relative to the start of + * the window to which it belongs, in milliseconds. May be negative if the start of the + * period is not within the window. + * @param adPlaybackState The state of the period's ads, or {@link AdPlaybackState#NONE} if + * there are no ads. + * @return This period, for convenience. + */ + public Period set( + @Nullable Object id, + @Nullable Object uid, + int windowIndex, + long durationUs, + long positionInWindowUs, + AdPlaybackState adPlaybackState) { + this.id = id; + this.uid = uid; + this.windowIndex = windowIndex; + this.durationUs = durationUs; + this.positionInWindowUs = positionInWindowUs; + this.adPlaybackState = adPlaybackState; + return this; + } + + /** + * Returns the duration of the period in milliseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long getDurationMs() { + return C.usToMs(durationUs); + } + + /** + * Returns the duration of this period in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the position of the start of this period relative to the start of the window to which + * it belongs, in milliseconds. May be negative if the start of the period is not within the + * window. + */ + public long getPositionInWindowMs() { + return C.usToMs(positionInWindowUs); + } + + /** + * Returns the position of the start of this period relative to the start of the window to which + * it belongs, in microseconds. May be negative if the start of the period is not within the + * window. + */ + public long getPositionInWindowUs() { + return positionInWindowUs; + } + + /** + * Returns the number of ad groups in the period. + */ + public int getAdGroupCount() { + return adPlaybackState.adGroupCount; + } + + /** + * Returns the time of the ad group at index {@code adGroupIndex} in the period, in + * microseconds. + * + * @param adGroupIndex The ad group index. + * @return The time of the ad group at the index, in microseconds, or {@link + * C#TIME_END_OF_SOURCE} for a post-roll ad group. + */ + public long getAdGroupTimeUs(int adGroupIndex) { + return adPlaybackState.adGroupTimesUs[adGroupIndex]; + } + + /** + * Returns the index of the first ad in the specified ad group that should be played, or the + * number of ads in the ad group if no ads should be played. + * + * @param adGroupIndex The ad group index. + * @return The index of the first ad that should be played, or the number of ads in the ad group + * if no ads should be played. + */ + public int getFirstAdIndexToPlay(int adGroupIndex) { + return adPlaybackState.adGroups[adGroupIndex].getFirstAdIndexToPlay(); + } + + /** + * Returns the index of the next ad in the specified ad group that should be played after + * playing {@code adIndexInAdGroup}, or the number of ads in the ad group if no later ads should + * be played. + * + * @param adGroupIndex The ad group index. + * @param lastPlayedAdIndex The last played ad index in the ad group. + * @return The index of the next ad that should be played, or the number of ads in the ad group + * if the ad group does not have any ads remaining to play. + */ + public int getNextAdIndexToPlay(int adGroupIndex, int lastPlayedAdIndex) { + return adPlaybackState.adGroups[adGroupIndex].getNextAdIndexToPlay(lastPlayedAdIndex); + } + + /** + * Returns whether the ad group at index {@code adGroupIndex} has been played. + * + * @param adGroupIndex The ad group index. + * @return Whether the ad group at index {@code adGroupIndex} has been played. + */ + public boolean hasPlayedAdGroup(int adGroupIndex) { + return !adPlaybackState.adGroups[adGroupIndex].hasUnplayedAds(); + } + + /** + * Returns the index of the ad group at or before {@code positionUs}, if that ad group is + * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has + * no ads remaining to be played, or if there is no such ad group. + * + * @param positionUs The position at or before which to find an ad group, in microseconds. + * @return The index of the ad group, or {@link C#INDEX_UNSET}. + */ + public int getAdGroupIndexForPositionUs(long positionUs) { + return adPlaybackState.getAdGroupIndexForPositionUs(positionUs); + } + + /** + * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be + * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. + * + * @param positionUs The position after which to find an ad group, in microseconds. + * @return The index of the ad group, or {@link C#INDEX_UNSET}. + */ + public int getAdGroupIndexAfterPositionUs(long positionUs) { + return adPlaybackState.getAdGroupIndexAfterPositionUs(positionUs, durationUs); + } + + /** + * Returns the number of ads in the ad group at index {@code adGroupIndex}, or + * {@link C#LENGTH_UNSET} if not yet known. + * + * @param adGroupIndex The ad group index. + * @return The number of ads in the ad group, or {@link C#LENGTH_UNSET} if not yet known. + */ + public int getAdCountInAdGroup(int adGroupIndex) { + return adPlaybackState.adGroups[adGroupIndex].count; + } + + /** + * Returns whether the URL for the specified ad is known. + * + * @param adGroupIndex The ad group index. + * @param adIndexInAdGroup The ad index in the ad group. + * @return Whether the URL for the specified ad is known. + */ + public boolean isAdAvailable(int adGroupIndex, int adIndexInAdGroup) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + return adGroup.count != C.LENGTH_UNSET + && adGroup.states[adIndexInAdGroup] != AdPlaybackState.AD_STATE_UNAVAILABLE; + } + + /** + * Returns the duration of the ad at index {@code adIndexInAdGroup} in the ad group at + * {@code adGroupIndex}, in microseconds, or {@link C#TIME_UNSET} if not yet known. + * + * @param adGroupIndex The ad group index. + * @param adIndexInAdGroup The ad index in the ad group. + * @return The duration of the ad, or {@link C#TIME_UNSET} if not yet known. + */ + public long getAdDurationUs(int adGroupIndex, int adIndexInAdGroup) { + AdPlaybackState.AdGroup adGroup = adPlaybackState.adGroups[adGroupIndex]; + return adGroup.count != C.LENGTH_UNSET ? adGroup.durationsUs[adIndexInAdGroup] : C.TIME_UNSET; + } + + /** + * Returns the position offset in the first unplayed ad at which to begin playback, in + * microseconds. + */ + public long getAdResumePositionUs() { + return adPlaybackState.adResumePositionUs; + } + + } + + /** An empty timeline. */ + public static final Timeline EMPTY = + new Timeline() { + + @Override + public int getWindowCount() { + return 0; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + throw new IndexOutOfBoundsException(); + } + + @Override + public int getPeriodCount() { + return 0; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + throw new IndexOutOfBoundsException(); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return C.INDEX_UNSET; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + throw new IndexOutOfBoundsException(); + } + }; + + /** + * Returns whether the timeline is empty. + */ + public final boolean isEmpty() { + return getWindowCount() == 0; + } + + /** + * Returns the number of windows in the timeline. + */ + public abstract int getWindowCount(); + + /** + * Returns the index of the window after the window at index {@code windowIndex} depending on the + * {@code repeatMode} and whether shuffling is enabled. + * + * @param windowIndex Index of a window in the timeline. + * @param repeatMode A repeat mode. + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return The index of the next window, or {@link C#INDEX_UNSET} if this is the last window. + */ + public int getNextWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + return windowIndex == getLastWindowIndex(shuffleModeEnabled) ? C.INDEX_UNSET + : windowIndex + 1; + case Player.REPEAT_MODE_ONE: + return windowIndex; + case Player.REPEAT_MODE_ALL: + return windowIndex == getLastWindowIndex(shuffleModeEnabled) + ? getFirstWindowIndex(shuffleModeEnabled) : windowIndex + 1; + default: + throw new IllegalStateException(); + } + } + + /** + * Returns the index of the window before the window at index {@code windowIndex} depending on the + * {@code repeatMode} and whether shuffling is enabled. + * + * @param windowIndex Index of a window in the timeline. + * @param repeatMode A repeat mode. + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return The index of the previous window, or {@link C#INDEX_UNSET} if this is the first window. + */ + public int getPreviousWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + return windowIndex == getFirstWindowIndex(shuffleModeEnabled) ? C.INDEX_UNSET + : windowIndex - 1; + case Player.REPEAT_MODE_ONE: + return windowIndex; + case Player.REPEAT_MODE_ALL: + return windowIndex == getFirstWindowIndex(shuffleModeEnabled) + ? getLastWindowIndex(shuffleModeEnabled) : windowIndex - 1; + default: + throw new IllegalStateException(); + } + } + + /** + * Returns the index of the last window in the playback order depending on whether shuffling is + * enabled. + * + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return The index of the last window in the playback order, or {@link C#INDEX_UNSET} if the + * timeline is empty. + */ + public int getLastWindowIndex(boolean shuffleModeEnabled) { + return isEmpty() ? C.INDEX_UNSET : getWindowCount() - 1; + } + + /** + * Returns the index of the first window in the playback order depending on whether shuffling is + * enabled. + * + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return The index of the first window in the playback order, or {@link C#INDEX_UNSET} if the + * timeline is empty. + */ + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + return isEmpty() ? C.INDEX_UNSET : 0; + } + + /** + * Populates a {@link Window} with data for the window at the specified index. + * + * @param windowIndex The index of the window. + * @param window The {@link Window} to populate. Must not be null. + * @return The populated {@link Window}, for convenience. + */ + public final Window getWindow(int windowIndex, Window window) { + return getWindow(windowIndex, window, /* defaultPositionProjectionUs= */ 0); + } + + /** @deprecated Use {@link #getWindow(int, Window)} instead. Tags will always be set. */ + @Deprecated + public final Window getWindow(int windowIndex, Window window, boolean setTag) { + return getWindow(windowIndex, window, /* defaultPositionProjectionUs= */ 0); + } + + /** + * Populates a {@link Window} with data for the window at the specified index. + * + * @param windowIndex The index of the window. + * @param window The {@link Window} to populate. Must not be null. + * @param defaultPositionProjectionUs A duration into the future that the populated window's + * default start position should be projected. + * @return The populated {@link Window}, for convenience. + */ + public abstract Window getWindow( + int windowIndex, Window window, long defaultPositionProjectionUs); + + /** + * Returns the number of periods in the timeline. + */ + public abstract int getPeriodCount(); + + /** + * Returns the index of the period after the period at index {@code periodIndex} depending on the + * {@code repeatMode} and whether shuffling is enabled. + * + * @param periodIndex Index of a period in the timeline. + * @param period A {@link Period} to be used internally. Must not be null. + * @param window A {@link Window} to be used internally. Must not be null. + * @param repeatMode A repeat mode. + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return The index of the next period, or {@link C#INDEX_UNSET} if this is the last period. + */ + public final int getNextPeriodIndex(int periodIndex, Period period, Window window, + @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled) { + int windowIndex = getPeriod(periodIndex, period).windowIndex; + if (getWindow(windowIndex, window).lastPeriodIndex == periodIndex) { + int nextWindowIndex = getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + if (nextWindowIndex == C.INDEX_UNSET) { + return C.INDEX_UNSET; + } + return getWindow(nextWindowIndex, window).firstPeriodIndex; + } + return periodIndex + 1; + } + + /** + * Returns whether the given period is the last period of the timeline depending on the + * {@code repeatMode} and whether shuffling is enabled. + * + * @param periodIndex A period index. + * @param period A {@link Period} to be used internally. Must not be null. + * @param window A {@link Window} to be used internally. Must not be null. + * @param repeatMode A repeat mode. + * @param shuffleModeEnabled Whether shuffling is enabled. + * @return Whether the period of the given index is the last period of the timeline. + */ + public final boolean isLastPeriod(int periodIndex, Period period, Window window, + @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled) { + return getNextPeriodIndex(periodIndex, period, window, repeatMode, shuffleModeEnabled) + == C.INDEX_UNSET; + } + + /** + * Calls {@link #getPeriodPosition(Window, Period, int, long, long)} with a zero default position + * projection. + */ + public final Pair<Object, Long> getPeriodPosition( + Window window, Period period, int windowIndex, long windowPositionUs) { + return Assertions.checkNotNull( + getPeriodPosition( + window, period, windowIndex, windowPositionUs, /* defaultPositionProjectionUs= */ 0)); + } + + /** + * Converts (windowIndex, windowPositionUs) to the corresponding (periodUid, periodPositionUs). + * + * @param window A {@link Window} that may be overwritten. + * @param period A {@link Period} that may be overwritten. + * @param windowIndex The window index. + * @param windowPositionUs The window time, or {@link C#TIME_UNSET} to use the window's default + * start position. + * @param defaultPositionProjectionUs If {@code windowPositionUs} is {@link C#TIME_UNSET}, the + * duration into the future by which the window's position should be projected. + * @return The corresponding (periodUid, periodPositionUs), or null if {@code #windowPositionUs} + * is {@link C#TIME_UNSET}, {@code defaultPositionProjectionUs} is non-zero, and the window's + * position could not be projected by {@code defaultPositionProjectionUs}. + */ + @Nullable + public final Pair<Object, Long> getPeriodPosition( + Window window, + Period period, + int windowIndex, + long windowPositionUs, + long defaultPositionProjectionUs) { + Assertions.checkIndex(windowIndex, 0, getWindowCount()); + getWindow(windowIndex, window, defaultPositionProjectionUs); + if (windowPositionUs == C.TIME_UNSET) { + windowPositionUs = window.getDefaultPositionUs(); + if (windowPositionUs == C.TIME_UNSET) { + return null; + } + } + int periodIndex = window.firstPeriodIndex; + long periodPositionUs = window.getPositionInFirstPeriodUs() + windowPositionUs; + long periodDurationUs = getPeriod(periodIndex, period, /* setIds= */ true).getDurationUs(); + while (periodDurationUs != C.TIME_UNSET && periodPositionUs >= periodDurationUs + && periodIndex < window.lastPeriodIndex) { + periodPositionUs -= periodDurationUs; + periodDurationUs = getPeriod(++periodIndex, period, /* setIds= */ true).getDurationUs(); + } + return Pair.create(Assertions.checkNotNull(period.uid), periodPositionUs); + } + + /** + * Populates a {@link Period} with data for the period with the specified unique identifier. + * + * @param periodUid The unique identifier of the period. + * @param period The {@link Period} to populate. Must not be null. + * @return The populated {@link Period}, for convenience. + */ + public Period getPeriodByUid(Object periodUid, Period period) { + return getPeriod(getIndexOfPeriod(periodUid), period, /* setIds= */ true); + } + + /** + * Populates a {@link Period} with data for the period at the specified index. {@link Period#id} + * and {@link Period#uid} will be set to null. + * + * @param periodIndex The index of the period. + * @param period The {@link Period} to populate. Must not be null. + * @return The populated {@link Period}, for convenience. + */ + public final Period getPeriod(int periodIndex, Period period) { + return getPeriod(periodIndex, period, false); + } + + /** + * Populates a {@link Period} with data for the period at the specified index. + * + * @param periodIndex The index of the period. + * @param period The {@link Period} to populate. Must not be null. + * @param setIds Whether {@link Period#id} and {@link Period#uid} should be populated. If false, + * the fields will be set to null. The caller should pass false for efficiency reasons unless + * the fields are required. + * @return The populated {@link Period}, for convenience. + */ + public abstract Period getPeriod(int periodIndex, Period period, boolean setIds); + + /** + * Returns the index of the period identified by its unique {@link Period#uid}, or {@link + * C#INDEX_UNSET} if the period is not in the timeline. + * + * @param uid A unique identifier for a period. + * @return The index of the period, or {@link C#INDEX_UNSET} if the period was not found. + */ + public abstract int getIndexOfPeriod(Object uid); + + /** + * Returns the unique id of the period identified by its index in the timeline. + * + * @param periodIndex The index of the period. + * @return The unique id of the period. + */ + public abstract Object getUidOfPeriod(int periodIndex); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/WakeLockManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/WakeLockManager.java new file mode 100644 index 0000000000..368eb8aa0d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/WakeLockManager.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; + +/** + * Handles a {@link WakeLock}. + * + * <p>The handling of wake locks requires the {@link android.Manifest.permission#WAKE_LOCK} + * permission. + */ +/* package */ final class WakeLockManager { + + private static final String TAG = "WakeLockManager"; + private static final String WAKE_LOCK_TAG = "ExoPlayer:WakeLockManager"; + + @Nullable private final PowerManager powerManager; + @Nullable private WakeLock wakeLock; + private boolean enabled; + private boolean stayAwake; + + public WakeLockManager(Context context) { + powerManager = + (PowerManager) context.getApplicationContext().getSystemService(Context.POWER_SERVICE); + } + + /** + * Sets whether to enable the acquiring and releasing of the {@link WakeLock}. + * + * <p>By default, wake lock handling is not enabled. Enabling this will acquire the wake lock if + * necessary. Disabling this will release the wake lock if it is held. + * + * <p>Enabling {@link WakeLock} requires the {@link android.Manifest.permission#WAKE_LOCK}. + * + * @param enabled True if the player should handle a {@link WakeLock}, false otherwise. + */ + public void setEnabled(boolean enabled) { + if (enabled) { + if (wakeLock == null) { + if (powerManager == null) { + Log.w(TAG, "PowerManager is null, therefore not creating the WakeLock."); + return; + } + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG); + wakeLock.setReferenceCounted(false); + } + } + + this.enabled = enabled; + updateWakeLock(); + } + + /** + * Sets whether to acquire or release the {@link WakeLock}. + * + * <p>Please note this method requires wake lock handling to be enabled through setEnabled(boolean + * enable) to actually have an impact on the {@link WakeLock}. + * + * @param stayAwake True if the player should acquire the {@link WakeLock}. False if the player + * should release. + */ + public void setStayAwake(boolean stayAwake) { + this.stayAwake = stayAwake; + updateWakeLock(); + } + + // WakelockTimeout suppressed because the time the wake lock is needed for is unknown (could be + // listening to radio with screen off for multiple hours), therefore we can not determine a + // reasonable timeout that would not affect the user. + @SuppressLint("WakelockTimeout") + private void updateWakeLock() { + if (wakeLock == null) { + return; + } + + if (enabled && stayAwake) { + wakeLock.acquire(); + } else { + wakeLock.release(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/WifiLockManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/WifiLockManager.java new file mode 100644 index 0000000000..1081dd39a8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/WifiLockManager.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.net.wifi.WifiManager.WifiLock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; + +/** + * Handles a {@link WifiLock} + * + * <p>The handling of wifi locks requires the {@link android.Manifest.permission#WAKE_LOCK} + * permission. + */ +/* package */ final class WifiLockManager { + + private static final String TAG = "WifiLockManager"; + private static final String WIFI_LOCK_TAG = "ExoPlayer:WifiLockManager"; + + @Nullable private final WifiManager wifiManager; + @Nullable private WifiLock wifiLock; + private boolean enabled; + private boolean stayAwake; + + public WifiLockManager(Context context) { + wifiManager = + (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + } + + /** + * Sets whether to enable the usage of a {@link WifiLock}. + * + * <p>By default, wifi lock handling is not enabled. Enabling will acquire the wifi lock if + * necessary. Disabling will release the wifi lock if held. + * + * <p>Enabling {@link WifiLock} requires the {@link android.Manifest.permission#WAKE_LOCK}. + * + * @param enabled True if the player should handle a {@link WifiLock}. + */ + public void setEnabled(boolean enabled) { + if (enabled && wifiLock == null) { + if (wifiManager == null) { + Log.w(TAG, "WifiManager is null, therefore not creating the WifiLock."); + return; + } + wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, WIFI_LOCK_TAG); + wifiLock.setReferenceCounted(false); + } + + this.enabled = enabled; + updateWifiLock(); + } + + /** + * Sets whether to acquire or release the {@link WifiLock}. + * + * <p>The wifi lock will not be acquired unless handling has been enabled through {@link + * #setEnabled(boolean)}. + * + * @param stayAwake True if the player should acquire the {@link WifiLock}. False if it should + * release. + */ + public void setStayAwake(boolean stayAwake) { + this.stayAwake = stayAwake; + updateWifiLock(); + } + + private void updateWifiLock() { + if (wifiLock == null) { + return; + } + + if (enabled && stayAwake) { + wifiLock.acquire(); + } else { + wifiLock.release(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsCollector.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsCollector.java new file mode 100644 index 0000000000..6bdb4c7727 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsCollector.java @@ -0,0 +1,881 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.view.Surface; +import androidx.annotation.Nullable; +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.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Period; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DefaultDrmSessionEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Data collector which is able to forward analytics events to {@link AnalyticsListener}s by + * listening to all available ExoPlayer listeners. + */ +public class AnalyticsCollector + implements Player.EventListener, + MetadataOutput, + AudioRendererEventListener, + VideoRendererEventListener, + MediaSourceEventListener, + BandwidthMeter.EventListener, + DefaultDrmSessionEventListener, + VideoListener, + AudioListener { + + private final CopyOnWriteArraySet<AnalyticsListener> listeners; + private final Clock clock; + private final Window window; + private final MediaPeriodQueueTracker mediaPeriodQueueTracker; + + private @MonotonicNonNull Player player; + + /** + * Creates an analytics collector. + * + * @param clock A {@link Clock} used to generate timestamps. + */ + public AnalyticsCollector(Clock clock) { + this.clock = Assertions.checkNotNull(clock); + listeners = new CopyOnWriteArraySet<>(); + mediaPeriodQueueTracker = new MediaPeriodQueueTracker(); + window = new Window(); + } + + /** + * Adds a listener for analytics events. + * + * @param listener The listener to add. + */ + public void addListener(AnalyticsListener listener) { + listeners.add(listener); + } + + /** + * Removes a previously added analytics event listener. + * + * @param listener The listener to remove. + */ + public void removeListener(AnalyticsListener listener) { + listeners.remove(listener); + } + + /** + * Sets the player for which data will be collected. Must only be called if no player has been set + * yet or the current player is idle. + * + * @param player The {@link Player} for which data will be collected. + */ + public void setPlayer(Player player) { + Assertions.checkState( + this.player == null || mediaPeriodQueueTracker.mediaPeriodInfoQueue.isEmpty()); + this.player = Assertions.checkNotNull(player); + } + + // External events. + + /** + * Notify analytics collector that a seek operation will start. Should be called before the player + * adjusts its state and position to the seek. + */ + public final void notifySeekStarted() { + if (!mediaPeriodQueueTracker.isSeeking()) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + mediaPeriodQueueTracker.onSeekStarted(); + for (AnalyticsListener listener : listeners) { + listener.onSeekStarted(eventTime); + } + } + } + + /** + * Resets the analytics collector for a new media source. Should be called before the player is + * prepared with a new media source. + */ + public final void resetForNewMediaSource() { + // Copying the list is needed because onMediaPeriodReleased will modify the list. + List<MediaPeriodInfo> mediaPeriodInfos = + new ArrayList<>(mediaPeriodQueueTracker.mediaPeriodInfoQueue); + for (MediaPeriodInfo mediaPeriodInfo : mediaPeriodInfos) { + onMediaPeriodReleased(mediaPeriodInfo.windowIndex, mediaPeriodInfo.mediaPeriodId); + } + } + + // MetadataOutput implementation. + + @Override + public final void onMetadata(Metadata metadata) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onMetadata(eventTime, metadata); + } + } + + // AudioRendererEventListener implementation. + + @Override + public final void onAudioEnabled(DecoderCounters counters) { + // The renderers are only enabled after we changed the playing media period. + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_AUDIO, counters); + } + } + + @Override + public final void onAudioDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInitialized( + eventTime, C.TRACK_TYPE_AUDIO, decoderName, initializationDurationMs); + } + } + + @Override + public final void onAudioInputFormatChanged(Format format) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_AUDIO, format); + } + } + + @Override + public final void onAudioSinkUnderrun( + int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioUnderrun(eventTime, bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + } + + @Override + public final void onAudioDisabled(DecoderCounters counters) { + // The renderers are disabled after we changed the playing media period on the playback thread + // but before this change is reported to the app thread. + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_AUDIO, counters); + } + } + + // AudioListener implementation. + + @Override + public final void onAudioSessionId(int audioSessionId) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioSessionId(eventTime, audioSessionId); + } + } + + @Override + public void onAudioAttributesChanged(AudioAttributes audioAttributes) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onAudioAttributesChanged(eventTime, audioAttributes); + } + } + + @Override + public void onVolumeChanged(float audioVolume) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onVolumeChanged(eventTime, audioVolume); + } + } + + // VideoRendererEventListener implementation. + + @Override + public final void onVideoEnabled(DecoderCounters counters) { + // The renderers are only enabled after we changed the playing media period. + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderEnabled(eventTime, C.TRACK_TYPE_VIDEO, counters); + } + } + + @Override + public final void onVideoDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInitialized( + eventTime, C.TRACK_TYPE_VIDEO, decoderName, initializationDurationMs); + } + } + + @Override + public final void onVideoInputFormatChanged(Format format) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderInputFormatChanged(eventTime, C.TRACK_TYPE_VIDEO, format); + } + } + + @Override + public final void onDroppedFrames(int count, long elapsedMs) { + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDroppedVideoFrames(eventTime, count, elapsedMs); + } + } + + @Override + public final void onVideoDisabled(DecoderCounters counters) { + // The renderers are disabled after we changed the playing media period on the playback thread + // but before this change is reported to the app thread. + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDecoderDisabled(eventTime, C.TRACK_TYPE_VIDEO, counters); + } + } + + @Override + public final void onRenderedFirstFrame(@Nullable Surface surface) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onRenderedFirstFrame(eventTime, surface); + } + } + + // VideoListener implementation. + + @Override + public final void onRenderedFirstFrame() { + // Do nothing. Already reported in VideoRendererEventListener.onRenderedFirstFrame. + } + + @Override + public final void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onVideoSizeChanged( + eventTime, width, height, unappliedRotationDegrees, pixelWidthHeightRatio); + } + } + + @Override + public void onSurfaceSizeChanged(int width, int height) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onSurfaceSizeChanged(eventTime, width, height); + } + } + + // MediaSourceEventListener implementation. + + @Override + public final void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + mediaPeriodQueueTracker.onMediaPeriodCreated(windowIndex, mediaPeriodId); + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onMediaPeriodCreated(eventTime); + } + } + + @Override + public final void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + if (mediaPeriodQueueTracker.onMediaPeriodReleased(mediaPeriodId)) { + for (AnalyticsListener listener : listeners) { + listener.onMediaPeriodReleased(eventTime); + } + } + } + + @Override + public final void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadStarted(eventTime, loadEventInfo, mediaLoadData); + } + } + + @Override + public final void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadCompleted(eventTime, loadEventInfo, mediaLoadData); + } + } + + @Override + public final void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadCanceled(eventTime, loadEventInfo, mediaLoadData); + } + } + + @Override + public final void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onLoadError(eventTime, loadEventInfo, mediaLoadData, error, wasCanceled); + } + } + + @Override + public final void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { + mediaPeriodQueueTracker.onReadingStarted(mediaPeriodId); + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onReadingStarted(eventTime); + } + } + + @Override + public final void onUpstreamDiscarded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onUpstreamDiscarded(eventTime, mediaLoadData); + } + } + + @Override + public final void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + EventTime eventTime = generateMediaPeriodEventTime(windowIndex, mediaPeriodId); + for (AnalyticsListener listener : listeners) { + listener.onDownstreamFormatChanged(eventTime, mediaLoadData); + } + } + + // Player.EventListener implementation. + + // TODO: Add onFinishedReportingChanges to Player.EventListener to know when a set of simultaneous + // callbacks finished. This helps to assign exactly the same EventTime to all of them instead of + // having slightly different real times. + + @Override + public final void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) { + mediaPeriodQueueTracker.onTimelineChanged(timeline); + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onTimelineChanged(eventTime, reason); + } + } + + @Override + public final void onTracksChanged( + TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onTracksChanged(eventTime, trackGroups, trackSelections); + } + } + + @Override + public final void onLoadingChanged(boolean isLoading) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onLoadingChanged(eventTime, isLoading); + } + } + + @Override + public final void onPlayerStateChanged(boolean playWhenReady, @Player.State int playbackState) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlayerStateChanged(eventTime, playWhenReady, playbackState); + } + } + + @Override + public void onPlaybackSuppressionReasonChanged( + @PlaybackSuppressionReason int playbackSuppressionReason) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlaybackSuppressionReasonChanged(eventTime, playbackSuppressionReason); + } + } + + @Override + public void onIsPlayingChanged(boolean isPlaying) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onIsPlayingChanged(eventTime, isPlaying); + } + } + + @Override + public final void onRepeatModeChanged(@Player.RepeatMode int repeatMode) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onRepeatModeChanged(eventTime, repeatMode); + } + } + + @Override + public final void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onShuffleModeChanged(eventTime, shuffleModeEnabled); + } + } + + @Override + public final void onPlayerError(ExoPlaybackException error) { + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlayerError(eventTime, error); + } + } + + @Override + public final void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + mediaPeriodQueueTracker.onPositionDiscontinuity(reason); + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPositionDiscontinuity(eventTime, reason); + } + } + + @Override + public final void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onPlaybackParametersChanged(eventTime, playbackParameters); + } + } + + @Override + public final void onSeekProcessed() { + if (mediaPeriodQueueTracker.isSeeking()) { + mediaPeriodQueueTracker.onSeekProcessed(); + EventTime eventTime = generatePlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onSeekProcessed(eventTime); + } + } + } + + // BandwidthMeter.Listener implementation. + + @Override + public final void onBandwidthSample(int elapsedMs, long bytes, long bitrate) { + EventTime eventTime = generateLoadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onBandwidthEstimate(eventTime, elapsedMs, bytes, bitrate); + } + } + + // DefaultDrmSessionManager.EventListener implementation. + + @Override + public final void onDrmSessionAcquired() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmSessionAcquired(eventTime); + } + } + + @Override + public final void onDrmKeysLoaded() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmKeysLoaded(eventTime); + } + } + + @Override + public final void onDrmSessionManagerError(Exception error) { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmSessionManagerError(eventTime, error); + } + } + + @Override + public final void onDrmKeysRestored() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmKeysRestored(eventTime); + } + } + + @Override + public final void onDrmKeysRemoved() { + EventTime eventTime = generateReadingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmKeysRemoved(eventTime); + } + } + + @Override + public final void onDrmSessionReleased() { + EventTime eventTime = generateLastReportedPlayingMediaPeriodEventTime(); + for (AnalyticsListener listener : listeners) { + listener.onDrmSessionReleased(eventTime); + } + } + + // Internal methods. + + /** Returns read-only set of registered listeners. */ + protected Set<AnalyticsListener> getListeners() { + return Collections.unmodifiableSet(listeners); + } + + /** Returns a new {@link EventTime} for the specified timeline, window and media period id. */ + @RequiresNonNull("player") + protected EventTime generateEventTime( + Timeline timeline, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + if (timeline.isEmpty()) { + // Ensure media period id is only reported together with a valid timeline. + mediaPeriodId = null; + } + long realtimeMs = clock.elapsedRealtime(); + long eventPositionMs; + boolean isInCurrentWindow = + timeline == player.getCurrentTimeline() && windowIndex == player.getCurrentWindowIndex(); + if (mediaPeriodId != null && mediaPeriodId.isAd()) { + boolean isCurrentAd = + isInCurrentWindow + && player.getCurrentAdGroupIndex() == mediaPeriodId.adGroupIndex + && player.getCurrentAdIndexInAdGroup() == mediaPeriodId.adIndexInAdGroup; + // Assume start position of 0 for future ads. + eventPositionMs = isCurrentAd ? player.getCurrentPosition() : 0; + } else if (isInCurrentWindow) { + eventPositionMs = player.getContentPosition(); + } else { + // Assume default start position for future content windows. If timeline is not available yet, + // assume start position of 0. + eventPositionMs = + timeline.isEmpty() ? 0 : timeline.getWindow(windowIndex, window).getDefaultPositionMs(); + } + return new EventTime( + realtimeMs, + timeline, + windowIndex, + mediaPeriodId, + eventPositionMs, + player.getCurrentPosition(), + player.getTotalBufferedDuration()); + } + + private EventTime generateEventTime(@Nullable MediaPeriodInfo mediaPeriodInfo) { + Assertions.checkNotNull(player); + if (mediaPeriodInfo == null) { + int windowIndex = player.getCurrentWindowIndex(); + mediaPeriodInfo = mediaPeriodQueueTracker.tryResolveWindowIndex(windowIndex); + if (mediaPeriodInfo == null) { + Timeline timeline = player.getCurrentTimeline(); + boolean windowIsInTimeline = windowIndex < timeline.getWindowCount(); + return generateEventTime( + windowIsInTimeline ? timeline : Timeline.EMPTY, windowIndex, /* mediaPeriodId= */ null); + } + } + return generateEventTime( + mediaPeriodInfo.timeline, mediaPeriodInfo.windowIndex, mediaPeriodInfo.mediaPeriodId); + } + + private EventTime generateLastReportedPlayingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getLastReportedPlayingMediaPeriod()); + } + + private EventTime generatePlayingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getPlayingMediaPeriod()); + } + + private EventTime generateReadingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getReadingMediaPeriod()); + } + + private EventTime generateLoadingMediaPeriodEventTime() { + return generateEventTime(mediaPeriodQueueTracker.getLoadingMediaPeriod()); + } + + private EventTime generateMediaPeriodEventTime( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + Assertions.checkNotNull(player); + if (mediaPeriodId != null) { + MediaPeriodInfo mediaPeriodInfo = mediaPeriodQueueTracker.getMediaPeriodInfo(mediaPeriodId); + return mediaPeriodInfo != null + ? generateEventTime(mediaPeriodInfo) + : generateEventTime(Timeline.EMPTY, windowIndex, mediaPeriodId); + } + Timeline timeline = player.getCurrentTimeline(); + boolean windowIsInTimeline = windowIndex < timeline.getWindowCount(); + return generateEventTime( + windowIsInTimeline ? timeline : Timeline.EMPTY, windowIndex, /* mediaPeriodId= */ null); + } + + /** Keeps track of the active media periods and currently playing and reading media period. */ + private static final class MediaPeriodQueueTracker { + + // TODO: Investigate reporting MediaPeriodId in renderer events and adding a listener of queue + // changes, which would hopefully remove the need to track the queue here. + + private final ArrayList<MediaPeriodInfo> mediaPeriodInfoQueue; + private final HashMap<MediaPeriodId, MediaPeriodInfo> mediaPeriodIdToInfo; + private final Period period; + + @Nullable private MediaPeriodInfo lastPlayingMediaPeriod; + @Nullable private MediaPeriodInfo lastReportedPlayingMediaPeriod; + @Nullable private MediaPeriodInfo readingMediaPeriod; + private Timeline timeline; + private boolean isSeeking; + + public MediaPeriodQueueTracker() { + mediaPeriodInfoQueue = new ArrayList<>(); + mediaPeriodIdToInfo = new HashMap<>(); + period = new Period(); + timeline = Timeline.EMPTY; + } + + /** + * Returns the {@link MediaPeriodInfo} of the media period in the front of the queue. This is + * the playing media period unless the player hasn't started playing yet (in which case it is + * the loading media period or null). While the player is seeking or preparing, this method will + * always return null to reflect the uncertainty about the current playing period. May also be + * null, if the timeline is empty or no media period is active yet. + */ + @Nullable + public MediaPeriodInfo getPlayingMediaPeriod() { + return mediaPeriodInfoQueue.isEmpty() || timeline.isEmpty() || isSeeking + ? null + : mediaPeriodInfoQueue.get(0); + } + + /** + * Returns the {@link MediaPeriodInfo} of the currently playing media period. This is the + * publicly reported period which should always match {@link Player#getCurrentPeriodIndex()} + * unless the player is currently seeking or being prepared in which case the previous period is + * reported until the seek or preparation is processed. May be null, if no media period is + * active yet. + */ + @Nullable + public MediaPeriodInfo getLastReportedPlayingMediaPeriod() { + return lastReportedPlayingMediaPeriod; + } + + /** + * Returns the {@link MediaPeriodInfo} of the media period currently being read by the player. + * May be null, if the player is not reading a media period. + */ + @Nullable + public MediaPeriodInfo getReadingMediaPeriod() { + return readingMediaPeriod; + } + + /** + * Returns the {@link MediaPeriodInfo} of the media period at the end of the queue which is + * currently loading or will be the next one loading. May be null, if no media period is active + * yet. + */ + @Nullable + public MediaPeriodInfo getLoadingMediaPeriod() { + return mediaPeriodInfoQueue.isEmpty() + ? null + : mediaPeriodInfoQueue.get(mediaPeriodInfoQueue.size() - 1); + } + + /** Returns the {@link MediaPeriodInfo} for the given {@link MediaPeriodId}. */ + @Nullable + public MediaPeriodInfo getMediaPeriodInfo(MediaPeriodId mediaPeriodId) { + return mediaPeriodIdToInfo.get(mediaPeriodId); + } + + /** Returns whether the player is currently seeking. */ + public boolean isSeeking() { + return isSeeking; + } + + /** + * Tries to find an existing media period info from the specified window index. Only returns a + * non-null media period info if there is a unique, unambiguous match. + */ + @Nullable + public MediaPeriodInfo tryResolveWindowIndex(int windowIndex) { + MediaPeriodInfo match = null; + for (int i = 0; i < mediaPeriodInfoQueue.size(); i++) { + MediaPeriodInfo info = mediaPeriodInfoQueue.get(i); + int periodIndex = timeline.getIndexOfPeriod(info.mediaPeriodId.periodUid); + if (periodIndex != C.INDEX_UNSET + && timeline.getPeriod(periodIndex, period).windowIndex == windowIndex) { + if (match != null) { + // Ambiguous match. + return null; + } + match = info; + } + } + return match; + } + + /** Updates the queue with a reported position discontinuity . */ + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + + /** Updates the queue with a reported timeline change. */ + public void onTimelineChanged(Timeline timeline) { + for (int i = 0; i < mediaPeriodInfoQueue.size(); i++) { + MediaPeriodInfo newMediaPeriodInfo = + updateMediaPeriodInfoToNewTimeline(mediaPeriodInfoQueue.get(i), timeline); + mediaPeriodInfoQueue.set(i, newMediaPeriodInfo); + mediaPeriodIdToInfo.put(newMediaPeriodInfo.mediaPeriodId, newMediaPeriodInfo); + } + if (readingMediaPeriod != null) { + readingMediaPeriod = updateMediaPeriodInfoToNewTimeline(readingMediaPeriod, timeline); + } + this.timeline = timeline; + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + + /** Updates the queue with a reported start of seek. */ + public void onSeekStarted() { + isSeeking = true; + } + + /** Updates the queue with a reported processed seek. */ + public void onSeekProcessed() { + isSeeking = false; + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + + /** Updates the queue with a newly created media period. */ + public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + int periodIndex = timeline.getIndexOfPeriod(mediaPeriodId.periodUid); + boolean isInTimeline = periodIndex != C.INDEX_UNSET; + MediaPeriodInfo mediaPeriodInfo = + new MediaPeriodInfo( + mediaPeriodId, + isInTimeline ? timeline : Timeline.EMPTY, + isInTimeline ? timeline.getPeriod(periodIndex, period).windowIndex : windowIndex); + mediaPeriodInfoQueue.add(mediaPeriodInfo); + mediaPeriodIdToInfo.put(mediaPeriodId, mediaPeriodInfo); + lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); + if (mediaPeriodInfoQueue.size() == 1 && !timeline.isEmpty()) { + lastReportedPlayingMediaPeriod = lastPlayingMediaPeriod; + } + } + + /** + * Updates the queue with a released media period. Returns whether the media period was still in + * the queue. + */ + public boolean onMediaPeriodReleased(MediaPeriodId mediaPeriodId) { + MediaPeriodInfo mediaPeriodInfo = mediaPeriodIdToInfo.remove(mediaPeriodId); + if (mediaPeriodInfo == null) { + // The media period has already been removed from the queue in resetForNewMediaSource(). + return false; + } + mediaPeriodInfoQueue.remove(mediaPeriodInfo); + if (readingMediaPeriod != null && mediaPeriodId.equals(readingMediaPeriod.mediaPeriodId)) { + readingMediaPeriod = mediaPeriodInfoQueue.isEmpty() ? null : mediaPeriodInfoQueue.get(0); + } + if (!mediaPeriodInfoQueue.isEmpty()) { + lastPlayingMediaPeriod = mediaPeriodInfoQueue.get(0); + } + return true; + } + + /** Update the queue with a change in the reading media period. */ + public void onReadingStarted(MediaPeriodId mediaPeriodId) { + readingMediaPeriod = mediaPeriodIdToInfo.get(mediaPeriodId); + } + + private MediaPeriodInfo updateMediaPeriodInfoToNewTimeline( + MediaPeriodInfo info, Timeline newTimeline) { + int newPeriodIndex = newTimeline.getIndexOfPeriod(info.mediaPeriodId.periodUid); + if (newPeriodIndex == C.INDEX_UNSET) { + // Media period is not yet or no longer available in the new timeline. Keep it as it is. + return info; + } + int newWindowIndex = newTimeline.getPeriod(newPeriodIndex, period).windowIndex; + return new MediaPeriodInfo(info.mediaPeriodId, newTimeline, newWindowIndex); + } + } + + /** Information about a media period and its associated timeline. */ + private static final class MediaPeriodInfo { + + /** The {@link MediaPeriodId} of the media period. */ + public final MediaPeriodId mediaPeriodId; + /** + * The {@link Timeline} in which the media period can be found. Or {@link Timeline#EMPTY} if the + * media period is not part of a known timeline yet. + */ + public final Timeline timeline; + /** + * The window index of the media period in the timeline. If the timeline is empty, this is the + * prospective window index. + */ + public final int windowIndex; + + public MediaPeriodInfo(MediaPeriodId mediaPeriodId, Timeline timeline, int windowIndex) { + this.mediaPeriodId = mediaPeriodId; + this.timeline = timeline; + this.windowIndex = windowIndex; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsListener.java new file mode 100644 index 0000000000..a265268c19 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/AnalyticsListener.java @@ -0,0 +1,514 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.view.Surface; +import androidx.annotation.Nullable; +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.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.TimelineChangeReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import java.io.IOException; + +/** + * A listener for analytics events. + * + * <p>All events are recorded with an {@link EventTime} specifying the elapsed real time and media + * time at the time of the event. + * + * <p>All methods have no-op default implementations to allow selective overrides. + */ +public interface AnalyticsListener { + + /** Time information of an event. */ + final class EventTime { + + /** + * Elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} at the time of the + * event, in milliseconds. + */ + public final long realtimeMs; + + /** Timeline at the time of the event. */ + public final Timeline timeline; + + /** + * Window index in the {@link #timeline} this event belongs to, or the prospective window index + * if the timeline is not yet known and empty. + */ + public final int windowIndex; + + /** + * Media period identifier for the media period this event belongs to, or {@code null} if the + * event is not associated with a specific media period. + */ + @Nullable public final MediaPeriodId mediaPeriodId; + + /** + * Position in the window or ad this event belongs to at the time of the event, in milliseconds. + */ + public final long eventPlaybackPositionMs; + + /** + * Position in the current timeline window ({@link Player#getCurrentWindowIndex()}) or the + * currently playing ad at the time of the event, in milliseconds. + */ + public final long currentPlaybackPositionMs; + + /** + * Total buffered duration from {@link #currentPlaybackPositionMs} at the time of the event, in + * milliseconds. This includes pre-buffered data for subsequent ads and windows. + */ + public final long totalBufferedDurationMs; + + /** + * @param realtimeMs Elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} at + * the time of the event, in milliseconds. + * @param timeline Timeline at the time of the event. + * @param windowIndex Window index in the {@link #timeline} this event belongs to, or the + * prospective window index if the timeline is not yet known and empty. + * @param mediaPeriodId Media period identifier for the media period this event belongs to, or + * {@code null} if the event is not associated with a specific media period. + * @param eventPlaybackPositionMs Position in the window or ad this event belongs to at the time + * of the event, in milliseconds. + * @param currentPlaybackPositionMs Position in the current timeline window ({@link + * Player#getCurrentWindowIndex()}) or the currently playing ad at the time of the event, in + * milliseconds. + * @param totalBufferedDurationMs Total buffered duration from {@link + * #currentPlaybackPositionMs} at the time of the event, in milliseconds. This includes + * pre-buffered data for subsequent ads and windows. + */ + public EventTime( + long realtimeMs, + Timeline timeline, + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + long eventPlaybackPositionMs, + long currentPlaybackPositionMs, + long totalBufferedDurationMs) { + this.realtimeMs = realtimeMs; + this.timeline = timeline; + this.windowIndex = windowIndex; + this.mediaPeriodId = mediaPeriodId; + this.eventPlaybackPositionMs = eventPlaybackPositionMs; + this.currentPlaybackPositionMs = currentPlaybackPositionMs; + this.totalBufferedDurationMs = totalBufferedDurationMs; + } + } + + /** + * Called when the player state changed. + * + * @param eventTime The event time. + * @param playWhenReady Whether the playback will proceed when ready. + * @param playbackState The new {@link Player.State playback state}. + */ + default void onPlayerStateChanged( + EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) {} + + /** + * Called when playback suppression reason changed. + * + * @param eventTime The event time. + * @param playbackSuppressionReason The new {@link PlaybackSuppressionReason}. + */ + default void onPlaybackSuppressionReasonChanged( + EventTime eventTime, @PlaybackSuppressionReason int playbackSuppressionReason) {} + + /** + * Called when the player starts or stops playing. + * + * @param eventTime The event time. + * @param isPlaying Whether the player is playing. + */ + default void onIsPlayingChanged(EventTime eventTime, boolean isPlaying) {} + + /** + * Called when the timeline changed. + * + * @param eventTime The event time. + * @param reason The reason for the timeline change. + */ + default void onTimelineChanged(EventTime eventTime, @TimelineChangeReason int reason) {} + + /** + * Called when a position discontinuity occurred. + * + * @param eventTime The event time. + * @param reason The reason for the position discontinuity. + */ + default void onPositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason) {} + + /** + * Called when a seek operation started. + * + * @param eventTime The event time. + */ + default void onSeekStarted(EventTime eventTime) {} + + /** + * Called when a seek operation was processed. + * + * @param eventTime The event time. + */ + default void onSeekProcessed(EventTime eventTime) {} + + /** + * Called when the playback parameters changed. + * + * @param eventTime The event time. + * @param playbackParameters The new playback parameters. + */ + default void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) {} + + /** + * Called when the repeat mode changed. + * + * @param eventTime The event time. + * @param repeatMode The new repeat mode. + */ + default void onRepeatModeChanged(EventTime eventTime, @Player.RepeatMode int repeatMode) {} + + /** + * Called when the shuffle mode changed. + * + * @param eventTime The event time. + * @param shuffleModeEnabled Whether the shuffle mode is enabled. + */ + default void onShuffleModeChanged(EventTime eventTime, boolean shuffleModeEnabled) {} + + /** + * Called when the player starts or stops loading data from a source. + * + * @param eventTime The event time. + * @param isLoading Whether the player is loading. + */ + default void onLoadingChanged(EventTime eventTime, boolean isLoading) {} + + /** + * Called when a fatal player error occurred. + * + * @param eventTime The event time. + * @param error The error. + */ + default void onPlayerError(EventTime eventTime, ExoPlaybackException error) {} + + /** + * Called when the available or selected tracks for the renderers changed. + * + * @param eventTime The event time. + * @param trackGroups The available tracks. May be empty. + * @param trackSelections The track selections for each renderer. May contain null elements. + */ + default void onTracksChanged( + EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {} + + /** + * Called when a media source started loading data. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadStarted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source completed loading data. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCompleted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source canceled loading data. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCanceled( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source loading error occurred. These errors are just for informational + * purposes and the player may recover. + * + * @param eventTime The event time. + * @param loadEventInfo The {@link LoadEventInfo} defining the load event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + * @param error The load error. + * @param wasCanceled Whether the load was canceled as a result of the error. + */ + default void onLoadError( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) {} + + /** + * Called when the downstream format sent to the renderers changed. + * + * @param eventTime The event time. + * @param mediaLoadData The {@link MediaLoadData} defining the newly selected media data. + */ + default void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {} + + /** + * Called when data is removed from the back of a media buffer, typically so that it can be + * re-buffered in a different format. + * + * @param eventTime The event time. + * @param mediaLoadData The {@link MediaLoadData} defining the media being discarded. + */ + default void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) {} + + /** + * Called when a media source created a media period. + * + * @param eventTime The event time. + */ + default void onMediaPeriodCreated(EventTime eventTime) {} + + /** + * Called when a media source released a media period. + * + * @param eventTime The event time. + */ + default void onMediaPeriodReleased(EventTime eventTime) {} + + /** + * Called when the player started reading a media period. + * + * @param eventTime The event time. + */ + default void onReadingStarted(EventTime eventTime) {} + + /** + * Called when the bandwidth estimate for the current data source has been updated. + * + * @param eventTime The event time. + * @param totalLoadTimeMs The total time spend loading this update is based on, in milliseconds. + * @param totalBytesLoaded The total bytes loaded this update is based on. + * @param bitrateEstimate The bandwidth estimate, in bits per second. + */ + default void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {} + + /** + * Called when the output surface size changed. + * + * @param eventTime The event time. + * @param width The surface width in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if the + * video is not rendered onto a surface. + * @param height The surface height in pixels. May be {@link C#LENGTH_UNSET} if unknown, or 0 if + * the video is not rendered onto a surface. + */ + default void onSurfaceSizeChanged(EventTime eventTime, int width, int height) {} + + /** + * Called when there is {@link Metadata} associated with the current playback time. + * + * @param eventTime The event time. + * @param metadata The metadata. + */ + default void onMetadata(EventTime eventTime, Metadata metadata) {} + + /** + * Called when an audio or video decoder has been enabled. + * + * @param eventTime The event time. + * @param trackType The track type of the enabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or + * {@link C#TRACK_TYPE_VIDEO}. + * @param decoderCounters The accumulated event counters associated with this decoder. + */ + default void onDecoderEnabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + + /** + * Called when an audio or video decoder has been initialized. + * + * @param eventTime The event time. + * @param trackType The track type of the initialized decoder. Either {@link C#TRACK_TYPE_AUDIO} + * or {@link C#TRACK_TYPE_VIDEO}. + * @param decoderName The decoder that was created. + * @param initializationDurationMs Time taken to initialize the decoder, in milliseconds. + */ + default void onDecoderInitialized( + EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) {} + + /** + * Called when an audio or video decoder input format changed. + * + * @param eventTime The event time. + * @param trackType The track type of the decoder whose format changed. Either {@link + * C#TRACK_TYPE_AUDIO} or {@link C#TRACK_TYPE_VIDEO}. + * @param format The new input format for the decoder. + */ + default void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) {} + + /** + * Called when an audio or video decoder has been disabled. + * + * @param eventTime The event time. + * @param trackType The track type of the disabled decoder. Either {@link C#TRACK_TYPE_AUDIO} or + * {@link C#TRACK_TYPE_VIDEO}. + * @param decoderCounters The accumulated event counters associated with this decoder. + */ + default void onDecoderDisabled( + EventTime eventTime, int trackType, DecoderCounters decoderCounters) {} + + /** + * Called when the audio session id is set. + * + * @param eventTime The event time. + * @param audioSessionId The audio session id. + */ + default void onAudioSessionId(EventTime eventTime, int audioSessionId) {} + + /** + * Called when the audio attributes change. + * + * @param eventTime The event time. + * @param audioAttributes The audio attributes. + */ + default void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audioAttributes) {} + + /** + * Called when the volume changes. + * + * @param eventTime The event time. + * @param volume The new volume, with 0 being silence and 1 being unity gain. + */ + default void onVolumeChanged(EventTime eventTime, float volume) {} + + /** + * Called when an audio underrun occurred. + * + * @param eventTime The event time. + * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. + * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is + * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, + * as the buffered media can have a variable bitrate so the duration may be unknown. + * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + */ + default void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + + /** + * Called after video frames have been dropped. + * + * @param eventTime The event time. + * @param droppedFrames The number of dropped frames since the last call to this method. + * @param elapsedMs The duration in milliseconds over which the frames were dropped. This duration + * is timed from when the renderer was started or from when dropped frames were last reported + * (whichever was more recent), and not from when the first of the reported drops occurred. + */ + default void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {} + + /** + * Called before a frame is rendered for the first time since setting the surface, and each time + * there's a change in the size or pixel aspect ratio of the video being rendered. + * + * @param eventTime The event time. + * @param width The width of the video. + * @param height The height of the video. + * @param unappliedRotationDegrees For videos that require a rotation, this is the clockwise + * rotation in degrees that the application should apply for the video for it to be rendered + * in the correct orientation. This value will always be zero on API levels 21 and above, + * since the renderer will apply all necessary rotations internally. + * @param pixelWidthHeightRatio The width to height ratio of each pixel. + */ + default void onVideoSizeChanged( + EventTime eventTime, + int width, + int height, + int unappliedRotationDegrees, + float pixelWidthHeightRatio) {} + + /** + * Called when a frame is rendered for the first time since setting the surface, and when a frame + * is rendered for the first time since the renderer was reset. + * + * @param eventTime The event time. + * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if + * the renderer renders to something that isn't a {@link Surface}. + */ + default void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) {} + + /** + * Called each time a drm session is acquired. + * + * @param eventTime The event time. + */ + default void onDrmSessionAcquired(EventTime eventTime) {} + + /** + * Called each time drm keys are loaded. + * + * @param eventTime The event time. + */ + default void onDrmKeysLoaded(EventTime eventTime) {} + + /** + * Called when a drm error occurs. These errors are just for informational purposes and the player + * may recover. + * + * @param eventTime The event time. + * @param error The error. + */ + default void onDrmSessionManagerError(EventTime eventTime, Exception error) {} + + /** + * Called each time offline drm keys are restored. + * + * @param eventTime The event time. + */ + default void onDrmKeysRestored(EventTime eventTime) {} + + /** + * Called each time offline drm keys are removed. + * + * @param eventTime The event time. + */ + default void onDrmKeysRemoved(EventTime eventTime) {} + + /** + * Called each time a drm session is released. + * + * @param eventTime The event time. + */ + default void onDrmSessionReleased(EventTime eventTime) {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultAnalyticsListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultAnalyticsListener.java new file mode 100644 index 0000000000..f56ac3fef0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultAnalyticsListener.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +/** + * @deprecated Use {@link AnalyticsListener} directly for selective overrides as all methods are + * implemented as no-op default methods. + */ +@Deprecated +public abstract class DefaultAnalyticsListener implements AnalyticsListener {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java new file mode 100644 index 0000000000..710934bd36 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/DefaultPlaybackSessionManager.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.util.Base64; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Random; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Default {@link PlaybackSessionManager} which instantiates a new session for each window in the + * timeline and also for each ad within the windows. + * + * <p>Sessions are identified by Base64-encoded, URL-safe, random strings. + */ +public final class DefaultPlaybackSessionManager implements PlaybackSessionManager { + + private static final Random RANDOM = new Random(); + private static final int SESSION_ID_LENGTH = 12; + + private final Timeline.Window window; + private final Timeline.Period period; + private final HashMap<String, SessionDescriptor> sessions; + + private @MonotonicNonNull Listener listener; + private Timeline currentTimeline; + @Nullable private MediaPeriodId currentMediaPeriodId; + @Nullable private String activeSessionId; + + /** Creates session manager. */ + public DefaultPlaybackSessionManager() { + window = new Timeline.Window(); + period = new Timeline.Period(); + sessions = new HashMap<>(); + currentTimeline = Timeline.EMPTY; + } + + @Override + public void setListener(Listener listener) { + this.listener = listener; + } + + @Override + public synchronized String getSessionForMediaPeriodId( + Timeline timeline, MediaPeriodId mediaPeriodId) { + int windowIndex = timeline.getPeriodByUid(mediaPeriodId.periodUid, period).windowIndex; + return getOrAddSession(windowIndex, mediaPeriodId).sessionId; + } + + @Override + public synchronized boolean belongsToSession(EventTime eventTime, String sessionId) { + SessionDescriptor sessionDescriptor = sessions.get(sessionId); + if (sessionDescriptor == null) { + return false; + } + sessionDescriptor.maybeSetWindowSequenceNumber(eventTime.windowIndex, eventTime.mediaPeriodId); + return sessionDescriptor.belongsToSession(eventTime.windowIndex, eventTime.mediaPeriodId); + } + + @Override + public synchronized void updateSessions(EventTime eventTime) { + boolean isObviouslyFinished = + eventTime.mediaPeriodId != null + && currentMediaPeriodId != null + && eventTime.mediaPeriodId.windowSequenceNumber + < currentMediaPeriodId.windowSequenceNumber; + if (!isObviouslyFinished) { + SessionDescriptor descriptor = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (!descriptor.isCreated) { + descriptor.isCreated = true; + Assertions.checkNotNull(listener).onSessionCreated(eventTime, descriptor.sessionId); + if (activeSessionId == null) { + updateActiveSession(eventTime, descriptor); + } + } + } + } + + @Override + public synchronized void handleTimelineUpdate(EventTime eventTime) { + Assertions.checkNotNull(listener); + Timeline previousTimeline = currentTimeline; + currentTimeline = eventTime.timeline; + Iterator<SessionDescriptor> iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + if (!session.tryResolvingToNewTimeline(previousTimeline, currentTimeline)) { + iterator.remove(); + if (session.isCreated) { + if (session.sessionId.equals(activeSessionId)) { + activeSessionId = null; + } + listener.onSessionFinished( + eventTime, session.sessionId, /* automaticTransitionToNextPlayback= */ false); + } + } + } + handlePositionDiscontinuity(eventTime, Player.DISCONTINUITY_REASON_INTERNAL); + } + + @Override + public synchronized void handlePositionDiscontinuity( + EventTime eventTime, @DiscontinuityReason int reason) { + Assertions.checkNotNull(listener); + boolean hasAutomaticTransition = + reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION + || reason == Player.DISCONTINUITY_REASON_AD_INSERTION; + Iterator<SessionDescriptor> iterator = sessions.values().iterator(); + while (iterator.hasNext()) { + SessionDescriptor session = iterator.next(); + if (session.isFinishedAtEventTime(eventTime)) { + iterator.remove(); + if (session.isCreated) { + boolean isRemovingActiveSession = session.sessionId.equals(activeSessionId); + boolean isAutomaticTransition = hasAutomaticTransition && isRemovingActiveSession; + if (isRemovingActiveSession) { + activeSessionId = null; + } + listener.onSessionFinished(eventTime, session.sessionId, isAutomaticTransition); + } + } + } + SessionDescriptor activeSessionDescriptor = + getOrAddSession(eventTime.windowIndex, eventTime.mediaPeriodId); + if (eventTime.mediaPeriodId != null + && eventTime.mediaPeriodId.isAd() + && (currentMediaPeriodId == null + || currentMediaPeriodId.windowSequenceNumber + != eventTime.mediaPeriodId.windowSequenceNumber + || currentMediaPeriodId.adGroupIndex != eventTime.mediaPeriodId.adGroupIndex + || currentMediaPeriodId.adIndexInAdGroup != eventTime.mediaPeriodId.adIndexInAdGroup)) { + // New ad playback started. Find corresponding content session and notify ad playback started. + MediaPeriodId contentMediaPeriodId = + new MediaPeriodId( + eventTime.mediaPeriodId.periodUid, eventTime.mediaPeriodId.windowSequenceNumber); + SessionDescriptor contentSession = + getOrAddSession(eventTime.windowIndex, contentMediaPeriodId); + if (contentSession.isCreated && activeSessionDescriptor.isCreated) { + listener.onAdPlaybackStarted( + eventTime, contentSession.sessionId, activeSessionDescriptor.sessionId); + } + } + updateActiveSession(eventTime, activeSessionDescriptor); + } + + private SessionDescriptor getOrAddSession( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + // There should only be one matching session if mediaPeriodId is non-null. If mediaPeriodId is + // null, there may be multiple matching sessions with different window sequence numbers or + // adMediaPeriodIds. The best match is the one with the smaller window sequence number, and for + // windows with ads, the content session is preferred over ad sessions. + SessionDescriptor bestMatch = null; + long bestMatchWindowSequenceNumber = Long.MAX_VALUE; + for (SessionDescriptor sessionDescriptor : sessions.values()) { + sessionDescriptor.maybeSetWindowSequenceNumber(windowIndex, mediaPeriodId); + if (sessionDescriptor.belongsToSession(windowIndex, mediaPeriodId)) { + long windowSequenceNumber = sessionDescriptor.windowSequenceNumber; + if (windowSequenceNumber == C.INDEX_UNSET + || windowSequenceNumber < bestMatchWindowSequenceNumber) { + bestMatch = sessionDescriptor; + bestMatchWindowSequenceNumber = windowSequenceNumber; + } else if (windowSequenceNumber == bestMatchWindowSequenceNumber + && Util.castNonNull(bestMatch).adMediaPeriodId != null + && sessionDescriptor.adMediaPeriodId != null) { + bestMatch = sessionDescriptor; + } + } + } + if (bestMatch == null) { + String sessionId = generateSessionId(); + bestMatch = new SessionDescriptor(sessionId, windowIndex, mediaPeriodId); + sessions.put(sessionId, bestMatch); + } + return bestMatch; + } + + @RequiresNonNull("listener") + private void updateActiveSession(EventTime eventTime, SessionDescriptor sessionDescriptor) { + currentMediaPeriodId = eventTime.mediaPeriodId; + if (sessionDescriptor.isCreated) { + activeSessionId = sessionDescriptor.sessionId; + if (!sessionDescriptor.isActive) { + sessionDescriptor.isActive = true; + listener.onSessionActive(eventTime, sessionDescriptor.sessionId); + } + } + } + + private static String generateSessionId() { + byte[] randomBytes = new byte[SESSION_ID_LENGTH]; + RANDOM.nextBytes(randomBytes); + return Base64.encodeToString(randomBytes, Base64.URL_SAFE | Base64.NO_WRAP); + } + + /** + * Descriptor for a session. + * + * <p>The session may be described in one of three ways: + * + * <ul> + * <li>A window index with unset window sequence number and a null ad media period id + * <li>A content window with index and sequence number, but a null ad media period id. + * <li>An ad with all values set. + * </ul> + */ + private final class SessionDescriptor { + + private final String sessionId; + + private int windowIndex; + private long windowSequenceNumber; + private @MonotonicNonNull MediaPeriodId adMediaPeriodId; + + private boolean isCreated; + private boolean isActive; + + public SessionDescriptor( + String sessionId, int windowIndex, @Nullable MediaPeriodId mediaPeriodId) { + this.sessionId = sessionId; + this.windowIndex = windowIndex; + this.windowSequenceNumber = + mediaPeriodId == null ? C.INDEX_UNSET : mediaPeriodId.windowSequenceNumber; + if (mediaPeriodId != null && mediaPeriodId.isAd()) { + this.adMediaPeriodId = mediaPeriodId; + } + } + + public boolean tryResolvingToNewTimeline(Timeline oldTimeline, Timeline newTimeline) { + windowIndex = resolveWindowIndexToNewTimeline(oldTimeline, newTimeline, windowIndex); + if (windowIndex == C.INDEX_UNSET) { + return false; + } + if (adMediaPeriodId == null) { + return true; + } + int newPeriodIndex = newTimeline.getIndexOfPeriod(adMediaPeriodId.periodUid); + return newPeriodIndex != C.INDEX_UNSET; + } + + public boolean belongsToSession( + int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { + if (eventMediaPeriodId == null) { + // Events without concrete media period id are for all sessions of the same window. + return eventWindowIndex == windowIndex; + } + if (adMediaPeriodId == null) { + // If this is a content session, only events for content with the same window sequence + // number belong to this session. + return !eventMediaPeriodId.isAd() + && eventMediaPeriodId.windowSequenceNumber == windowSequenceNumber; + } + // If this is an ad session, only events for this ad belong to the session. + return eventMediaPeriodId.windowSequenceNumber == adMediaPeriodId.windowSequenceNumber + && eventMediaPeriodId.adGroupIndex == adMediaPeriodId.adGroupIndex + && eventMediaPeriodId.adIndexInAdGroup == adMediaPeriodId.adIndexInAdGroup; + } + + public void maybeSetWindowSequenceNumber( + int eventWindowIndex, @Nullable MediaPeriodId eventMediaPeriodId) { + if (windowSequenceNumber == C.INDEX_UNSET + && eventWindowIndex == windowIndex + && eventMediaPeriodId != null + && !eventMediaPeriodId.isAd()) { + // Set window sequence number for this session as soon as we have one. + windowSequenceNumber = eventMediaPeriodId.windowSequenceNumber; + } + } + + public boolean isFinishedAtEventTime(EventTime eventTime) { + if (windowSequenceNumber == C.INDEX_UNSET) { + // Sessions with unspecified window sequence number are kept until we know more. + return false; + } + if (eventTime.mediaPeriodId == null) { + // For event times without media period id (e.g. after seek to new window), we only keep + // sessions of this window. + return windowIndex != eventTime.windowIndex; + } + if (eventTime.mediaPeriodId.windowSequenceNumber > windowSequenceNumber) { + // All past window sequence numbers are finished. + return true; + } + if (adMediaPeriodId == null) { + // Current or future content is not finished. + return false; + } + int eventPeriodIndex = eventTime.timeline.getIndexOfPeriod(eventTime.mediaPeriodId.periodUid); + int adPeriodIndex = eventTime.timeline.getIndexOfPeriod(adMediaPeriodId.periodUid); + if (eventTime.mediaPeriodId.windowSequenceNumber < adMediaPeriodId.windowSequenceNumber + || eventPeriodIndex < adPeriodIndex) { + // Ads in future windows or periods are not finished. + return false; + } + if (eventPeriodIndex > adPeriodIndex) { + // Ads in past periods are finished. + return true; + } + if (eventTime.mediaPeriodId.isAd()) { + int eventAdGroup = eventTime.mediaPeriodId.adGroupIndex; + int eventAdIndex = eventTime.mediaPeriodId.adIndexInAdGroup; + // Finished if event is for an ad after this one in the same period. + return eventAdGroup > adMediaPeriodId.adGroupIndex + || (eventAdGroup == adMediaPeriodId.adGroupIndex + && eventAdIndex > adMediaPeriodId.adIndexInAdGroup); + } else { + // Finished if the event is for content after this ad. + return eventTime.mediaPeriodId.nextAdGroupIndex == C.INDEX_UNSET + || eventTime.mediaPeriodId.nextAdGroupIndex > adMediaPeriodId.adGroupIndex; + } + } + + private int resolveWindowIndexToNewTimeline( + Timeline oldTimeline, Timeline newTimeline, int windowIndex) { + if (windowIndex >= oldTimeline.getWindowCount()) { + return windowIndex < newTimeline.getWindowCount() ? windowIndex : C.INDEX_UNSET; + } + oldTimeline.getWindow(windowIndex, window); + for (int periodIndex = window.firstPeriodIndex; + periodIndex <= window.lastPeriodIndex; + periodIndex++) { + Object periodUid = oldTimeline.getUidOfPeriod(periodIndex); + int newPeriodIndex = newTimeline.getIndexOfPeriod(periodUid); + if (newPeriodIndex != C.INDEX_UNSET) { + return newTimeline.getPeriod(newPeriodIndex, period).windowIndex; + } + } + return C.INDEX_UNSET; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java new file mode 100644 index 0000000000..d3c6f7dd20 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackSessionManager.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.DiscontinuityReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; + +/** + * Manager for active playback sessions. + * + * <p>The manager keeps track of the association between window index and/or media period id to + * session identifier. + */ +public interface PlaybackSessionManager { + + /** A listener for session updates. */ + interface Listener { + + /** + * Called when a new session is created as a result of {@link #updateSessions(EventTime)}. + * + * @param eventTime The {@link EventTime} at which the session is created. + * @param sessionId The identifier of the new session. + */ + void onSessionCreated(EventTime eventTime, String sessionId); + + /** + * Called when a session becomes active, i.e. playing in the foreground. + * + * @param eventTime The {@link EventTime} at which the session becomes active. + * @param sessionId The identifier of the session. + */ + void onSessionActive(EventTime eventTime, String sessionId); + + /** + * Called when a session is interrupted by ad playback. + * + * @param eventTime The {@link EventTime} at which the ad playback starts. + * @param contentSessionId The session identifier of the content session. + * @param adSessionId The identifier of the ad session. + */ + void onAdPlaybackStarted(EventTime eventTime, String contentSessionId, String adSessionId); + + /** + * Called when a session is permanently finished. + * + * @param eventTime The {@link EventTime} at which the session finished. + * @param sessionId The identifier of the finished session. + * @param automaticTransitionToNextPlayback Whether the session finished because of an automatic + * transition to the next playback item. + */ + void onSessionFinished( + EventTime eventTime, String sessionId, boolean automaticTransitionToNextPlayback); + } + + /** + * Sets the listener to be notified of session updates. Must be called before the session manager + * is used. + * + * @param listener The {@link Listener} to be notified of session updates. + */ + void setListener(Listener listener); + + /** + * Returns the session identifier for the given media period id. + * + * <p>Note that this will reserve a new session identifier if it doesn't exist yet, but will not + * call any {@link Listener} callbacks. + * + * @param timeline The timeline, {@code mediaPeriodId} is part of. + * @param mediaPeriodId A {@link MediaPeriodId}. + */ + String getSessionForMediaPeriodId(Timeline timeline, MediaPeriodId mediaPeriodId); + + /** + * Returns whether an event time belong to a session. + * + * @param eventTime The {@link EventTime}. + * @param sessionId A session identifier. + * @return Whether the event belongs to the specified session. + */ + boolean belongsToSession(EventTime eventTime, String sessionId); + + /** + * Updates or creates sessions based on a player {@link EventTime}. + * + * @param eventTime The {@link EventTime}. + */ + void updateSessions(EventTime eventTime); + + /** + * Updates the session associations to a new timeline. + * + * @param eventTime The event time with the timeline change. + */ + void handleTimelineUpdate(EventTime eventTime); + + /** + * Handles a position discontinuity. + * + * @param eventTime The event time of the position discontinuity. + * @param reason The {@link DiscontinuityReason}. + */ + void handlePositionDiscontinuity(EventTime eventTime, @DiscontinuityReason int reason); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStats.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStats.java new file mode 100644 index 0000000000..eef0f6e7ce --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStats.java @@ -0,0 +1,980 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime; +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.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Statistics about playbacks. */ +public final class PlaybackStats { + + /** + * State of a playback. One of {@link #PLAYBACK_STATE_NOT_STARTED}, {@link + * #PLAYBACK_STATE_JOINING_FOREGROUND}, {@link #PLAYBACK_STATE_JOINING_BACKGROUND}, {@link + * #PLAYBACK_STATE_PLAYING}, {@link #PLAYBACK_STATE_PAUSED}, {@link #PLAYBACK_STATE_SEEKING}, + * {@link #PLAYBACK_STATE_BUFFERING}, {@link #PLAYBACK_STATE_PAUSED_BUFFERING}, {@link + * #PLAYBACK_STATE_SEEK_BUFFERING}, {@link #PLAYBACK_STATE_SUPPRESSED}, {@link + * #PLAYBACK_STATE_SUPPRESSED_BUFFERING}, {@link #PLAYBACK_STATE_ENDED}, {@link + * #PLAYBACK_STATE_STOPPED}, {@link #PLAYBACK_STATE_FAILED}, {@link + * #PLAYBACK_STATE_INTERRUPTED_BY_AD} or {@link #PLAYBACK_STATE_ABANDONED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE}) + @IntDef({ + PLAYBACK_STATE_NOT_STARTED, + PLAYBACK_STATE_JOINING_BACKGROUND, + PLAYBACK_STATE_JOINING_FOREGROUND, + PLAYBACK_STATE_PLAYING, + PLAYBACK_STATE_PAUSED, + PLAYBACK_STATE_SEEKING, + PLAYBACK_STATE_BUFFERING, + PLAYBACK_STATE_PAUSED_BUFFERING, + PLAYBACK_STATE_SEEK_BUFFERING, + PLAYBACK_STATE_SUPPRESSED, + PLAYBACK_STATE_SUPPRESSED_BUFFERING, + PLAYBACK_STATE_ENDED, + PLAYBACK_STATE_STOPPED, + PLAYBACK_STATE_FAILED, + PLAYBACK_STATE_INTERRUPTED_BY_AD, + PLAYBACK_STATE_ABANDONED + }) + @interface PlaybackState {} + /** Playback has not started (initial state). */ + public static final int PLAYBACK_STATE_NOT_STARTED = 0; + /** Playback is buffering in the background for initial playback start. */ + public static final int PLAYBACK_STATE_JOINING_BACKGROUND = 1; + /** Playback is buffering in the foreground for initial playback start. */ + public static final int PLAYBACK_STATE_JOINING_FOREGROUND = 2; + /** Playback is actively playing. */ + public static final int PLAYBACK_STATE_PLAYING = 3; + /** Playback is paused but ready to play. */ + public static final int PLAYBACK_STATE_PAUSED = 4; + /** Playback is handling a seek. */ + public static final int PLAYBACK_STATE_SEEKING = 5; + /** Playback is buffering to resume active playback. */ + public static final int PLAYBACK_STATE_BUFFERING = 6; + /** Playback is buffering while paused. */ + public static final int PLAYBACK_STATE_PAUSED_BUFFERING = 7; + /** Playback is buffering after a seek. */ + public static final int PLAYBACK_STATE_SEEK_BUFFERING = 8; + /** Playback is suppressed (e.g. due to audio focus loss). */ + public static final int PLAYBACK_STATE_SUPPRESSED = 9; + /** Playback is suppressed (e.g. due to audio focus loss) while buffering to resume a playback. */ + public static final int PLAYBACK_STATE_SUPPRESSED_BUFFERING = 10; + /** Playback has reached the end of the media. */ + public static final int PLAYBACK_STATE_ENDED = 11; + /** Playback is stopped and can be restarted. */ + public static final int PLAYBACK_STATE_STOPPED = 12; + /** Playback is stopped due a fatal error and can be retried. */ + public static final int PLAYBACK_STATE_FAILED = 13; + /** Playback is interrupted by an ad. */ + public static final int PLAYBACK_STATE_INTERRUPTED_BY_AD = 14; + /** Playback is abandoned before reaching the end of the media. */ + public static final int PLAYBACK_STATE_ABANDONED = 15; + /** Total number of playback states. */ + /* package */ static final int PLAYBACK_STATE_COUNT = 16; + + /** Empty playback stats. */ + public static final PlaybackStats EMPTY = merge(/* nothing */ ); + + /** + * Returns the combined {@link PlaybackStats} for all input {@link PlaybackStats}. + * + * <p>Note that the full history of events is not kept as the history only makes sense in the + * context of a single playback. + * + * @param playbackStats Array of {@link PlaybackStats} to combine. + * @return The combined {@link PlaybackStats}. + */ + public static PlaybackStats merge(PlaybackStats... playbackStats) { + int playbackCount = 0; + long[] playbackStateDurationsMs = new long[PLAYBACK_STATE_COUNT]; + long firstReportedTimeMs = C.TIME_UNSET; + int foregroundPlaybackCount = 0; + int abandonedBeforeReadyCount = 0; + int endedCount = 0; + int backgroundJoiningCount = 0; + long totalValidJoinTimeMs = C.TIME_UNSET; + int validJoinTimeCount = 0; + int totalPauseCount = 0; + int totalPauseBufferCount = 0; + int totalSeekCount = 0; + int totalRebufferCount = 0; + long maxRebufferTimeMs = C.TIME_UNSET; + int adPlaybackCount = 0; + long totalVideoFormatHeightTimeMs = 0; + long totalVideoFormatHeightTimeProduct = 0; + long totalVideoFormatBitrateTimeMs = 0; + long totalVideoFormatBitrateTimeProduct = 0; + long totalAudioFormatTimeMs = 0; + long totalAudioFormatBitrateTimeProduct = 0; + int initialVideoFormatHeightCount = 0; + int initialVideoFormatBitrateCount = 0; + int totalInitialVideoFormatHeight = C.LENGTH_UNSET; + long totalInitialVideoFormatBitrate = C.LENGTH_UNSET; + int initialAudioFormatBitrateCount = 0; + long totalInitialAudioFormatBitrate = C.LENGTH_UNSET; + long totalBandwidthTimeMs = 0; + long totalBandwidthBytes = 0; + long totalDroppedFrames = 0; + long totalAudioUnderruns = 0; + int fatalErrorPlaybackCount = 0; + int fatalErrorCount = 0; + int nonFatalErrorCount = 0; + for (PlaybackStats stats : playbackStats) { + playbackCount += stats.playbackCount; + for (int i = 0; i < PLAYBACK_STATE_COUNT; i++) { + playbackStateDurationsMs[i] += stats.playbackStateDurationsMs[i]; + } + if (firstReportedTimeMs == C.TIME_UNSET) { + firstReportedTimeMs = stats.firstReportedTimeMs; + } else if (stats.firstReportedTimeMs != C.TIME_UNSET) { + firstReportedTimeMs = Math.min(firstReportedTimeMs, stats.firstReportedTimeMs); + } + foregroundPlaybackCount += stats.foregroundPlaybackCount; + abandonedBeforeReadyCount += stats.abandonedBeforeReadyCount; + endedCount += stats.endedCount; + backgroundJoiningCount += stats.backgroundJoiningCount; + if (totalValidJoinTimeMs == C.TIME_UNSET) { + totalValidJoinTimeMs = stats.totalValidJoinTimeMs; + } else if (stats.totalValidJoinTimeMs != C.TIME_UNSET) { + totalValidJoinTimeMs += stats.totalValidJoinTimeMs; + } + validJoinTimeCount += stats.validJoinTimeCount; + totalPauseCount += stats.totalPauseCount; + totalPauseBufferCount += stats.totalPauseBufferCount; + totalSeekCount += stats.totalSeekCount; + totalRebufferCount += stats.totalRebufferCount; + if (maxRebufferTimeMs == C.TIME_UNSET) { + maxRebufferTimeMs = stats.maxRebufferTimeMs; + } else if (stats.maxRebufferTimeMs != C.TIME_UNSET) { + maxRebufferTimeMs = Math.max(maxRebufferTimeMs, stats.maxRebufferTimeMs); + } + adPlaybackCount += stats.adPlaybackCount; + totalVideoFormatHeightTimeMs += stats.totalVideoFormatHeightTimeMs; + totalVideoFormatHeightTimeProduct += stats.totalVideoFormatHeightTimeProduct; + totalVideoFormatBitrateTimeMs += stats.totalVideoFormatBitrateTimeMs; + totalVideoFormatBitrateTimeProduct += stats.totalVideoFormatBitrateTimeProduct; + totalAudioFormatTimeMs += stats.totalAudioFormatTimeMs; + totalAudioFormatBitrateTimeProduct += stats.totalAudioFormatBitrateTimeProduct; + initialVideoFormatHeightCount += stats.initialVideoFormatHeightCount; + initialVideoFormatBitrateCount += stats.initialVideoFormatBitrateCount; + if (totalInitialVideoFormatHeight == C.LENGTH_UNSET) { + totalInitialVideoFormatHeight = stats.totalInitialVideoFormatHeight; + } else if (stats.totalInitialVideoFormatHeight != C.LENGTH_UNSET) { + totalInitialVideoFormatHeight += stats.totalInitialVideoFormatHeight; + } + if (totalInitialVideoFormatBitrate == C.LENGTH_UNSET) { + totalInitialVideoFormatBitrate = stats.totalInitialVideoFormatBitrate; + } else if (stats.totalInitialVideoFormatBitrate != C.LENGTH_UNSET) { + totalInitialVideoFormatBitrate += stats.totalInitialVideoFormatBitrate; + } + initialAudioFormatBitrateCount += stats.initialAudioFormatBitrateCount; + if (totalInitialAudioFormatBitrate == C.LENGTH_UNSET) { + totalInitialAudioFormatBitrate = stats.totalInitialAudioFormatBitrate; + } else if (stats.totalInitialAudioFormatBitrate != C.LENGTH_UNSET) { + totalInitialAudioFormatBitrate += stats.totalInitialAudioFormatBitrate; + } + totalBandwidthTimeMs += stats.totalBandwidthTimeMs; + totalBandwidthBytes += stats.totalBandwidthBytes; + totalDroppedFrames += stats.totalDroppedFrames; + totalAudioUnderruns += stats.totalAudioUnderruns; + fatalErrorPlaybackCount += stats.fatalErrorPlaybackCount; + fatalErrorCount += stats.fatalErrorCount; + nonFatalErrorCount += stats.nonFatalErrorCount; + } + return new PlaybackStats( + playbackCount, + playbackStateDurationsMs, + /* playbackStateHistory */ Collections.emptyList(), + /* mediaTimeHistory= */ Collections.emptyList(), + firstReportedTimeMs, + foregroundPlaybackCount, + abandonedBeforeReadyCount, + endedCount, + backgroundJoiningCount, + totalValidJoinTimeMs, + validJoinTimeCount, + totalPauseCount, + totalPauseBufferCount, + totalSeekCount, + totalRebufferCount, + maxRebufferTimeMs, + adPlaybackCount, + /* videoFormatHistory= */ Collections.emptyList(), + /* audioFormatHistory= */ Collections.emptyList(), + totalVideoFormatHeightTimeMs, + totalVideoFormatHeightTimeProduct, + totalVideoFormatBitrateTimeMs, + totalVideoFormatBitrateTimeProduct, + totalAudioFormatTimeMs, + totalAudioFormatBitrateTimeProduct, + initialVideoFormatHeightCount, + initialVideoFormatBitrateCount, + totalInitialVideoFormatHeight, + totalInitialVideoFormatBitrate, + initialAudioFormatBitrateCount, + totalInitialAudioFormatBitrate, + totalBandwidthTimeMs, + totalBandwidthBytes, + totalDroppedFrames, + totalAudioUnderruns, + fatalErrorPlaybackCount, + fatalErrorCount, + nonFatalErrorCount, + /* fatalErrorHistory= */ Collections.emptyList(), + /* nonFatalErrorHistory= */ Collections.emptyList()); + } + + /** The number of individual playbacks for which these stats were collected. */ + public final int playbackCount; + + // Playback state stats. + + /** + * The playback state history as ordered pairs of the {@link EventTime} at which a state became + * active and the {@link PlaybackState}. + */ + public final List<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory; + /** + * The media time history as an ordered list of long[2] arrays with [0] being the realtime as + * returned by {@code SystemClock.elapsedRealtime()} and [1] being the media time at this + * realtime, in milliseconds. + */ + public final List<long[]> mediaTimeHistory; + /** + * The elapsed real-time as returned by {@code SystemClock.elapsedRealtime()} of the first + * reported playback event, or {@link C#TIME_UNSET} if no event has been reported. + */ + public final long firstReportedTimeMs; + /** The number of playbacks which were the active foreground playback at some point. */ + public final int foregroundPlaybackCount; + /** The number of playbacks which were abandoned before they were ready to play. */ + public final int abandonedBeforeReadyCount; + /** The number of playbacks which reached the ended state at least once. */ + public final int endedCount; + /** The number of playbacks which were pre-buffered in the background. */ + public final int backgroundJoiningCount; + /** + * The total time spent joining the playback, in milliseconds, or {@link C#TIME_UNSET} if no valid + * join time could be determined. + * + * <p>Note that this does not include background joining time. A join time may be invalid if the + * playback never reached {@link #PLAYBACK_STATE_PLAYING} or {@link #PLAYBACK_STATE_PAUSED}, or + * joining was interrupted by a seek, stop, or error state. + */ + public final long totalValidJoinTimeMs; + /** + * The number of playbacks with a valid join time as documented in {@link #totalValidJoinTimeMs}. + */ + public final int validJoinTimeCount; + /** The total number of times a playback has been paused. */ + public final int totalPauseCount; + /** The total number of times a playback has been paused while rebuffering. */ + public final int totalPauseBufferCount; + /** + * The total number of times a seek occurred. This includes seeks happening before playback + * resumed after another seek. + */ + public final int totalSeekCount; + /** + * The total number of times a rebuffer occurred. This excludes initial joining and buffering + * after seek. + */ + public final int totalRebufferCount; + /** + * The maximum time spent during a single rebuffer, in milliseconds, or {@link C#TIME_UNSET} if no + * rebuffer occurred. + */ + public final long maxRebufferTimeMs; + /** The number of ad playbacks. */ + public final int adPlaybackCount; + + // Format stats. + + /** + * The video format history as ordered pairs of the {@link EventTime} at which a format started + * being used and the {@link Format}. The {@link Format} may be null if no video format was used. + */ + public final List<Pair<EventTime, @NullableType Format>> videoFormatHistory; + /** + * The audio format history as ordered pairs of the {@link EventTime} at which a format started + * being used and the {@link Format}. The {@link Format} may be null if no audio format was used. + */ + public final List<Pair<EventTime, @NullableType Format>> audioFormatHistory; + /** The total media time for which video format height data is available, in milliseconds. */ + public final long totalVideoFormatHeightTimeMs; + /** + * The accumulated sum of all video format heights, in pixels, times the time the format was used + * for playback, in milliseconds. + */ + public final long totalVideoFormatHeightTimeProduct; + /** The total media time for which video format bitrate data is available, in milliseconds. */ + public final long totalVideoFormatBitrateTimeMs; + /** + * The accumulated sum of all video format bitrates, in bits per second, times the time the format + * was used for playback, in milliseconds. + */ + public final long totalVideoFormatBitrateTimeProduct; + /** The total media time for which audio format data is available, in milliseconds. */ + public final long totalAudioFormatTimeMs; + /** + * The accumulated sum of all audio format bitrates, in bits per second, times the time the format + * was used for playback, in milliseconds. + */ + public final long totalAudioFormatBitrateTimeProduct; + /** The number of playbacks with initial video format height data. */ + public final int initialVideoFormatHeightCount; + /** The number of playbacks with initial video format bitrate data. */ + public final int initialVideoFormatBitrateCount; + /** + * The total initial video format height for all playbacks, in pixels, or {@link C#LENGTH_UNSET} + * if no initial video format data is available. + */ + public final int totalInitialVideoFormatHeight; + /** + * The total initial video format bitrate for all playbacks, in bits per second, or {@link + * C#LENGTH_UNSET} if no initial video format data is available. + */ + public final long totalInitialVideoFormatBitrate; + /** The number of playbacks with initial audio format bitrate data. */ + public final int initialAudioFormatBitrateCount; + /** + * The total initial audio format bitrate for all playbacks, in bits per second, or {@link + * C#LENGTH_UNSET} if no initial audio format data is available. + */ + public final long totalInitialAudioFormatBitrate; + + // Bandwidth stats. + + /** The total time for which bandwidth measurement data is available, in milliseconds. */ + public final long totalBandwidthTimeMs; + /** The total bytes transferred during {@link #totalBandwidthTimeMs}. */ + public final long totalBandwidthBytes; + + // Renderer quality stats. + + /** The total number of dropped video frames. */ + public final long totalDroppedFrames; + /** The total number of audio underruns. */ + public final long totalAudioUnderruns; + + // Error stats. + + /** + * The total number of playback with at least one fatal error. Errors are fatal if playback + * stopped due to this error. + */ + public final int fatalErrorPlaybackCount; + /** The total number of fatal errors. Errors are fatal if playback stopped due to this error. */ + public final int fatalErrorCount; + /** + * The total number of non-fatal errors. Error are non-fatal if playback can recover from the + * error without stopping. + */ + public final int nonFatalErrorCount; + /** + * The history of fatal errors as ordered pairs of the {@link EventTime} at which an error + * occurred and the error. Errors are fatal if playback stopped due to this error. + */ + public final List<Pair<EventTime, Exception>> fatalErrorHistory; + /** + * The history of non-fatal errors as ordered pairs of the {@link EventTime} at which an error + * occurred and the error. Error are non-fatal if playback can recover from the error without + * stopping. + */ + public final List<Pair<EventTime, Exception>> nonFatalErrorHistory; + + private final long[] playbackStateDurationsMs; + + /* package */ PlaybackStats( + int playbackCount, + long[] playbackStateDurationsMs, + List<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory, + List<long[]> mediaTimeHistory, + long firstReportedTimeMs, + int foregroundPlaybackCount, + int abandonedBeforeReadyCount, + int endedCount, + int backgroundJoiningCount, + long totalValidJoinTimeMs, + int validJoinTimeCount, + int totalPauseCount, + int totalPauseBufferCount, + int totalSeekCount, + int totalRebufferCount, + long maxRebufferTimeMs, + int adPlaybackCount, + List<Pair<EventTime, @NullableType Format>> videoFormatHistory, + List<Pair<EventTime, @NullableType Format>> audioFormatHistory, + long totalVideoFormatHeightTimeMs, + long totalVideoFormatHeightTimeProduct, + long totalVideoFormatBitrateTimeMs, + long totalVideoFormatBitrateTimeProduct, + long totalAudioFormatTimeMs, + long totalAudioFormatBitrateTimeProduct, + int initialVideoFormatHeightCount, + int initialVideoFormatBitrateCount, + int totalInitialVideoFormatHeight, + long totalInitialVideoFormatBitrate, + int initialAudioFormatBitrateCount, + long totalInitialAudioFormatBitrate, + long totalBandwidthTimeMs, + long totalBandwidthBytes, + long totalDroppedFrames, + long totalAudioUnderruns, + int fatalErrorPlaybackCount, + int fatalErrorCount, + int nonFatalErrorCount, + List<Pair<EventTime, Exception>> fatalErrorHistory, + List<Pair<EventTime, Exception>> nonFatalErrorHistory) { + this.playbackCount = playbackCount; + this.playbackStateDurationsMs = playbackStateDurationsMs; + this.playbackStateHistory = Collections.unmodifiableList(playbackStateHistory); + this.mediaTimeHistory = Collections.unmodifiableList(mediaTimeHistory); + this.firstReportedTimeMs = firstReportedTimeMs; + this.foregroundPlaybackCount = foregroundPlaybackCount; + this.abandonedBeforeReadyCount = abandonedBeforeReadyCount; + this.endedCount = endedCount; + this.backgroundJoiningCount = backgroundJoiningCount; + this.totalValidJoinTimeMs = totalValidJoinTimeMs; + this.validJoinTimeCount = validJoinTimeCount; + this.totalPauseCount = totalPauseCount; + this.totalPauseBufferCount = totalPauseBufferCount; + this.totalSeekCount = totalSeekCount; + this.totalRebufferCount = totalRebufferCount; + this.maxRebufferTimeMs = maxRebufferTimeMs; + this.adPlaybackCount = adPlaybackCount; + this.videoFormatHistory = Collections.unmodifiableList(videoFormatHistory); + this.audioFormatHistory = Collections.unmodifiableList(audioFormatHistory); + this.totalVideoFormatHeightTimeMs = totalVideoFormatHeightTimeMs; + this.totalVideoFormatHeightTimeProduct = totalVideoFormatHeightTimeProduct; + this.totalVideoFormatBitrateTimeMs = totalVideoFormatBitrateTimeMs; + this.totalVideoFormatBitrateTimeProduct = totalVideoFormatBitrateTimeProduct; + this.totalAudioFormatTimeMs = totalAudioFormatTimeMs; + this.totalAudioFormatBitrateTimeProduct = totalAudioFormatBitrateTimeProduct; + this.initialVideoFormatHeightCount = initialVideoFormatHeightCount; + this.initialVideoFormatBitrateCount = initialVideoFormatBitrateCount; + this.totalInitialVideoFormatHeight = totalInitialVideoFormatHeight; + this.totalInitialVideoFormatBitrate = totalInitialVideoFormatBitrate; + this.initialAudioFormatBitrateCount = initialAudioFormatBitrateCount; + this.totalInitialAudioFormatBitrate = totalInitialAudioFormatBitrate; + this.totalBandwidthTimeMs = totalBandwidthTimeMs; + this.totalBandwidthBytes = totalBandwidthBytes; + this.totalDroppedFrames = totalDroppedFrames; + this.totalAudioUnderruns = totalAudioUnderruns; + this.fatalErrorPlaybackCount = fatalErrorPlaybackCount; + this.fatalErrorCount = fatalErrorCount; + this.nonFatalErrorCount = nonFatalErrorCount; + this.fatalErrorHistory = Collections.unmodifiableList(fatalErrorHistory); + this.nonFatalErrorHistory = Collections.unmodifiableList(nonFatalErrorHistory); + } + + /** + * Returns the total time spent in a given {@link PlaybackState}, in milliseconds. + * + * @param playbackState A {@link PlaybackState}. + * @return Total spent in the given playback state, in milliseconds + */ + public long getPlaybackStateDurationMs(@PlaybackState int playbackState) { + return playbackStateDurationsMs[playbackState]; + } + + /** + * Returns the {@link PlaybackState} at the given time. + * + * @param realtimeMs The time as returned by {@link SystemClock#elapsedRealtime()}. + * @return The {@link PlaybackState} at that time, or {@link #PLAYBACK_STATE_NOT_STARTED} if the + * given time is before the first known playback state in the history. + */ + public @PlaybackState int getPlaybackStateAtTime(long realtimeMs) { + @PlaybackState int state = PLAYBACK_STATE_NOT_STARTED; + for (Pair<EventTime, @PlaybackState Integer> timeAndState : playbackStateHistory) { + if (timeAndState.first.realtimeMs > realtimeMs) { + break; + } + state = timeAndState.second; + } + return state; + } + + /** + * Returns the estimated media time at the given realtime, in milliseconds, or {@link + * C#TIME_UNSET} if the media time history is unknown. + * + * @param realtimeMs The realtime as returned by {@link SystemClock#elapsedRealtime()}. + * @return The estimated media time in milliseconds at this realtime, {@link C#TIME_UNSET} if no + * estimate can be given. + */ + public long getMediaTimeMsAtRealtimeMs(long realtimeMs) { + if (mediaTimeHistory.isEmpty()) { + return C.TIME_UNSET; + } + int nextIndex = 0; + while (nextIndex < mediaTimeHistory.size() + && mediaTimeHistory.get(nextIndex)[0] <= realtimeMs) { + nextIndex++; + } + if (nextIndex == 0) { + return mediaTimeHistory.get(0)[1]; + } + if (nextIndex == mediaTimeHistory.size()) { + return mediaTimeHistory.get(mediaTimeHistory.size() - 1)[1]; + } + long prevRealtimeMs = mediaTimeHistory.get(nextIndex - 1)[0]; + long prevMediaTimeMs = mediaTimeHistory.get(nextIndex - 1)[1]; + long nextRealtimeMs = mediaTimeHistory.get(nextIndex)[0]; + long nextMediaTimeMs = mediaTimeHistory.get(nextIndex)[1]; + long realtimeDurationMs = nextRealtimeMs - prevRealtimeMs; + if (realtimeDurationMs == 0) { + return prevMediaTimeMs; + } + float fraction = (float) (realtimeMs - prevRealtimeMs) / realtimeDurationMs; + return prevMediaTimeMs + (long) ((nextMediaTimeMs - prevMediaTimeMs) * fraction); + } + + /** + * Returns the mean time spent joining the playback, in milliseconds, or {@link C#TIME_UNSET} if + * no valid join time is available. Only includes playbacks with valid join times as documented in + * {@link #totalValidJoinTimeMs}. + */ + public long getMeanJoinTimeMs() { + return validJoinTimeCount == 0 ? C.TIME_UNSET : totalValidJoinTimeMs / validJoinTimeCount; + } + + /** + * Returns the total time spent joining the playback in foreground, in milliseconds. This does + * include invalid join times where the playback never reached {@link #PLAYBACK_STATE_PLAYING} or + * {@link #PLAYBACK_STATE_PAUSED}, or joining was interrupted by a seek, stop, or error state. + */ + public long getTotalJoinTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_JOINING_FOREGROUND); + } + + /** Returns the total time spent actively playing, in milliseconds. */ + public long getTotalPlayTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_PLAYING); + } + + /** + * Returns the mean time spent actively playing per foreground playback, in milliseconds, or + * {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanPlayTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalPlayTimeMs() / foregroundPlaybackCount; + } + + /** Returns the total time spent in a paused state, in milliseconds. */ + public long getTotalPausedTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_PAUSED) + + getPlaybackStateDurationMs(PLAYBACK_STATE_PAUSED_BUFFERING); + } + + /** + * Returns the mean time spent in a paused state per foreground playback, in milliseconds, or + * {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanPausedTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalPausedTimeMs() / foregroundPlaybackCount; + } + + /** + * Returns the total time spent rebuffering, in milliseconds. This excludes initial join times, + * buffer times after a seek and buffering while paused. + */ + public long getTotalRebufferTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING); + } + + /** + * Returns the mean time spent rebuffering per foreground playback, in milliseconds, or {@link + * C#TIME_UNSET} if no playback has been in foreground. This excludes initial join times, buffer + * times after a seek and buffering while paused. + */ + public long getMeanRebufferTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalRebufferTimeMs() / foregroundPlaybackCount; + } + + /** + * Returns the mean time spent during a single rebuffer, in milliseconds, or {@link C#TIME_UNSET} + * if no rebuffer was recorded. This excludes initial join times and buffer times after a seek. + */ + public long getMeanSingleRebufferTimeMs() { + return totalRebufferCount == 0 + ? C.TIME_UNSET + : (getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_PAUSED_BUFFERING)) + / totalRebufferCount; + } + + /** + * Returns the total time spent from the start of a seek until playback is ready again, in + * milliseconds. + */ + public long getTotalSeekTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING); + } + + /** + * Returns the mean time spent per foreground playback from the start of a seek until playback is + * ready again, in milliseconds, or {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanSeekTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalSeekTimeMs() / foregroundPlaybackCount; + } + + /** + * Returns the mean time spent from the start of a single seek until playback is ready again, in + * milliseconds, or {@link C#TIME_UNSET} if no seek occurred. + */ + public long getMeanSingleSeekTimeMs() { + return totalSeekCount == 0 ? C.TIME_UNSET : getTotalSeekTimeMs() / totalSeekCount; + } + + /** + * Returns the total time spent actively waiting for playback, in milliseconds. This includes all + * join times, rebuffer times and seek times, but excludes times without user intention to play, + * e.g. all paused states. + */ + public long getTotalWaitTimeMs() { + return getPlaybackStateDurationMs(PLAYBACK_STATE_JOINING_FOREGROUND) + + getPlaybackStateDurationMs(PLAYBACK_STATE_BUFFERING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEKING) + + getPlaybackStateDurationMs(PLAYBACK_STATE_SEEK_BUFFERING); + } + + /** + * Returns the mean time spent actively waiting for playback per foreground playback, in + * milliseconds, or {@link C#TIME_UNSET} if no playback has been in foreground. This includes all + * join times, rebuffer times and seek times, but excludes times without user intention to play, + * e.g. all paused states. + */ + public long getMeanWaitTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalWaitTimeMs() / foregroundPlaybackCount; + } + + /** Returns the total time spent playing or actively waiting for playback, in milliseconds. */ + public long getTotalPlayAndWaitTimeMs() { + return getTotalPlayTimeMs() + getTotalWaitTimeMs(); + } + + /** + * Returns the mean time spent playing or actively waiting for playback per foreground playback, + * in milliseconds, or {@link C#TIME_UNSET} if no playback has been in foreground. + */ + public long getMeanPlayAndWaitTimeMs() { + return foregroundPlaybackCount == 0 + ? C.TIME_UNSET + : getTotalPlayAndWaitTimeMs() / foregroundPlaybackCount; + } + + /** Returns the total time covered by any playback state, in milliseconds. */ + public long getTotalElapsedTimeMs() { + long totalTimeMs = 0; + for (int i = 0; i < PLAYBACK_STATE_COUNT; i++) { + totalTimeMs += playbackStateDurationsMs[i]; + } + return totalTimeMs; + } + + /** + * Returns the mean time covered by any playback state per playback, in milliseconds, or {@link + * C#TIME_UNSET} if no playback was recorded. + */ + public long getMeanElapsedTimeMs() { + return playbackCount == 0 ? C.TIME_UNSET : getTotalElapsedTimeMs() / playbackCount; + } + + /** + * Returns the ratio of foreground playbacks which were abandoned before they were ready to play, + * or {@code 0.0} if no playback has been in foreground. + */ + public float getAbandonedBeforeReadyRatio() { + int foregroundAbandonedBeforeReady = + abandonedBeforeReadyCount - (playbackCount - foregroundPlaybackCount); + return foregroundPlaybackCount == 0 + ? 0f + : (float) foregroundAbandonedBeforeReady / foregroundPlaybackCount; + } + + /** + * Returns the ratio of foreground playbacks which reached the ended state at least once, or + * {@code 0.0} if no playback has been in foreground. + */ + public float getEndedRatio() { + return foregroundPlaybackCount == 0 ? 0f : (float) endedCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a playback has been paused per foreground playback, or {@code + * 0.0} if no playback has been in foreground. + */ + public float getMeanPauseCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) totalPauseCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a playback has been paused while rebuffering per foreground + * playback, or {@code 0.0} if no playback has been in foreground. + */ + public float getMeanPauseBufferCount() { + return foregroundPlaybackCount == 0 + ? 0f + : (float) totalPauseBufferCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a seek occurred per foreground playback, or {@code 0.0} if no + * playback has been in foreground. This includes seeks happening before playback resumed after + * another seek. + */ + public float getMeanSeekCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) totalSeekCount / foregroundPlaybackCount; + } + + /** + * Returns the mean number of times a rebuffer occurred per foreground playback, or {@code 0.0} if + * no playback has been in foreground. This excludes initial joining and buffering after seek. + */ + public float getMeanRebufferCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) totalRebufferCount / foregroundPlaybackCount; + } + + /** + * Returns the ratio of wait times to the total time spent playing and waiting, or {@code 0.0} if + * no time was spend playing or waiting. This is equivalent to {@link #getTotalWaitTimeMs()} / + * {@link #getTotalPlayAndWaitTimeMs()} and also to {@link #getJoinTimeRatio()} + {@link + * #getRebufferTimeRatio()} + {@link #getSeekTimeRatio()}. + */ + public float getWaitTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalWaitTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the ratio of foreground join time to the total time spent playing and waiting, or + * {@code 0.0} if no time was spend playing or waiting. This is equivalent to {@link + * #getTotalJoinTimeMs()} / {@link #getTotalPlayAndWaitTimeMs()}. + */ + public float getJoinTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalJoinTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the ratio of rebuffer time to the total time spent playing and waiting, or {@code 0.0} + * if no time was spend playing or waiting. This is equivalent to {@link + * #getTotalRebufferTimeMs()} / {@link #getTotalPlayAndWaitTimeMs()}. + */ + public float getRebufferTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalRebufferTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the ratio of seek time to the total time spent playing and waiting, or {@code 0.0} if + * no time was spend playing or waiting. This is equivalent to {@link #getTotalSeekTimeMs()} / + * {@link #getTotalPlayAndWaitTimeMs()}. + */ + public float getSeekTimeRatio() { + long playAndWaitTimeMs = getTotalPlayAndWaitTimeMs(); + return playAndWaitTimeMs == 0 ? 0f : (float) getTotalSeekTimeMs() / playAndWaitTimeMs; + } + + /** + * Returns the rate of rebuffer events, in rebuffers per play time second, or {@code 0.0} if no + * time was spend playing. This is equivalent to 1.0 / {@link #getMeanTimeBetweenRebuffers()}. + */ + public float getRebufferRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * totalRebufferCount / playTimeMs; + } + + /** + * Returns the mean play time between rebuffer events, in seconds. This is equivalent to 1.0 / + * {@link #getRebufferRate()}. Note that this may return {@link Float#POSITIVE_INFINITY}. + */ + public float getMeanTimeBetweenRebuffers() { + return 1f / getRebufferRate(); + } + + /** + * Returns the mean initial video format height, in pixels, or {@link C#LENGTH_UNSET} if no video + * format data is available. + */ + public int getMeanInitialVideoFormatHeight() { + return initialVideoFormatHeightCount == 0 + ? C.LENGTH_UNSET + : totalInitialVideoFormatHeight / initialVideoFormatHeightCount; + } + + /** + * Returns the mean initial video format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if + * no video format data is available. + */ + public int getMeanInitialVideoFormatBitrate() { + return initialVideoFormatBitrateCount == 0 + ? C.LENGTH_UNSET + : (int) (totalInitialVideoFormatBitrate / initialVideoFormatBitrateCount); + } + + /** + * Returns the mean initial audio format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if + * no audio format data is available. + */ + public int getMeanInitialAudioFormatBitrate() { + return initialAudioFormatBitrateCount == 0 + ? C.LENGTH_UNSET + : (int) (totalInitialAudioFormatBitrate / initialAudioFormatBitrateCount); + } + + /** + * Returns the mean video format height, in pixels, or {@link C#LENGTH_UNSET} if no video format + * data is available. This is a weighted average taking the time the format was used for playback + * into account. + */ + public int getMeanVideoFormatHeight() { + return totalVideoFormatHeightTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalVideoFormatHeightTimeProduct / totalVideoFormatHeightTimeMs); + } + + /** + * Returns the mean video format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if no + * video format data is available. This is a weighted average taking the time the format was used + * for playback into account. + */ + public int getMeanVideoFormatBitrate() { + return totalVideoFormatBitrateTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalVideoFormatBitrateTimeProduct / totalVideoFormatBitrateTimeMs); + } + + /** + * Returns the mean audio format bitrate, in bits per second, or {@link C#LENGTH_UNSET} if no + * audio format data is available. This is a weighted average taking the time the format was used + * for playback into account. + */ + public int getMeanAudioFormatBitrate() { + return totalAudioFormatTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalAudioFormatBitrateTimeProduct / totalAudioFormatTimeMs); + } + + /** + * Returns the mean network bandwidth based on transfer measurements, in bits per second, or + * {@link C#LENGTH_UNSET} if no transfer data is available. + */ + public int getMeanBandwidth() { + return totalBandwidthTimeMs == 0 + ? C.LENGTH_UNSET + : (int) (totalBandwidthBytes * 8000 / totalBandwidthTimeMs); + } + + /** + * Returns the mean rate at which video frames are dropped, in dropped frames per play time + * second, or {@code 0.0} if no time was spent playing. + */ + public float getDroppedFramesRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * totalDroppedFrames / playTimeMs; + } + + /** + * Returns the mean rate at which audio underruns occurred, in underruns per play time second, or + * {@code 0.0} if no time was spent playing. + */ + public float getAudioUnderrunRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * totalAudioUnderruns / playTimeMs; + } + + /** + * Returns the ratio of foreground playbacks which experienced fatal errors, or {@code 0.0} if no + * playback has been in foreground. + */ + public float getFatalErrorRatio() { + return foregroundPlaybackCount == 0 + ? 0f + : (float) fatalErrorPlaybackCount / foregroundPlaybackCount; + } + + /** + * Returns the rate of fatal errors, in errors per play time second, or {@code 0.0} if no time was + * spend playing. This is equivalent to 1.0 / {@link #getMeanTimeBetweenFatalErrors()}. + */ + public float getFatalErrorRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * fatalErrorCount / playTimeMs; + } + + /** + * Returns the mean play time between fatal errors, in seconds. This is equivalent to 1.0 / {@link + * #getFatalErrorRate()}. Note that this may return {@link Float#POSITIVE_INFINITY}. + */ + public float getMeanTimeBetweenFatalErrors() { + return 1f / getFatalErrorRate(); + } + + /** + * Returns the mean number of non-fatal errors per foreground playback, or {@code 0.0} if no + * playback has been in foreground. + */ + public float getMeanNonFatalErrorCount() { + return foregroundPlaybackCount == 0 ? 0f : (float) nonFatalErrorCount / foregroundPlaybackCount; + } + + /** + * Returns the rate of non-fatal errors, in errors per play time second, or {@code 0.0} if no time + * was spend playing. This is equivalent to 1.0 / {@link #getMeanTimeBetweenNonFatalErrors()}. + */ + public float getNonFatalErrorRate() { + long playTimeMs = getTotalPlayTimeMs(); + return playTimeMs == 0 ? 0f : 1000f * nonFatalErrorCount / playTimeMs; + } + + /** + * Returns the mean play time between non-fatal errors, in seconds. This is equivalent to 1.0 / + * {@link #getNonFatalErrorRate()}. Note that this may return {@link Float#POSITIVE_INFINITY}. + */ + public float getMeanTimeBetweenNonFatalErrors() { + return 1f / getNonFatalErrorRate(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java new file mode 100644 index 0000000000..058a3a97c1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/PlaybackStatsListener.java @@ -0,0 +1,1059 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.Nullable; +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.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Period; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.PlaybackStats.PlaybackState; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +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.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +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.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * {@link AnalyticsListener} to gather {@link PlaybackStats} from the player. + * + * <p>For accurate measurements, the listener should be added to the player before loading media, + * i.e., {@link Player#getPlaybackState()} should be {@link Player#STATE_IDLE}. + * + * <p>Playback stats are gathered separately for each playback session, i.e. each window in the + * {@link Timeline} and each single ad. + */ +public final class PlaybackStatsListener + implements AnalyticsListener, PlaybackSessionManager.Listener { + + /** A listener for {@link PlaybackStats} updates. */ + public interface Callback { + + /** + * Called when a playback session ends and its {@link PlaybackStats} are ready. + * + * @param eventTime The {@link EventTime} at which the playback session started. Can be used to + * identify the playback session. + * @param playbackStats The {@link PlaybackStats} for the ended playback session. + */ + void onPlaybackStatsReady(EventTime eventTime, PlaybackStats playbackStats); + } + + private final PlaybackSessionManager sessionManager; + private final Map<String, PlaybackStatsTracker> playbackStatsTrackers; + private final Map<String, EventTime> sessionStartEventTimes; + @Nullable private final Callback callback; + private final boolean keepHistory; + private final Period period; + + private PlaybackStats finishedPlaybackStats; + @Nullable private String activeContentPlayback; + @Nullable private String activeAdPlayback; + private boolean playWhenReady; + @Player.State private int playbackState; + private boolean isSuppressed; + private float playbackSpeed; + + /** + * Creates listener for playback stats. + * + * @param keepHistory Whether the reported {@link PlaybackStats} should keep the full history of + * events. + * @param callback An optional callback for finished {@link PlaybackStats}. + */ + public PlaybackStatsListener(boolean keepHistory, @Nullable Callback callback) { + this.callback = callback; + this.keepHistory = keepHistory; + sessionManager = new DefaultPlaybackSessionManager(); + playbackStatsTrackers = new HashMap<>(); + sessionStartEventTimes = new HashMap<>(); + finishedPlaybackStats = PlaybackStats.EMPTY; + playWhenReady = false; + playbackState = Player.STATE_IDLE; + playbackSpeed = 1f; + period = new Period(); + sessionManager.setListener(this); + } + + /** + * Returns the combined {@link PlaybackStats} for all playback sessions this listener was and is + * listening to. + * + * <p>Note that these {@link PlaybackStats} will not contain the full history of events. + * + * @return The combined {@link PlaybackStats} for all playback sessions. + */ + public PlaybackStats getCombinedPlaybackStats() { + PlaybackStats[] allPendingPlaybackStats = new PlaybackStats[playbackStatsTrackers.size() + 1]; + allPendingPlaybackStats[0] = finishedPlaybackStats; + int index = 1; + for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { + allPendingPlaybackStats[index++] = tracker.build(/* isFinal= */ false); + } + return PlaybackStats.merge(allPendingPlaybackStats); + } + + /** + * Returns the {@link PlaybackStats} for the currently playback session, or null if no session is + * active. + * + * @return {@link PlaybackStats} for the current playback session. + */ + @Nullable + public PlaybackStats getPlaybackStats() { + PlaybackStatsTracker activeStatsTracker = + activeAdPlayback != null + ? playbackStatsTrackers.get(activeAdPlayback) + : activeContentPlayback != null + ? playbackStatsTrackers.get(activeContentPlayback) + : null; + return activeStatsTracker == null ? null : activeStatsTracker.build(/* isFinal= */ false); + } + + /** + * Finishes all pending playback sessions. Should be called when the listener is removed from the + * player or when the player is released. + */ + public void finishAllSessions() { + // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with + // an actual EventTime. Should also simplify other cases where the listener needs to be released + // separately from the player. + HashMap<String, PlaybackStatsTracker> trackerCopy = new HashMap<>(playbackStatsTrackers); + EventTime dummyEventTime = + new EventTime( + SystemClock.elapsedRealtime(), + Timeline.EMPTY, + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* eventPlaybackPositionMs= */ 0, + /* currentPlaybackPositionMs= */ 0, + /* totalBufferedDurationMs= */ 0); + for (String session : trackerCopy.keySet()) { + onSessionFinished(dummyEventTime, session, /* automaticTransition= */ false); + } + } + + // PlaybackSessionManager.Listener implementation. + + @Override + public void onSessionCreated(EventTime eventTime, String session) { + PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime); + tracker.onPlayerStateChanged( + eventTime, playWhenReady, playbackState, /* belongsToPlayback= */ true); + tracker.onIsSuppressedChanged(eventTime, isSuppressed, /* belongsToPlayback= */ true); + tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); + playbackStatsTrackers.put(session, tracker); + sessionStartEventTimes.put(session, eventTime); + } + + @Override + public void onSessionActive(EventTime eventTime, String session) { + Assertions.checkNotNull(playbackStatsTrackers.get(session)).onForeground(eventTime); + if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { + activeAdPlayback = session; + } else { + activeContentPlayback = session; + } + } + + @Override + public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) { + Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd()); + long contentPositionUs = + eventTime + .timeline + .getPeriodByUid(eventTime.mediaPeriodId.periodUid, period) + .getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex); + EventTime contentEventTime = + new EventTime( + eventTime.realtimeMs, + eventTime.timeline, + eventTime.windowIndex, + new MediaPeriodId( + eventTime.mediaPeriodId.periodUid, + eventTime.mediaPeriodId.windowSequenceNumber, + eventTime.mediaPeriodId.adGroupIndex), + /* eventPlaybackPositionMs= */ C.usToMs(contentPositionUs), + eventTime.currentPlaybackPositionMs, + eventTime.totalBufferedDurationMs); + Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) + .onInterruptedByAd(contentEventTime); + } + + @Override + public void onSessionFinished(EventTime eventTime, String session, boolean automaticTransition) { + if (session.equals(activeAdPlayback)) { + activeAdPlayback = null; + } else if (session.equals(activeContentPlayback)) { + activeContentPlayback = null; + } + PlaybackStatsTracker tracker = Assertions.checkNotNull(playbackStatsTrackers.remove(session)); + EventTime startEventTime = Assertions.checkNotNull(sessionStartEventTimes.remove(session)); + if (automaticTransition) { + // Simulate ENDED state to record natural ending of playback. + tracker.onPlayerStateChanged( + eventTime, /* playWhenReady= */ true, Player.STATE_ENDED, /* belongsToPlayback= */ false); + } + tracker.onFinished(eventTime); + PlaybackStats playbackStats = tracker.build(/* isFinal= */ true); + finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats); + if (callback != null) { + callback.onPlaybackStatsReady(startEventTime, playbackStats); + } + } + + // AnalyticsListener implementation. + + @Override + public void onPlayerStateChanged( + EventTime eventTime, boolean playWhenReady, @Player.State int playbackState) { + this.playWhenReady = playWhenReady; + this.playbackState = playbackState; + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); + playbackStatsTrackers + .get(session) + .onPlayerStateChanged(eventTime, playWhenReady, playbackState, belongsToPlayback); + } + } + + @Override + public void onPlaybackSuppressionReasonChanged( + EventTime eventTime, int playbackSuppressionReason) { + isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE; + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session); + playbackStatsTrackers + .get(session) + .onIsSuppressedChanged(eventTime, isSuppressed, belongsToPlayback); + } + } + + @Override + public void onTimelineChanged(EventTime eventTime, int reason) { + sessionManager.handleTimelineUpdate(eventTime); + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); + } + } + } + + @Override + public void onPositionDiscontinuity(EventTime eventTime, int reason) { + sessionManager.handlePositionDiscontinuity(eventTime, reason); + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime); + } + } + } + + @Override + public void onSeekStarted(EventTime eventTime) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onSeekStarted(eventTime); + } + } + } + + @Override + public void onSeekProcessed(EventTime eventTime) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onSeekProcessed(eventTime); + } + } + } + + @Override + public void onPlayerError(EventTime eventTime, ExoPlaybackException error) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onFatalError(eventTime, error); + } + } + } + + @Override + public void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) { + playbackSpeed = playbackParameters.speed; + sessionManager.updateSessions(eventTime); + for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { + tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed); + } + } + + @Override + public void onTracksChanged( + EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections); + } + } + } + + @Override + public void onLoadStarted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onLoadStarted(eventTime); + } + } + } + + @Override + public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData); + } + } + } + + @Override + public void onVideoSizeChanged( + EventTime eventTime, + int width, + int height, + int unappliedRotationDegrees, + float pixelWidthHeightRatio) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height); + } + } + } + + @Override + public void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded); + } + } + } + + @Override + public void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onAudioUnderrun(); + } + } + } + + @Override + public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames); + } + } + } + + @Override + public void onLoadError( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); + } + } + } + + @Override + public void onDrmSessionManagerError(EventTime eventTime, Exception error) { + sessionManager.updateSessions(eventTime); + for (String session : playbackStatsTrackers.keySet()) { + if (sessionManager.belongsToSession(eventTime, session)) { + playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); + } + } + } + + /** Tracker for playback stats of a single playback. */ + private static final class PlaybackStatsTracker { + + // Final stats. + private final boolean keepHistory; + private final long[] playbackStateDurationsMs; + private final List<Pair<EventTime, @PlaybackState Integer>> playbackStateHistory; + private final List<long[]> mediaTimeHistory; + private final List<Pair<EventTime, @NullableType Format>> videoFormatHistory; + private final List<Pair<EventTime, @NullableType Format>> audioFormatHistory; + private final List<Pair<EventTime, Exception>> fatalErrorHistory; + private final List<Pair<EventTime, Exception>> nonFatalErrorHistory; + private final boolean isAd; + + private long firstReportedTimeMs; + private boolean hasBeenReady; + private boolean hasEnded; + private boolean isJoinTimeInvalid; + private int pauseCount; + private int pauseBufferCount; + private int seekCount; + private int rebufferCount; + private long maxRebufferTimeMs; + private int initialVideoFormatHeight; + private long initialVideoFormatBitrate; + private long initialAudioFormatBitrate; + private long videoFormatHeightTimeMs; + private long videoFormatHeightTimeProduct; + private long videoFormatBitrateTimeMs; + private long videoFormatBitrateTimeProduct; + private long audioFormatTimeMs; + private long audioFormatBitrateTimeProduct; + private long bandwidthTimeMs; + private long bandwidthBytes; + private long droppedFrames; + private long audioUnderruns; + private int fatalErrorCount; + private int nonFatalErrorCount; + + // Current player state tracking. + private @PlaybackState int currentPlaybackState; + private long currentPlaybackStateStartTimeMs; + private boolean isSeeking; + private boolean isForeground; + private boolean isInterruptedByAd; + private boolean isFinished; + private boolean playWhenReady; + @Player.State private int playerPlaybackState; + private boolean isSuppressed; + private boolean hasFatalError; + private boolean startedLoading; + private long lastRebufferStartTimeMs; + @Nullable private Format currentVideoFormat; + @Nullable private Format currentAudioFormat; + private long lastVideoFormatStartTimeMs; + private long lastAudioFormatStartTimeMs; + private float currentPlaybackSpeed; + + /** + * Creates a tracker for playback stats. + * + * @param keepHistory Whether to keep a full history of events. + * @param startTime The {@link EventTime} at which the playback stats start. + */ + public PlaybackStatsTracker(boolean keepHistory, EventTime startTime) { + this.keepHistory = keepHistory; + playbackStateDurationsMs = new long[PlaybackStats.PLAYBACK_STATE_COUNT]; + playbackStateHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); + mediaTimeHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); + videoFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); + audioFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); + fatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); + nonFatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); + currentPlaybackState = PlaybackStats.PLAYBACK_STATE_NOT_STARTED; + currentPlaybackStateStartTimeMs = startTime.realtimeMs; + playerPlaybackState = Player.STATE_IDLE; + firstReportedTimeMs = C.TIME_UNSET; + maxRebufferTimeMs = C.TIME_UNSET; + isAd = startTime.mediaPeriodId != null && startTime.mediaPeriodId.isAd(); + initialAudioFormatBitrate = C.LENGTH_UNSET; + initialVideoFormatBitrate = C.LENGTH_UNSET; + initialVideoFormatHeight = C.LENGTH_UNSET; + currentPlaybackSpeed = 1f; + } + + /** + * Notifies the tracker of a player state change event, including all player state changes while + * the playback is not in the foreground. + * + * @param eventTime The {@link EventTime}. + * @param playWhenReady Whether the playback will proceed when ready. + * @param playbackState The current {@link Player.State}. + * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback. + */ + public void onPlayerStateChanged( + EventTime eventTime, + boolean playWhenReady, + @Player.State int playbackState, + boolean belongsToPlayback) { + this.playWhenReady = playWhenReady; + playerPlaybackState = playbackState; + if (playbackState != Player.STATE_IDLE) { + hasFatalError = false; + } + if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { + isInterruptedByAd = false; + } + maybeUpdatePlaybackState(eventTime, belongsToPlayback); + } + + /** + * Notifies the tracker of a change to the playback suppression (e.g. due to audio focus loss), + * including all updates while the playback is not in the foreground. + * + * @param eventTime The {@link EventTime}. + * @param isSuppressed Whether playback is suppressed. + * @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback. + */ + public void onIsSuppressedChanged( + EventTime eventTime, boolean isSuppressed, boolean belongsToPlayback) { + this.isSuppressed = isSuppressed; + maybeUpdatePlaybackState(eventTime, belongsToPlayback); + } + + /** + * Notifies the tracker of a position discontinuity or timeline update for the current playback. + * + * @param eventTime The {@link EventTime}. + */ + public void onPositionDiscontinuity(EventTime eventTime) { + isInterruptedByAd = false; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker of the start of a seek in the current playback. + * + * @param eventTime The {@link EventTime}. + */ + public void onSeekStarted(EventTime eventTime) { + isSeeking = true; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker of a seek has been processed in the current playback. + * + * @param eventTime The {@link EventTime}. + */ + public void onSeekProcessed(EventTime eventTime) { + isSeeking = false; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker of fatal player error in the current playback. + * + * @param eventTime The {@link EventTime}. + */ + public void onFatalError(EventTime eventTime, Exception error) { + fatalErrorCount++; + if (keepHistory) { + fatalErrorHistory.add(Pair.create(eventTime, error)); + } + hasFatalError = true; + isInterruptedByAd = false; + isSeeking = false; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker that a load for the current playback has started. + * + * @param eventTime The {@link EventTime}. + */ + public void onLoadStarted(EventTime eventTime) { + startedLoading = true; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker that the current playback became the active foreground playback. + * + * @param eventTime The {@link EventTime}. + */ + public void onForeground(EventTime eventTime) { + isForeground = true; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker that the current playback has been interrupted for ad playback. + * + * @param eventTime The {@link EventTime}. + */ + public void onInterruptedByAd(EventTime eventTime) { + isInterruptedByAd = true; + isSeeking = false; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); + } + + /** + * Notifies the tracker that the current playback has finished. + * + * @param eventTime The {@link EventTime}. Not guaranteed to belong to the current playback. + */ + public void onFinished(EventTime eventTime) { + isFinished = true; + maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ false); + } + + /** + * Notifies the tracker that the track selection for the current playback changed. + * + * @param eventTime The {@link EventTime}. + * @param trackSelections The new {@link TrackSelectionArray}. + */ + public void onTracksChanged(EventTime eventTime, TrackSelectionArray trackSelections) { + boolean videoEnabled = false; + boolean audioEnabled = false; + for (TrackSelection trackSelection : trackSelections.getAll()) { + if (trackSelection != null && trackSelection.length() > 0) { + int trackType = MimeTypes.getTrackType(trackSelection.getFormat(0).sampleMimeType); + if (trackType == C.TRACK_TYPE_VIDEO) { + videoEnabled = true; + } else if (trackType == C.TRACK_TYPE_AUDIO) { + audioEnabled = true; + } + } + } + if (!videoEnabled) { + maybeUpdateVideoFormat(eventTime, /* newFormat= */ null); + } + if (!audioEnabled) { + maybeUpdateAudioFormat(eventTime, /* newFormat= */ null); + } + } + + /** + * Notifies the tracker that a format being read by the renderers for the current playback + * changed. + * + * @param eventTime The {@link EventTime}. + * @param mediaLoadData The {@link MediaLoadData} describing the format change. + */ + public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { + if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO + || mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) { + maybeUpdateVideoFormat(eventTime, mediaLoadData.trackFormat); + } else if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) { + maybeUpdateAudioFormat(eventTime, mediaLoadData.trackFormat); + } + } + + /** + * Notifies the tracker that the video size for the current playback changed. + * + * @param eventTime The {@link EventTime}. + * @param width The video width in pixels. + * @param height The video height in pixels. + */ + public void onVideoSizeChanged(EventTime eventTime, int width, int height) { + if (currentVideoFormat != null && currentVideoFormat.height == Format.NO_VALUE) { + Format formatWithHeight = currentVideoFormat.copyWithVideoSize(width, height); + maybeUpdateVideoFormat(eventTime, formatWithHeight); + } + } + + /** + * Notifies the tracker of a playback speed change, including all playback speed changes while + * the playback is not in the foreground. + * + * @param eventTime The {@link EventTime}. + * @param playbackSpeed The new playback speed. + */ + public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { + maybeUpdateMediaTimeHistory(eventTime.realtimeMs, eventTime.eventPlaybackPositionMs); + maybeRecordVideoFormatTime(eventTime.realtimeMs); + maybeRecordAudioFormatTime(eventTime.realtimeMs); + currentPlaybackSpeed = playbackSpeed; + } + + /** Notifies the builder of an audio underrun for the current playback. */ + public void onAudioUnderrun() { + audioUnderruns++; + } + + /** + * Notifies the tracker of dropped video frames for the current playback. + * + * @param droppedFrames The number of dropped video frames. + */ + public void onDroppedVideoFrames(int droppedFrames) { + this.droppedFrames += droppedFrames; + } + + /** + * Notifies the tracker of bandwidth measurement data for the current playback. + * + * @param timeMs The time for which bandwidth measurement data is available, in milliseconds. + * @param bytes The bytes transferred during {@code timeMs}. + */ + public void onBandwidthData(long timeMs, long bytes) { + bandwidthTimeMs += timeMs; + bandwidthBytes += bytes; + } + + /** + * Notifies the tracker of a non-fatal error in the current playback. + * + * @param eventTime The {@link EventTime}. + * @param error The error. + */ + public void onNonFatalError(EventTime eventTime, Exception error) { + nonFatalErrorCount++; + if (keepHistory) { + nonFatalErrorHistory.add(Pair.create(eventTime, error)); + } + } + + /** + * Builds the playback stats. + * + * @param isFinal Whether this is the final build and no further events are expected. + */ + public PlaybackStats build(boolean isFinal) { + long[] playbackStateDurationsMs = this.playbackStateDurationsMs; + List<long[]> mediaTimeHistory = this.mediaTimeHistory; + if (!isFinal) { + long buildTimeMs = SystemClock.elapsedRealtime(); + playbackStateDurationsMs = + Arrays.copyOf(this.playbackStateDurationsMs, PlaybackStats.PLAYBACK_STATE_COUNT); + long lastStateDurationMs = Math.max(0, buildTimeMs - currentPlaybackStateStartTimeMs); + playbackStateDurationsMs[currentPlaybackState] += lastStateDurationMs; + maybeUpdateMaxRebufferTimeMs(buildTimeMs); + maybeRecordVideoFormatTime(buildTimeMs); + maybeRecordAudioFormatTime(buildTimeMs); + mediaTimeHistory = new ArrayList<>(this.mediaTimeHistory); + if (keepHistory && currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING) { + mediaTimeHistory.add(guessMediaTimeBasedOnElapsedRealtime(buildTimeMs)); + } + } + boolean isJoinTimeInvalid = this.isJoinTimeInvalid || !hasBeenReady; + long validJoinTimeMs = + isJoinTimeInvalid + ? C.TIME_UNSET + : playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND]; + boolean hasBackgroundJoin = + playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND] > 0; + List<Pair<EventTime, @NullableType Format>> videoHistory = + isFinal ? videoFormatHistory : new ArrayList<>(videoFormatHistory); + List<Pair<EventTime, @NullableType Format>> audioHistory = + isFinal ? audioFormatHistory : new ArrayList<>(audioFormatHistory); + return new PlaybackStats( + /* playbackCount= */ 1, + playbackStateDurationsMs, + isFinal ? playbackStateHistory : new ArrayList<>(playbackStateHistory), + mediaTimeHistory, + firstReportedTimeMs, + /* foregroundPlaybackCount= */ isForeground ? 1 : 0, + /* abandonedBeforeReadyCount= */ hasBeenReady ? 0 : 1, + /* endedCount= */ hasEnded ? 1 : 0, + /* backgroundJoiningCount= */ hasBackgroundJoin ? 1 : 0, + validJoinTimeMs, + /* validJoinTimeCount= */ isJoinTimeInvalid ? 0 : 1, + pauseCount, + pauseBufferCount, + seekCount, + rebufferCount, + maxRebufferTimeMs, + /* adPlaybackCount= */ isAd ? 1 : 0, + videoHistory, + audioHistory, + videoFormatHeightTimeMs, + videoFormatHeightTimeProduct, + videoFormatBitrateTimeMs, + videoFormatBitrateTimeProduct, + audioFormatTimeMs, + audioFormatBitrateTimeProduct, + /* initialVideoFormatHeightCount= */ initialVideoFormatHeight == C.LENGTH_UNSET ? 0 : 1, + /* initialVideoFormatBitrateCount= */ initialVideoFormatBitrate == C.LENGTH_UNSET ? 0 : 1, + initialVideoFormatHeight, + initialVideoFormatBitrate, + /* initialAudioFormatBitrateCount= */ initialAudioFormatBitrate == C.LENGTH_UNSET ? 0 : 1, + initialAudioFormatBitrate, + bandwidthTimeMs, + bandwidthBytes, + droppedFrames, + audioUnderruns, + /* fatalErrorPlaybackCount= */ fatalErrorCount > 0 ? 1 : 0, + fatalErrorCount, + nonFatalErrorCount, + fatalErrorHistory, + nonFatalErrorHistory); + } + + private void maybeUpdatePlaybackState(EventTime eventTime, boolean belongsToPlayback) { + @PlaybackState int newPlaybackState = resolveNewPlaybackState(); + if (newPlaybackState == currentPlaybackState) { + return; + } + Assertions.checkArgument(eventTime.realtimeMs >= currentPlaybackStateStartTimeMs); + + long stateDurationMs = eventTime.realtimeMs - currentPlaybackStateStartTimeMs; + playbackStateDurationsMs[currentPlaybackState] += stateDurationMs; + if (firstReportedTimeMs == C.TIME_UNSET) { + firstReportedTimeMs = eventTime.realtimeMs; + } + isJoinTimeInvalid |= isInvalidJoinTransition(currentPlaybackState, newPlaybackState); + hasBeenReady |= isReadyState(newPlaybackState); + hasEnded |= newPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED; + if (!isPausedState(currentPlaybackState) && isPausedState(newPlaybackState)) { + pauseCount++; + } + if (newPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING) { + seekCount++; + } + if (!isRebufferingState(currentPlaybackState) && isRebufferingState(newPlaybackState)) { + rebufferCount++; + lastRebufferStartTimeMs = eventTime.realtimeMs; + } + if (isRebufferingState(currentPlaybackState) + && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING + && newPlaybackState == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING) { + pauseBufferCount++; + } + + maybeUpdateMediaTimeHistory( + eventTime.realtimeMs, + /* mediaTimeMs= */ belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET); + maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs); + maybeRecordVideoFormatTime(eventTime.realtimeMs); + maybeRecordAudioFormatTime(eventTime.realtimeMs); + + currentPlaybackState = newPlaybackState; + currentPlaybackStateStartTimeMs = eventTime.realtimeMs; + if (keepHistory) { + playbackStateHistory.add(Pair.create(eventTime, currentPlaybackState)); + } + } + + private @PlaybackState int resolveNewPlaybackState() { + if (isFinished) { + // Keep VIDEO_STATE_ENDED if playback naturally ended (or progressed to next item). + return currentPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED + ? PlaybackStats.PLAYBACK_STATE_ENDED + : PlaybackStats.PLAYBACK_STATE_ABANDONED; + } else if (isSeeking) { + // Seeking takes precedence over errors such that we report a seek while in error state. + return PlaybackStats.PLAYBACK_STATE_SEEKING; + } else if (hasFatalError) { + return PlaybackStats.PLAYBACK_STATE_FAILED; + } else if (!isForeground) { + // Before the playback becomes foreground, only report background joining and not started. + return startedLoading + ? PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND + : PlaybackStats.PLAYBACK_STATE_NOT_STARTED; + } else if (isInterruptedByAd) { + return PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD; + } else if (playerPlaybackState == Player.STATE_ENDED) { + return PlaybackStats.PLAYBACK_STATE_ENDED; + } else if (playerPlaybackState == Player.STATE_BUFFERING) { + if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_NOT_STARTED + || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND + || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND + || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) { + return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND; + } + if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING + || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING) { + return PlaybackStats.PLAYBACK_STATE_SEEK_BUFFERING; + } + if (!playWhenReady) { + return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; + } + return isSuppressed + ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING + : PlaybackStats.PLAYBACK_STATE_BUFFERING; + } else if (playerPlaybackState == Player.STATE_READY) { + if (!playWhenReady) { + return PlaybackStats.PLAYBACK_STATE_PAUSED; + } + return isSuppressed + ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED + : PlaybackStats.PLAYBACK_STATE_PLAYING; + } else if (playerPlaybackState == Player.STATE_IDLE + && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_NOT_STARTED) { + // This case only applies for calls to player.stop(). All other IDLE cases are handled by + // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored. + return PlaybackStats.PLAYBACK_STATE_STOPPED; + } + return currentPlaybackState; + } + + private void maybeUpdateMaxRebufferTimeMs(long nowMs) { + if (isRebufferingState(currentPlaybackState)) { + long rebufferDurationMs = nowMs - lastRebufferStartTimeMs; + if (maxRebufferTimeMs == C.TIME_UNSET || rebufferDurationMs > maxRebufferTimeMs) { + maxRebufferTimeMs = rebufferDurationMs; + } + } + } + + private void maybeUpdateMediaTimeHistory(long realtimeMs, long mediaTimeMs) { + if (!keepHistory) { + return; + } + if (currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PLAYING) { + if (mediaTimeMs == C.TIME_UNSET) { + return; + } + if (!mediaTimeHistory.isEmpty()) { + long previousMediaTimeMs = mediaTimeHistory.get(mediaTimeHistory.size() - 1)[1]; + if (previousMediaTimeMs != mediaTimeMs) { + mediaTimeHistory.add(new long[] {realtimeMs, previousMediaTimeMs}); + } + } + } + mediaTimeHistory.add( + mediaTimeMs == C.TIME_UNSET + ? guessMediaTimeBasedOnElapsedRealtime(realtimeMs) + : new long[] {realtimeMs, mediaTimeMs}); + } + + private long[] guessMediaTimeBasedOnElapsedRealtime(long realtimeMs) { + long[] previousKnownMediaTimeHistory = mediaTimeHistory.get(mediaTimeHistory.size() - 1); + long previousRealtimeMs = previousKnownMediaTimeHistory[0]; + long previousMediaTimeMs = previousKnownMediaTimeHistory[1]; + long elapsedMediaTimeEstimateMs = + (long) ((realtimeMs - previousRealtimeMs) * currentPlaybackSpeed); + long mediaTimeEstimateMs = previousMediaTimeMs + elapsedMediaTimeEstimateMs; + return new long[] {realtimeMs, mediaTimeEstimateMs}; + } + + private void maybeUpdateVideoFormat(EventTime eventTime, @Nullable Format newFormat) { + if (Util.areEqual(currentVideoFormat, newFormat)) { + return; + } + maybeRecordVideoFormatTime(eventTime.realtimeMs); + if (newFormat != null) { + if (initialVideoFormatHeight == C.LENGTH_UNSET && newFormat.height != Format.NO_VALUE) { + initialVideoFormatHeight = newFormat.height; + } + if (initialVideoFormatBitrate == C.LENGTH_UNSET && newFormat.bitrate != Format.NO_VALUE) { + initialVideoFormatBitrate = newFormat.bitrate; + } + } + currentVideoFormat = newFormat; + if (keepHistory) { + videoFormatHistory.add(Pair.create(eventTime, currentVideoFormat)); + } + } + + private void maybeUpdateAudioFormat(EventTime eventTime, @Nullable Format newFormat) { + if (Util.areEqual(currentAudioFormat, newFormat)) { + return; + } + maybeRecordAudioFormatTime(eventTime.realtimeMs); + if (newFormat != null + && initialAudioFormatBitrate == C.LENGTH_UNSET + && newFormat.bitrate != Format.NO_VALUE) { + initialAudioFormatBitrate = newFormat.bitrate; + } + currentAudioFormat = newFormat; + if (keepHistory) { + audioFormatHistory.add(Pair.create(eventTime, currentAudioFormat)); + } + } + + private void maybeRecordVideoFormatTime(long nowMs) { + if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING + && currentVideoFormat != null) { + long mediaDurationMs = (long) ((nowMs - lastVideoFormatStartTimeMs) * currentPlaybackSpeed); + if (currentVideoFormat.height != Format.NO_VALUE) { + videoFormatHeightTimeMs += mediaDurationMs; + videoFormatHeightTimeProduct += mediaDurationMs * currentVideoFormat.height; + } + if (currentVideoFormat.bitrate != Format.NO_VALUE) { + videoFormatBitrateTimeMs += mediaDurationMs; + videoFormatBitrateTimeProduct += mediaDurationMs * currentVideoFormat.bitrate; + } + } + lastVideoFormatStartTimeMs = nowMs; + } + + private void maybeRecordAudioFormatTime(long nowMs) { + if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING + && currentAudioFormat != null + && currentAudioFormat.bitrate != Format.NO_VALUE) { + long mediaDurationMs = (long) ((nowMs - lastAudioFormatStartTimeMs) * currentPlaybackSpeed); + audioFormatTimeMs += mediaDurationMs; + audioFormatBitrateTimeProduct += mediaDurationMs * currentAudioFormat.bitrate; + } + lastAudioFormatStartTimeMs = nowMs; + } + + private static boolean isReadyState(@PlaybackState int state) { + return state == PlaybackStats.PLAYBACK_STATE_PLAYING + || state == PlaybackStats.PLAYBACK_STATE_PAUSED + || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED; + } + + private static boolean isPausedState(@PlaybackState int state) { + return state == PlaybackStats.PLAYBACK_STATE_PAUSED + || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; + } + + private static boolean isRebufferingState(@PlaybackState int state) { + return state == PlaybackStats.PLAYBACK_STATE_BUFFERING + || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING + || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING; + } + + private static boolean isInvalidJoinTransition( + @PlaybackState int oldState, @PlaybackState int newState) { + if (oldState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND + && oldState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND + && oldState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) { + return false; + } + return newState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND + && newState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND + && newState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD + && newState != PlaybackStats.PLAYBACK_STATE_PLAYING + && newState != PlaybackStats.PLAYBACK_STATE_PAUSED + && newState != PlaybackStats.PLAYBACK_STATE_SUPPRESSED + && newState != PlaybackStats.PLAYBACK_STATE_ENDED; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/package-info.java new file mode 100644 index 0000000000..08556b00b0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/analytics/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.analytics; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Ac3Util.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Ac3Util.java new file mode 100644 index 0000000000..c68e49dea1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Ac3Util.java @@ -0,0 +1,584 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac3Util.SyncFrameInfo.StreamType; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; + +/** + * Utility methods for parsing Dolby TrueHD and (E-)AC-3 syncframes. (E-)AC-3 parsing follows the + * definition in ETSI TS 102 366 V1.4.1. + */ +public final class Ac3Util { + + /** Holds sample format information as presented by a syncframe header. */ + public static final class SyncFrameInfo { + + /** + * AC3 stream types. See also E.1.3.1.1. One of {@link #STREAM_TYPE_UNDEFINED}, {@link + * #STREAM_TYPE_TYPE0}, {@link #STREAM_TYPE_TYPE1} or {@link #STREAM_TYPE_TYPE2}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STREAM_TYPE_UNDEFINED, STREAM_TYPE_TYPE0, STREAM_TYPE_TYPE1, STREAM_TYPE_TYPE2}) + public @interface StreamType {} + /** Undefined AC3 stream type. */ + public static final int STREAM_TYPE_UNDEFINED = -1; + /** Type 0 AC3 stream type. */ + public static final int STREAM_TYPE_TYPE0 = 0; + /** Type 1 AC3 stream type. */ + public static final int STREAM_TYPE_TYPE1 = 1; + /** Type 2 AC3 stream type. */ + public static final int STREAM_TYPE_TYPE2 = 2; + + /** + * The sample mime type of the bitstream. One of {@link MimeTypes#AUDIO_AC3} and {@link + * MimeTypes#AUDIO_E_AC3}. + */ + @Nullable public final String mimeType; + /** + * The type of the stream if {@link #mimeType} is {@link MimeTypes#AUDIO_E_AC3}, or {@link + * #STREAM_TYPE_UNDEFINED} otherwise. + */ + public final @StreamType int streamType; + /** + * The audio sampling rate in Hz. + */ + public final int sampleRate; + /** + * The number of audio channels + */ + public final int channelCount; + /** + * The size of the frame. + */ + public final int frameSize; + /** + * Number of audio samples in the frame. + */ + public final int sampleCount; + + private SyncFrameInfo( + @Nullable String mimeType, + @StreamType int streamType, + int channelCount, + int sampleRate, + int frameSize, + int sampleCount) { + this.mimeType = mimeType; + this.streamType = streamType; + this.channelCount = channelCount; + this.sampleRate = sampleRate; + this.frameSize = frameSize; + this.sampleCount = sampleCount; + } + + } + + /** + * The number of samples to store in each output chunk when rechunking TrueHD streams. The number + * of samples extracted from the container corresponding to one syncframe must be an integer + * multiple of this value. + */ + public static final int TRUEHD_RECHUNK_SAMPLE_COUNT = 16; + /** + * The number of bytes that must be parsed from a TrueHD syncframe to calculate the sample count. + */ + public static final int TRUEHD_SYNCFRAME_PREFIX_LENGTH = 10; + + /** + * The number of new samples per (E-)AC-3 audio block. + */ + private static final int AUDIO_SAMPLES_PER_AUDIO_BLOCK = 256; + /** Each syncframe has 6 blocks that provide 256 new audio samples. See subsection 4.1. */ + private static final int AC3_SYNCFRAME_AUDIO_SAMPLE_COUNT = 6 * AUDIO_SAMPLES_PER_AUDIO_BLOCK; + /** + * Number of audio blocks per E-AC-3 syncframe, indexed by numblkscod. + */ + private static final int[] BLOCKS_PER_SYNCFRAME_BY_NUMBLKSCOD = new int[] {1, 2, 3, 6}; + /** + * Sample rates, indexed by fscod. + */ + private static final int[] SAMPLE_RATE_BY_FSCOD = new int[] {48000, 44100, 32000}; + /** + * Sample rates, indexed by fscod2 (E-AC-3). + */ + private static final int[] SAMPLE_RATE_BY_FSCOD2 = new int[] {24000, 22050, 16000}; + /** + * Channel counts, indexed by acmod. + */ + private static final int[] CHANNEL_COUNT_BY_ACMOD = new int[] {2, 1, 2, 3, 3, 4, 4, 5}; + /** Nominal bitrates in kbps, indexed by frmsizecod / 2. (See table 4.13.) */ + private static final int[] BITRATE_BY_HALF_FRMSIZECOD = + new int[] { + 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576, 640 + }; + /** 16-bit words per syncframe, indexed by frmsizecod / 2. (See table 4.13.) */ + private static final int[] SYNCFRAME_SIZE_WORDS_BY_HALF_FRMSIZECOD_44_1 = + new int[] { + 69, 87, 104, 121, 139, 174, 208, 243, 278, 348, 417, 487, 557, 696, 835, 975, 1114, 1253, + 1393 + }; + + /** + * Returns the AC-3 format given {@code data} containing the AC3SpecificBox according to Annex F. + * The reading position of {@code data} will be modified. + * + * @param data The AC3SpecificBox to parse. + * @param trackId The track identifier to set on the format. + * @param language The language to set on the format. + * @param drmInitData {@link DrmInitData} to be included in the format. + * @return The AC-3 format parsed from data in the header. + */ + public static Format parseAc3AnnexFFormat( + ParsableByteArray data, String trackId, String language, @Nullable DrmInitData drmInitData) { + int fscod = (data.readUnsignedByte() & 0xC0) >> 6; + int sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; + int nextByte = data.readUnsignedByte(); + int channelCount = CHANNEL_COUNT_BY_ACMOD[(nextByte & 0x38) >> 3]; + if ((nextByte & 0x04) != 0) { // lfeon + channelCount++; + } + return Format.createAudioSampleFormat( + trackId, + MimeTypes.AUDIO_AC3, + /* codecs= */ null, + Format.NO_VALUE, + Format.NO_VALUE, + channelCount, + sampleRate, + /* initializationData= */ null, + drmInitData, + /* selectionFlags= */ 0, + language); + } + + /** + * Returns the E-AC-3 format given {@code data} containing the EC3SpecificBox according to Annex + * F. The reading position of {@code data} will be modified. + * + * @param data The EC3SpecificBox to parse. + * @param trackId The track identifier to set on the format. + * @param language The language to set on the format. + * @param drmInitData {@link DrmInitData} to be included in the format. + * @return The E-AC-3 format parsed from data in the header. + */ + public static Format parseEAc3AnnexFFormat( + ParsableByteArray data, String trackId, String language, @Nullable DrmInitData drmInitData) { + data.skipBytes(2); // data_rate, num_ind_sub + + // Read the first independent substream. + int fscod = (data.readUnsignedByte() & 0xC0) >> 6; + int sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; + int nextByte = data.readUnsignedByte(); + int channelCount = CHANNEL_COUNT_BY_ACMOD[(nextByte & 0x0E) >> 1]; + if ((nextByte & 0x01) != 0) { // lfeon + channelCount++; + } + + // Read the first dependent substream. + nextByte = data.readUnsignedByte(); + int numDepSub = ((nextByte & 0x1E) >> 1); + if (numDepSub > 0) { + int lowByteChanLoc = data.readUnsignedByte(); + // Read Lrs/Rrs pair + // TODO: Read other channel configuration + if ((lowByteChanLoc & 0x02) != 0) { + channelCount += 2; + } + } + String mimeType = MimeTypes.AUDIO_E_AC3; + if (data.bytesLeft() > 0) { + nextByte = data.readUnsignedByte(); + if ((nextByte & 0x01) != 0) { // flag_ec3_extension_type_a + mimeType = MimeTypes.AUDIO_E_AC3_JOC; + } + } + return Format.createAudioSampleFormat( + trackId, + mimeType, + /* codecs= */ null, + Format.NO_VALUE, + Format.NO_VALUE, + channelCount, + sampleRate, + /* initializationData= */ null, + drmInitData, + /* selectionFlags= */ 0, + language); + } + + /** + * Returns (E-)AC-3 format information given {@code data} containing a syncframe. The reading + * position of {@code data} will be modified. + * + * @param data The data to parse, positioned at the start of the syncframe. + * @return The (E-)AC-3 format data parsed from the header. + */ + public static SyncFrameInfo parseAc3SyncframeInfo(ParsableBitArray data) { + int initialPosition = data.getPosition(); + data.skipBits(40); + // Parse the bitstream ID for AC-3 and E-AC-3 (see subsections 4.3, E.1.2 and E.1.3.1.6). + boolean isEac3 = data.readBits(5) > 10; + data.setPosition(initialPosition); + @Nullable String mimeType; + @StreamType int streamType = SyncFrameInfo.STREAM_TYPE_UNDEFINED; + int sampleRate; + int acmod; + int frameSize; + int sampleCount; + boolean lfeon; + int channelCount; + if (isEac3) { + // Subsection E.1.2. + data.skipBits(16); // syncword + switch (data.readBits(2)) { // strmtyp + case 0: + streamType = SyncFrameInfo.STREAM_TYPE_TYPE0; + break; + case 1: + streamType = SyncFrameInfo.STREAM_TYPE_TYPE1; + break; + case 2: + streamType = SyncFrameInfo.STREAM_TYPE_TYPE2; + break; + default: + streamType = SyncFrameInfo.STREAM_TYPE_UNDEFINED; + break; + } + data.skipBits(3); // substreamid + frameSize = (data.readBits(11) + 1) * 2; // See frmsiz in subsection E.1.3.1.3. + int fscod = data.readBits(2); + int audioBlocks; + int numblkscod; + if (fscod == 3) { + numblkscod = 3; + sampleRate = SAMPLE_RATE_BY_FSCOD2[data.readBits(2)]; + audioBlocks = 6; + } else { + numblkscod = data.readBits(2); + audioBlocks = BLOCKS_PER_SYNCFRAME_BY_NUMBLKSCOD[numblkscod]; + sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; + } + sampleCount = AUDIO_SAMPLES_PER_AUDIO_BLOCK * audioBlocks; + acmod = data.readBits(3); + lfeon = data.readBit(); + channelCount = CHANNEL_COUNT_BY_ACMOD[acmod] + (lfeon ? 1 : 0); + data.skipBits(5 + 5); // bsid, dialnorm + if (data.readBit()) { // compre + data.skipBits(8); // compr + } + if (acmod == 0) { + data.skipBits(5); // dialnorm2 + if (data.readBit()) { // compr2e + data.skipBits(8); // compr2 + } + } + if (streamType == SyncFrameInfo.STREAM_TYPE_TYPE1 && data.readBit()) { // chanmape + data.skipBits(16); // chanmap + } + if (data.readBit()) { // mixmdate + if (acmod > 2) { + data.skipBits(2); // dmixmod + } + if ((acmod & 0x01) != 0 && acmod > 2) { + data.skipBits(3 + 3); // ltrtcmixlev, lorocmixlev + } + if ((acmod & 0x04) != 0) { + data.skipBits(6); // ltrtsurmixlev, lorosurmixlev + } + if (lfeon && data.readBit()) { // lfemixlevcode + data.skipBits(5); // lfemixlevcod + } + if (streamType == SyncFrameInfo.STREAM_TYPE_TYPE0) { + if (data.readBit()) { // pgmscle + data.skipBits(6); //pgmscl + } + if (acmod == 0 && data.readBit()) { // pgmscl2e + data.skipBits(6); // pgmscl2 + } + if (data.readBit()) { // extpgmscle + data.skipBits(6); // extpgmscl + } + int mixdef = data.readBits(2); + if (mixdef == 1) { + data.skipBits(1 + 1 + 3); // premixcmpsel, drcsrc, premixcmpscl + } else if (mixdef == 2) { + data.skipBits(12); // mixdata + } else if (mixdef == 3) { + int mixdeflen = data.readBits(5); + if (data.readBit()) { // mixdata2e + data.skipBits(1 + 1 + 3); // premixcmpsel, drcsrc, premixcmpscl + if (data.readBit()) { // extpgmlscle + data.skipBits(4); // extpgmlscl + } + if (data.readBit()) { // extpgmcscle + data.skipBits(4); // extpgmcscl + } + if (data.readBit()) { // extpgmrscle + data.skipBits(4); // extpgmrscl + } + if (data.readBit()) { // extpgmlsscle + data.skipBits(4); // extpgmlsscl + } + if (data.readBit()) { // extpgmrsscle + data.skipBits(4); // extpgmrsscl + } + if (data.readBit()) { // extpgmlfescle + data.skipBits(4); // extpgmlfescl + } + if (data.readBit()) { // dmixscle + data.skipBits(4); // dmixscl + } + if (data.readBit()) { // addche + if (data.readBit()) { // extpgmaux1scle + data.skipBits(4); // extpgmaux1scl + } + if (data.readBit()) { // extpgmaux2scle + data.skipBits(4); // extpgmaux2scl + } + } + } + if (data.readBit()) { // mixdata3e + data.skipBits(5); // spchdat + if (data.readBit()) { // addspchdate + data.skipBits(5 + 2); // spchdat1, spchan1att + if (data.readBit()) { // addspdat1e + data.skipBits(5 + 3); // spchdat2, spchan2att + } + } + } + data.skipBits(8 * (mixdeflen + 2)); // mixdata + data.byteAlign(); // mixdatafill + } + if (acmod < 2) { + if (data.readBit()) { // paninfoe + data.skipBits(8 + 6); // panmean, paninfo + } + if (acmod == 0) { + if (data.readBit()) { // paninfo2e + data.skipBits(8 + 6); // panmean2, paninfo2 + } + } + } + if (data.readBit()) { // frmmixcfginfoe + if (numblkscod == 0) { + data.skipBits(5); // blkmixcfginfo[0] + } else { + for (int blk = 0; blk < audioBlocks; blk++) { + if (data.readBit()) { // blkmixcfginfoe + data.skipBits(5); // blkmixcfginfo[blk] + } + } + } + } + } + } + if (data.readBit()) { // infomdate + data.skipBits(3 + 1 + 1); // bsmod, copyrightb, origbs + if (acmod == 2) { + data.skipBits(2 + 2); // dsurmod, dheadphonmod + } + if (acmod >= 6) { + data.skipBits(2); // dsurexmod + } + if (data.readBit()) { // audioprodie + data.skipBits(5 + 2 + 1); // mixlevel, roomtyp, adconvtyp + } + if (acmod == 0 && data.readBit()) { // audioprodi2e + data.skipBits(5 + 2 + 1); // mixlevel2, roomtyp2, adconvtyp2 + } + if (fscod < 3) { + data.skipBit(); // sourcefscod + } + } + if (streamType == SyncFrameInfo.STREAM_TYPE_TYPE0 && numblkscod != 3) { + data.skipBit(); // convsync + } + if (streamType == SyncFrameInfo.STREAM_TYPE_TYPE2 + && (numblkscod == 3 || data.readBit())) { // blkid + data.skipBits(6); // frmsizecod + } + mimeType = MimeTypes.AUDIO_E_AC3; + if (data.readBit()) { // addbsie + int addbsil = data.readBits(6); + if (addbsil == 1 && data.readBits(8) == 1) { // addbsi + mimeType = MimeTypes.AUDIO_E_AC3_JOC; + } + } + } else /* is AC-3 */ { + mimeType = MimeTypes.AUDIO_AC3; + data.skipBits(16 + 16); // syncword, crc1 + int fscod = data.readBits(2); + if (fscod == 3) { + // fscod '11' indicates that the decoder should not attempt to decode audio. We invalidate + // the mime type to prevent association with a renderer. + mimeType = null; + } + int frmsizecod = data.readBits(6); + frameSize = getAc3SyncframeSize(fscod, frmsizecod); + data.skipBits(5 + 3); // bsid, bsmod + acmod = data.readBits(3); + if ((acmod & 0x01) != 0 && acmod != 1) { + data.skipBits(2); // cmixlev + } + if ((acmod & 0x04) != 0) { + data.skipBits(2); // surmixlev + } + if (acmod == 2) { + data.skipBits(2); // dsurmod + } + sampleRate = + fscod < SAMPLE_RATE_BY_FSCOD.length ? SAMPLE_RATE_BY_FSCOD[fscod] : Format.NO_VALUE; + sampleCount = AC3_SYNCFRAME_AUDIO_SAMPLE_COUNT; + lfeon = data.readBit(); + channelCount = CHANNEL_COUNT_BY_ACMOD[acmod] + (lfeon ? 1 : 0); + } + return new SyncFrameInfo( + mimeType, streamType, channelCount, sampleRate, frameSize, sampleCount); + } + + /** + * Returns the size in bytes of the given (E-)AC-3 syncframe. + * + * @param data The syncframe to parse. + * @return The syncframe size in bytes. {@link C#LENGTH_UNSET} if the input is invalid. + */ + public static int parseAc3SyncframeSize(byte[] data) { + if (data.length < 6) { + return C.LENGTH_UNSET; + } + // Parse the bitstream ID for AC-3 and E-AC-3 (see subsections 4.3, E.1.2 and E.1.3.1.6). + boolean isEac3 = ((data[5] & 0xF8) >> 3) > 10; + if (isEac3) { + int frmsiz = (data[2] & 0x07) << 8; // Most significant 3 bits. + frmsiz |= data[3] & 0xFF; // Least significant 8 bits. + return (frmsiz + 1) * 2; // See frmsiz in subsection E.1.3.1.3. + } else { + int fscod = (data[4] & 0xC0) >> 6; + int frmsizecod = data[4] & 0x3F; + return getAc3SyncframeSize(fscod, frmsizecod); + } + } + + /** + * Reads the number of audio samples represented by the given (E-)AC-3 syncframe. The buffer's + * position is not modified. + * + * @param buffer The {@link ByteBuffer} from which to read the syncframe. + * @return The number of audio samples represented by the syncframe. + */ + public static int parseAc3SyncframeAudioSampleCount(ByteBuffer buffer) { + // Parse the bitstream ID for AC-3 and E-AC-3 (see subsections 4.3, E.1.2 and E.1.3.1.6). + boolean isEac3 = ((buffer.get(buffer.position() + 5) & 0xF8) >> 3) > 10; + if (isEac3) { + int fscod = (buffer.get(buffer.position() + 4) & 0xC0) >> 6; + int numblkscod = fscod == 0x03 ? 3 : (buffer.get(buffer.position() + 4) & 0x30) >> 4; + return BLOCKS_PER_SYNCFRAME_BY_NUMBLKSCOD[numblkscod] * AUDIO_SAMPLES_PER_AUDIO_BLOCK; + } else { + return AC3_SYNCFRAME_AUDIO_SAMPLE_COUNT; + } + } + + /** + * Returns the offset relative to the buffer's position of the start of a TrueHD syncframe, or + * {@link C#INDEX_UNSET} if no syncframe was found. The buffer's position is not modified. + * + * @param buffer The {@link ByteBuffer} within which to find a syncframe. + * @return The offset relative to the buffer's position of the start of a TrueHD syncframe, or + * {@link C#INDEX_UNSET} if no syncframe was found. + */ + public static int findTrueHdSyncframeOffset(ByteBuffer buffer) { + int startIndex = buffer.position(); + int endIndex = buffer.limit() - TRUEHD_SYNCFRAME_PREFIX_LENGTH; + for (int i = startIndex; i <= endIndex; i++) { + // The syncword ends 0xBA for TrueHD or 0xBB for MLP. + if ((buffer.getInt(i + 4) & 0xFEFFFFFF) == 0xBA6F72F8) { + return i - startIndex; + } + } + return C.INDEX_UNSET; + } + + /** + * Returns the number of audio samples represented by the given TrueHD syncframe, or 0 if the + * buffer is not the start of a syncframe. + * + * @param syncframe The bytes from which to read the syncframe. Must be at least {@link + * #TRUEHD_SYNCFRAME_PREFIX_LENGTH} bytes long. + * @return The number of audio samples represented by the syncframe, or 0 if the buffer doesn't + * contain the start of a syncframe. + */ + public static int parseTrueHdSyncframeAudioSampleCount(byte[] syncframe) { + // See "Dolby TrueHD (MLP) high-level bitstream description" on the Dolby developer site, + // subsections 2.2 and 4.2.1. The syncword ends 0xBA for TrueHD or 0xBB for MLP. + if (syncframe[4] != (byte) 0xF8 + || syncframe[5] != (byte) 0x72 + || syncframe[6] != (byte) 0x6F + || (syncframe[7] & 0xFE) != 0xBA) { + return 0; + } + boolean isMlp = (syncframe[7] & 0xFF) == 0xBB; + return 40 << ((syncframe[isMlp ? 9 : 8] >> 4) & 0x07); + } + + /** + * Reads the number of audio samples represented by a TrueHD syncframe. The buffer's position is + * not modified. + * + * @param buffer The {@link ByteBuffer} from which to read the syncframe. + * @param offset The offset of the start of the syncframe relative to the buffer's position. + * @return The number of audio samples represented by the syncframe. + */ + public static int parseTrueHdSyncframeAudioSampleCount(ByteBuffer buffer, int offset) { + // TODO: Link to specification if available. + boolean isMlp = (buffer.get(buffer.position() + offset + 7) & 0xFF) == 0xBB; + return 40 << ((buffer.get(buffer.position() + offset + (isMlp ? 9 : 8)) >> 4) & 0x07); + } + + private static int getAc3SyncframeSize(int fscod, int frmsizecod) { + int halfFrmsizecod = frmsizecod / 2; + if (fscod < 0 || fscod >= SAMPLE_RATE_BY_FSCOD.length || frmsizecod < 0 + || halfFrmsizecod >= SYNCFRAME_SIZE_WORDS_BY_HALF_FRMSIZECOD_44_1.length) { + // Invalid values provided. + return C.LENGTH_UNSET; + } + int sampleRate = SAMPLE_RATE_BY_FSCOD[fscod]; + if (sampleRate == 44100) { + return 2 * (SYNCFRAME_SIZE_WORDS_BY_HALF_FRMSIZECOD_44_1[halfFrmsizecod] + (frmsizecod % 2)); + } + int bitrate = BITRATE_BY_HALF_FRMSIZECOD[halfFrmsizecod]; + if (sampleRate == 32000) { + return 6 * bitrate; + } else { // sampleRate == 48000 + return 4 * bitrate; + } + } + + private Ac3Util() {} + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Ac4Util.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Ac4Util.java new file mode 100644 index 0000000000..a921346e90 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Ac4Util.java @@ -0,0 +1,250 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; + +/** Utility methods for parsing AC-4 frames, which are access units in AC-4 bitstreams. */ +public final class Ac4Util { + + /** Holds sample format information as presented by a syncframe header. */ + public static final class SyncFrameInfo { + + /** The bitstream version. */ + public final int bitstreamVersion; + /** The audio sampling rate in Hz. */ + public final int sampleRate; + /** The number of audio channels */ + public final int channelCount; + /** The size of the frame. */ + public final int frameSize; + /** Number of audio samples in the frame. */ + public final int sampleCount; + + private SyncFrameInfo( + int bitstreamVersion, int channelCount, int sampleRate, int frameSize, int sampleCount) { + this.bitstreamVersion = bitstreamVersion; + this.channelCount = channelCount; + this.sampleRate = sampleRate; + this.frameSize = frameSize; + this.sampleCount = sampleCount; + } + } + + public static final int AC40_SYNCWORD = 0xAC40; + public static final int AC41_SYNCWORD = 0xAC41; + + /** The channel count of AC-4 stream. */ + // TODO: Parse AC-4 stream channel count. + private static final int CHANNEL_COUNT_2 = 2; + /** + * The AC-4 sync frame header size for extractor. The seven bytes are 0xAC, 0x40, 0xFF, 0xFF, + * sizeByte1, sizeByte2, sizeByte3. See ETSI TS 103 190-1 V1.3.1, Annex G + */ + public static final int SAMPLE_HEADER_SIZE = 7; + /** + * The header size for AC-4 parser. Only needs to be as big as we need to read, not the full + * header size. + */ + public static final int HEADER_SIZE_FOR_PARSER = 16; + /** + * Number of audio samples in the frame. Defined in IEC61937-14:2017 table 5 and 6. This table + * provides the number of samples per frame at the playback sampling frequency of 48 kHz. For 44.1 + * kHz, only frame_rate_index(13) is valid and corresponding sample count is 2048. + */ + private static final int[] SAMPLE_COUNT = + new int[] { + /* [ 0] 23.976 fps */ 2002, + /* [ 1] 24 fps */ 2000, + /* [ 2] 25 fps */ 1920, + /* [ 3] 29.97 fps */ 1601, // 1601 | 1602 | 1601 | 1602 | 1602 + /* [ 4] 30 fps */ 1600, + /* [ 5] 47.95 fps */ 1001, + /* [ 6] 48 fps */ 1000, + /* [ 7] 50 fps */ 960, + /* [ 8] 59.94 fps */ 800, // 800 | 801 | 801 | 801 | 801 + /* [ 9] 60 fps */ 800, + /* [10] 100 fps */ 480, + /* [11] 119.88 fps */ 400, // 400 | 400 | 401 | 400 | 401 + /* [12] 120 fps */ 400, + /* [13] 23.438 fps */ 2048 + }; + + /** + * Returns the AC-4 format given {@code data} containing the AC4SpecificBox according to ETSI TS + * 103 190-1 Annex E. The reading position of {@code data} will be modified. + * + * @param data The AC4SpecificBox to parse. + * @param trackId The track identifier to set on the format. + * @param language The language to set on the format. + * @param drmInitData {@link DrmInitData} to be included in the format. + * @return The AC-4 format parsed from data in the header. + */ + public static Format parseAc4AnnexEFormat( + ParsableByteArray data, String trackId, String language, @Nullable DrmInitData drmInitData) { + data.skipBytes(1); // ac4_dsi_version, bitstream_version[0:5] + int sampleRate = ((data.readUnsignedByte() & 0x20) >> 5 == 1) ? 48000 : 44100; + return Format.createAudioSampleFormat( + trackId, + MimeTypes.AUDIO_AC4, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + CHANNEL_COUNT_2, + sampleRate, + /* initializationData= */ null, + drmInitData, + /* selectionFlags= */ 0, + language); + } + + /** + * Returns AC-4 format information given {@code data} containing a syncframe. The reading position + * of {@code data} will be modified. + * + * @param data The data to parse, positioned at the start of the syncframe. + * @return The AC-4 format data parsed from the header. + */ + public static SyncFrameInfo parseAc4SyncframeInfo(ParsableBitArray data) { + int headerSize = 0; + int syncWord = data.readBits(16); + headerSize += 2; + int frameSize = data.readBits(16); + headerSize += 2; + if (frameSize == 0xFFFF) { + frameSize = data.readBits(24); + headerSize += 3; // Extended frame_size + } + frameSize += headerSize; + if (syncWord == AC41_SYNCWORD) { + frameSize += 2; // crc_word + } + int bitstreamVersion = data.readBits(2); + if (bitstreamVersion == 3) { + bitstreamVersion += readVariableBits(data, /* bitsPerRead= */ 2); + } + int sequenceCounter = data.readBits(10); + if (data.readBit()) { // b_wait_frames + if (data.readBits(3) > 0) { // wait_frames + data.skipBits(2); // reserved + } + } + int sampleRate = data.readBit() ? 48000 : 44100; + int frameRateIndex = data.readBits(4); + int sampleCount = 0; + if (sampleRate == 44100 && frameRateIndex == 13) { + sampleCount = SAMPLE_COUNT[frameRateIndex]; + } else if (sampleRate == 48000 && frameRateIndex < SAMPLE_COUNT.length) { + sampleCount = SAMPLE_COUNT[frameRateIndex]; + switch (sequenceCounter % 5) { + case 1: // fall through + case 3: + if (frameRateIndex == 3 || frameRateIndex == 8) { + sampleCount++; + } + break; + case 2: + if (frameRateIndex == 8 || frameRateIndex == 11) { + sampleCount++; + } + break; + case 4: + if (frameRateIndex == 3 || frameRateIndex == 8 || frameRateIndex == 11) { + sampleCount++; + } + break; + default: + break; + } + } + return new SyncFrameInfo(bitstreamVersion, CHANNEL_COUNT_2, sampleRate, frameSize, sampleCount); + } + + /** + * Returns the size in bytes of the given AC-4 syncframe. + * + * @param data The syncframe to parse. + * @param syncword The syncword value for the syncframe. + * @return The syncframe size in bytes, or {@link C#LENGTH_UNSET} if the input is invalid. + */ + public static int parseAc4SyncframeSize(byte[] data, int syncword) { + if (data.length < 7) { + return C.LENGTH_UNSET; + } + int headerSize = 2; // syncword + int frameSize = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF); + headerSize += 2; + if (frameSize == 0xFFFF) { + frameSize = ((data[4] & 0xFF) << 16) | ((data[5] & 0xFF) << 8) | (data[6] & 0xFF); + headerSize += 3; + } + if (syncword == AC41_SYNCWORD) { + headerSize += 2; + } + frameSize += headerSize; + return frameSize; + } + + /** + * Reads the number of audio samples represented by the given AC-4 syncframe. The buffer's + * position is not modified. + * + * @param buffer The {@link ByteBuffer} from which to read the syncframe. + * @return The number of audio samples represented by the syncframe. + */ + public static int parseAc4SyncframeAudioSampleCount(ByteBuffer buffer) { + byte[] bufferBytes = new byte[HEADER_SIZE_FOR_PARSER]; + int position = buffer.position(); + buffer.get(bufferBytes); + buffer.position(position); + return parseAc4SyncframeInfo(new ParsableBitArray(bufferBytes)).sampleCount; + } + + /** Populates {@code buffer} with an AC-4 sample header for a sample of the specified size. */ + public static void getAc4SampleHeader(int size, ParsableByteArray buffer) { + // See ETSI TS 103 190-1 V1.3.1, Annex G. + buffer.reset(SAMPLE_HEADER_SIZE); + buffer.data[0] = (byte) 0xAC; + buffer.data[1] = 0x40; + buffer.data[2] = (byte) 0xFF; + buffer.data[3] = (byte) 0xFF; + buffer.data[4] = (byte) ((size >> 16) & 0xFF); + buffer.data[5] = (byte) ((size >> 8) & 0xFF); + buffer.data[6] = (byte) (size & 0xFF); + } + + private static int readVariableBits(ParsableBitArray data, int bitsPerRead) { + int value = 0; + while (true) { + value += data.readBits(bitsPerRead); + if (!data.readBit()) { + break; + } + value++; + value <<= bitsPerRead; + } + return value; + } + + private Ac4Util() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioAttributes.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioAttributes.java new file mode 100644 index 0000000000..d0f3fcb438 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioAttributes.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.annotation.TargetApi; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Attributes for audio playback, which configure the underlying platform + * {@link android.media.AudioTrack}. + * <p> + * To set the audio attributes, create an instance using the {@link Builder} and either pass it to + * {@link org.mozilla.thirdparty.com.google.android.exoplayer2SimpleExoPlayer#setAudioAttributes(AudioAttributes)} or + * send a message of type {@link C#MSG_SET_AUDIO_ATTRIBUTES} to the audio renderers. + * <p> + * This class is based on {@link android.media.AudioAttributes}, but can be used on all supported + * API versions. + */ +public final class AudioAttributes { + + public static final AudioAttributes DEFAULT = new Builder().build(); + + /** + * Builder for {@link AudioAttributes}. + */ + public static final class Builder { + + private @C.AudioContentType int contentType; + private @C.AudioFlags int flags; + private @C.AudioUsage int usage; + private @C.AudioAllowedCapturePolicy int allowedCapturePolicy; + + /** + * Creates a new builder for {@link AudioAttributes}. + * + * <p>By default the content type is {@link C#CONTENT_TYPE_UNKNOWN}, usage is {@link + * C#USAGE_MEDIA}, capture policy is {@link C#ALLOW_CAPTURE_BY_ALL} and no flags are set. + */ + public Builder() { + contentType = C.CONTENT_TYPE_UNKNOWN; + flags = 0; + usage = C.USAGE_MEDIA; + allowedCapturePolicy = C.ALLOW_CAPTURE_BY_ALL; + } + + /** + * @see android.media.AudioAttributes.Builder#setContentType(int) + */ + public Builder setContentType(@C.AudioContentType int contentType) { + this.contentType = contentType; + return this; + } + + /** + * @see android.media.AudioAttributes.Builder#setFlags(int) + */ + public Builder setFlags(@C.AudioFlags int flags) { + this.flags = flags; + return this; + } + + /** + * @see android.media.AudioAttributes.Builder#setUsage(int) + */ + public Builder setUsage(@C.AudioUsage int usage) { + this.usage = usage; + return this; + } + + /** See {@link android.media.AudioAttributes.Builder#setAllowedCapturePolicy(int)}. */ + public Builder setAllowedCapturePolicy(@C.AudioAllowedCapturePolicy int allowedCapturePolicy) { + this.allowedCapturePolicy = allowedCapturePolicy; + return this; + } + + /** Creates an {@link AudioAttributes} instance from this builder. */ + public AudioAttributes build() { + return new AudioAttributes(contentType, flags, usage, allowedCapturePolicy); + } + + } + + public final @C.AudioContentType int contentType; + public final @C.AudioFlags int flags; + public final @C.AudioUsage int usage; + public final @C.AudioAllowedCapturePolicy int allowedCapturePolicy; + + @Nullable private android.media.AudioAttributes audioAttributesV21; + + private AudioAttributes( + @C.AudioContentType int contentType, + @C.AudioFlags int flags, + @C.AudioUsage int usage, + @C.AudioAllowedCapturePolicy int allowedCapturePolicy) { + this.contentType = contentType; + this.flags = flags; + this.usage = usage; + this.allowedCapturePolicy = allowedCapturePolicy; + } + + /** + * Returns a {@link android.media.AudioAttributes} from this instance. + * + * <p>Field {@link AudioAttributes#allowedCapturePolicy} is ignored for API levels prior to 29. + */ + @TargetApi(21) + public android.media.AudioAttributes getAudioAttributesV21() { + if (audioAttributesV21 == null) { + android.media.AudioAttributes.Builder builder = + new android.media.AudioAttributes.Builder() + .setContentType(contentType) + .setFlags(flags) + .setUsage(usage); + if (Util.SDK_INT >= 29) { + builder.setAllowedCapturePolicy(allowedCapturePolicy); + } + audioAttributesV21 = builder.build(); + } + return audioAttributesV21; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + AudioAttributes other = (AudioAttributes) obj; + return this.contentType == other.contentType + && this.flags == other.flags + && this.usage == other.usage + && this.allowedCapturePolicy == other.allowedCapturePolicy; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + contentType; + result = 31 * result + flags; + result = 31 * result + usage; + result = 31 * result + allowedCapturePolicy; + return result; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioCapabilities.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioCapabilities.java new file mode 100644 index 0000000000..f985891465 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioCapabilities.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.net.Uri; +import android.provider.Settings.Global; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** Represents the set of audio formats that a device is capable of playing. */ +@TargetApi(21) +public final class AudioCapabilities { + + private static final int DEFAULT_MAX_CHANNEL_COUNT = 8; + + /** The minimum audio capabilities supported by all devices. */ + public static final AudioCapabilities DEFAULT_AUDIO_CAPABILITIES = + new AudioCapabilities(new int[] {AudioFormat.ENCODING_PCM_16BIT}, DEFAULT_MAX_CHANNEL_COUNT); + + /** Audio capabilities when the device specifies external surround sound. */ + private static final AudioCapabilities EXTERNAL_SURROUND_SOUND_CAPABILITIES = + new AudioCapabilities( + new int[] { + AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_E_AC3 + }, + DEFAULT_MAX_CHANNEL_COUNT); + + /** Global settings key for devices that can specify external surround sound. */ + private static final String EXTERNAL_SURROUND_SOUND_KEY = "external_surround_sound_enabled"; + + /** + * Returns the current audio capabilities for the device. + * + * @param context A context for obtaining the current audio capabilities. + * @return The current audio capabilities for the device. + */ + @SuppressWarnings("InlinedApi") + public static AudioCapabilities getCapabilities(Context context) { + Intent intent = + context.registerReceiver( + /* receiver= */ null, new IntentFilter(AudioManager.ACTION_HDMI_AUDIO_PLUG)); + return getCapabilities(context, intent); + } + + @SuppressLint("InlinedApi") + /* package */ static AudioCapabilities getCapabilities(Context context, @Nullable Intent intent) { + if (deviceMaySetExternalSurroundSoundGlobalSetting() + && Global.getInt(context.getContentResolver(), EXTERNAL_SURROUND_SOUND_KEY, 0) == 1) { + return EXTERNAL_SURROUND_SOUND_CAPABILITIES; + } + if (intent == null || intent.getIntExtra(AudioManager.EXTRA_AUDIO_PLUG_STATE, 0) == 0) { + return DEFAULT_AUDIO_CAPABILITIES; + } + return new AudioCapabilities( + intent.getIntArrayExtra(AudioManager.EXTRA_ENCODINGS), + intent.getIntExtra( + AudioManager.EXTRA_MAX_CHANNEL_COUNT, /* defaultValue= */ DEFAULT_MAX_CHANNEL_COUNT)); + } + + /** + * Returns the global settings {@link Uri} used by the device to specify external surround sound, + * or null if the device does not support this functionality. + */ + @Nullable + /* package */ static Uri getExternalSurroundSoundGlobalSettingUri() { + return deviceMaySetExternalSurroundSoundGlobalSetting() + ? Global.getUriFor(EXTERNAL_SURROUND_SOUND_KEY) + : null; + } + + private final int[] supportedEncodings; + private final int maxChannelCount; + + /** + * Constructs new audio capabilities based on a set of supported encodings and a maximum channel + * count. + * + * <p>Applications should generally call {@link #getCapabilities(Context)} to obtain an instance + * based on the capabilities advertised by the platform, rather than calling this constructor. + * + * @param supportedEncodings Supported audio encodings from {@link android.media.AudioFormat}'s + * {@code ENCODING_*} constants. Passing {@code null} indicates that no encodings are + * supported. + * @param maxChannelCount The maximum number of audio channels that can be played simultaneously. + */ + public AudioCapabilities(@Nullable int[] supportedEncodings, int maxChannelCount) { + if (supportedEncodings != null) { + this.supportedEncodings = Arrays.copyOf(supportedEncodings, supportedEncodings.length); + Arrays.sort(this.supportedEncodings); + } else { + this.supportedEncodings = new int[0]; + } + this.maxChannelCount = maxChannelCount; + } + + /** + * Returns whether this device supports playback of the specified audio {@code encoding}. + * + * @param encoding One of {@link android.media.AudioFormat}'s {@code ENCODING_*} constants. + * @return Whether this device supports playback the specified audio {@code encoding}. + */ + public boolean supportsEncoding(int encoding) { + return Arrays.binarySearch(supportedEncodings, encoding) >= 0; + } + + /** + * Returns the maximum number of channels the device can play at the same time. + */ + public int getMaxChannelCount() { + return maxChannelCount; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof AudioCapabilities)) { + return false; + } + AudioCapabilities audioCapabilities = (AudioCapabilities) other; + return Arrays.equals(supportedEncodings, audioCapabilities.supportedEncodings) + && maxChannelCount == audioCapabilities.maxChannelCount; + } + + @Override + public int hashCode() { + return maxChannelCount + 31 * Arrays.hashCode(supportedEncodings); + } + + @Override + public String toString() { + return "AudioCapabilities[maxChannelCount=" + maxChannelCount + + ", supportedEncodings=" + Arrays.toString(supportedEncodings) + "]"; + } + + private static boolean deviceMaySetExternalSurroundSoundGlobalSetting() { + return Util.SDK_INT >= 17 && "Amazon".equals(Util.MANUFACTURER); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java new file mode 100644 index 0000000000..d96fd32f53 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioCapabilitiesReceiver.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.database.ContentObserver; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Receives broadcast events indicating changes to the device's audio capabilities, notifying a + * {@link Listener} when audio capability changes occur. + */ +public final class AudioCapabilitiesReceiver { + + /** + * Listener notified when audio capabilities change. + */ + public interface Listener { + + /** + * Called when the audio capabilities change. + * + * @param audioCapabilities The current audio capabilities for the device. + */ + void onAudioCapabilitiesChanged(AudioCapabilities audioCapabilities); + + } + + private final Context context; + private final Listener listener; + private final Handler handler; + @Nullable private final BroadcastReceiver receiver; + @Nullable private final ExternalSurroundSoundSettingObserver externalSurroundSoundSettingObserver; + + /* package */ @Nullable AudioCapabilities audioCapabilities; + private boolean registered; + + /** + * @param context A context for registering the receiver. + * @param listener The listener to notify when audio capabilities change. + */ + public AudioCapabilitiesReceiver(Context context, Listener listener) { + context = context.getApplicationContext(); + this.context = context; + this.listener = Assertions.checkNotNull(listener); + handler = new Handler(Util.getLooper()); + receiver = Util.SDK_INT >= 21 ? new HdmiAudioPlugBroadcastReceiver() : null; + Uri externalSurroundSoundUri = AudioCapabilities.getExternalSurroundSoundGlobalSettingUri(); + externalSurroundSoundSettingObserver = + externalSurroundSoundUri != null + ? new ExternalSurroundSoundSettingObserver( + handler, context.getContentResolver(), externalSurroundSoundUri) + : null; + } + + /** + * Registers the receiver, meaning it will notify the listener when audio capability changes + * occur. The current audio capabilities will be returned. It is important to call + * {@link #unregister} when the receiver is no longer required. + * + * @return The current audio capabilities for the device. + */ + @SuppressWarnings("InlinedApi") + public AudioCapabilities register() { + if (registered) { + return Assertions.checkNotNull(audioCapabilities); + } + registered = true; + if (externalSurroundSoundSettingObserver != null) { + externalSurroundSoundSettingObserver.register(); + } + Intent stickyIntent = null; + if (receiver != null) { + IntentFilter intentFilter = new IntentFilter(AudioManager.ACTION_HDMI_AUDIO_PLUG); + stickyIntent = + context.registerReceiver( + receiver, intentFilter, /* broadcastPermission= */ null, handler); + } + audioCapabilities = AudioCapabilities.getCapabilities(context, stickyIntent); + return audioCapabilities; + } + + /** + * Unregisters the receiver, meaning it will no longer notify the listener when audio capability + * changes occur. + */ + public void unregister() { + if (!registered) { + return; + } + audioCapabilities = null; + if (receiver != null) { + context.unregisterReceiver(receiver); + } + if (externalSurroundSoundSettingObserver != null) { + externalSurroundSoundSettingObserver.unregister(); + } + registered = false; + } + + private void onNewAudioCapabilities(AudioCapabilities newAudioCapabilities) { + if (registered && !newAudioCapabilities.equals(audioCapabilities)) { + audioCapabilities = newAudioCapabilities; + listener.onAudioCapabilitiesChanged(newAudioCapabilities); + } + } + + private final class HdmiAudioPlugBroadcastReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (!isInitialStickyBroadcast()) { + onNewAudioCapabilities(AudioCapabilities.getCapabilities(context, intent)); + } + } + } + + private final class ExternalSurroundSoundSettingObserver extends ContentObserver { + + private final ContentResolver resolver; + private final Uri settingUri; + + public ExternalSurroundSoundSettingObserver( + Handler handler, ContentResolver resolver, Uri settingUri) { + super(handler); + this.resolver = resolver; + this.settingUri = settingUri; + } + + public void register() { + resolver.registerContentObserver(settingUri, /* notifyForDescendants= */ false, this); + } + + public void unregister() { + resolver.unregisterContentObserver(this); + } + + @Override + public void onChange(boolean selfChange) { + onNewAudioCapabilities(AudioCapabilities.getCapabilities(context)); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioDecoderException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioDecoderException.java new file mode 100644 index 0000000000..0f4ac159b9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioDecoderException.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +/** Thrown when an audio decoder error occurs. */ +public class AudioDecoderException extends Exception { + + /** @param message The detail message for this exception. */ + public AudioDecoderException(String message) { + super(message); + } + + /** + * @param message The detail message for this exception. + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). + * A <tt>null</tt> value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public AudioDecoderException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioListener.java new file mode 100644 index 0000000000..457f52b887 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioListener.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +/** A listener for changes in audio configuration. */ +public interface AudioListener { + + /** + * Called when the audio session is set. + * + * @param audioSessionId The audio session id. + */ + default void onAudioSessionId(int audioSessionId) {} + + /** + * Called when the audio attributes change. + * + * @param audioAttributes The audio attributes. + */ + default void onAudioAttributesChanged(AudioAttributes audioAttributes) {} + + /** + * Called when the volume changes. + * + * @param volume The new volume, with 0 being silence and 1 being unity gain. + */ + default void onVolumeChanged(float volume) {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioProcessor.java new file mode 100644 index 0000000000..e0814314ca --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioProcessor.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Interface for audio processors, which take audio data as input and transform it, potentially + * modifying its channel count, encoding and/or sample rate. + * + * <p>In addition to being able to modify the format of audio, implementations may allow parameters + * to be set that affect the output audio and whether the processor is active/inactive. + */ +public interface AudioProcessor { + + /** PCM audio format that may be handled by an audio processor. */ + final class AudioFormat { + public static final AudioFormat NOT_SET = + new AudioFormat( + /* sampleRate= */ Format.NO_VALUE, + /* channelCount= */ Format.NO_VALUE, + /* encoding= */ Format.NO_VALUE); + + /** The sample rate in Hertz. */ + public final int sampleRate; + /** The number of interleaved channels. */ + public final int channelCount; + /** The type of linear PCM encoding. */ + @C.PcmEncoding public final int encoding; + /** The number of bytes used to represent one audio frame. */ + public final int bytesPerFrame; + + public AudioFormat(int sampleRate, int channelCount, @C.PcmEncoding int encoding) { + this.sampleRate = sampleRate; + this.channelCount = channelCount; + this.encoding = encoding; + bytesPerFrame = + Util.isEncodingLinearPcm(encoding) + ? Util.getPcmFrameSize(encoding, channelCount) + : Format.NO_VALUE; + } + + @Override + public String toString() { + return "AudioFormat[" + + "sampleRate=" + + sampleRate + + ", channelCount=" + + channelCount + + ", encoding=" + + encoding + + ']'; + } + } + + /** Exception thrown when a processor can't be configured for a given input audio format. */ + final class UnhandledAudioFormatException extends Exception { + + public UnhandledAudioFormatException(AudioFormat inputAudioFormat) { + super("Unhandled format: " + inputAudioFormat); + } + + } + + /** An empty, direct {@link ByteBuffer}. */ + ByteBuffer EMPTY_BUFFER = ByteBuffer.allocateDirect(0).order(ByteOrder.nativeOrder()); + + /** + * Configures the processor to process input audio with the specified format. After calling this + * method, call {@link #isActive()} to determine whether the audio processor is active. Returns + * the configured output audio format if this instance is active. + * + * <p>After calling this method, it is necessary to {@link #flush()} the processor to apply the + * new configuration. Before applying the new configuration, it is safe to queue input and get + * output in the old input/output formats. Call {@link #queueEndOfStream()} when no more input + * will be supplied in the old input format. + * + * @param inputAudioFormat The format of audio that will be queued after the next call to {@link + * #flush()}. + * @return The configured output audio format if this instance is {@link #isActive() active}. + * @throws UnhandledAudioFormatException Thrown if the specified format can't be handled as input. + */ + AudioFormat configure(AudioFormat inputAudioFormat) throws UnhandledAudioFormatException; + + /** Returns whether the processor is configured and will process input buffers. */ + boolean isActive(); + + /** + * Queues audio data between the position and limit of the input {@code buffer} for processing. + * {@code buffer} must be a direct byte buffer with native byte order. Its contents are treated as + * read-only. Its position will be advanced by the number of bytes consumed (which may be zero). + * The caller retains ownership of the provided buffer. Calling this method invalidates any + * previous buffer returned by {@link #getOutput()}. + * + * @param buffer The input buffer to process. + */ + void queueInput(ByteBuffer buffer); + + /** + * Queues an end of stream signal. After this method has been called, + * {@link #queueInput(ByteBuffer)} may not be called until after the next call to + * {@link #flush()}. Calling {@link #getOutput()} will return any remaining output data. Multiple + * calls may be required to read all of the remaining output data. {@link #isEnded()} will return + * {@code true} once all remaining output data has been read. + */ + void queueEndOfStream(); + + /** + * Returns a buffer containing processed output data between its position and limit. The buffer + * will always be a direct byte buffer with native byte order. Calling this method invalidates any + * previously returned buffer. The buffer will be empty if no output is available. + * + * @return A buffer containing processed output data between its position and limit. + */ + ByteBuffer getOutput(); + + /** + * Returns whether this processor will return no more output from {@link #getOutput()} until it + * has been {@link #flush()}ed and more input has been queued. + */ + boolean isEnded(); + + /** + * Clears any buffered data and pending output. If the audio processor is active, also prepares + * the audio processor to receive a new stream of input in the last configured (pending) format. + */ + void flush(); + + /** Resets the processor to its unconfigured state, releasing any resources. */ + void reset(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioRendererEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioRendererEventListener.java new file mode 100644 index 0000000000..bb1ae72855 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioRendererEventListener.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Handler; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Renderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Listener of audio {@link Renderer} events. All methods have no-op default implementations to + * allow selective overrides. + */ +public interface AudioRendererEventListener { + + /** + * Called when the renderer is enabled. + * + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. + */ + default void onAudioEnabled(DecoderCounters counters) {} + + /** + * Called when the audio session is set. + * + * @param audioSessionId The audio session id. + */ + default void onAudioSessionId(int audioSessionId) {} + + /** + * Called when a decoder is created. + * + * @param decoderName The decoder that was created. + * @param initializedTimestampMs {@link SystemClock#elapsedRealtime()} when initialization + * finished. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. + */ + default void onAudioDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) {} + + /** + * Called when the format of the media being consumed by the renderer changes. + * + * @param format The new format. + */ + default void onAudioInputFormatChanged(Format format) {} + + /** + * Called when an {@link AudioSink} underrun occurs. + * + * @param bufferSize The size of the {@link AudioSink}'s buffer, in bytes. + * @param bufferSizeMs The size of the {@link AudioSink}'s buffer, in milliseconds, if it is + * configured for PCM output. {@link C#TIME_UNSET} if it is configured for passthrough output, + * as the buffered media can have a variable bitrate so the duration may be unknown. + * @param elapsedSinceLastFeedMs The time since the {@link AudioSink} was last fed data. + */ + default void onAudioSinkUnderrun( + int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {} + + /** + * Called when the renderer is disabled. + * + * @param counters {@link DecoderCounters} that were updated by the renderer. + */ + default void onAudioDisabled(DecoderCounters counters) {} + + /** + * Dispatches events to a {@link AudioRendererEventListener}. + */ + final class EventDispatcher { + + @Nullable private final Handler handler; + @Nullable private final AudioRendererEventListener listener; + + /** + * @param handler A handler for dispatching events, or null if creating a dummy instance. + * @param listener The listener to which events should be dispatched, or null if creating a + * dummy instance. + */ + public EventDispatcher(@Nullable Handler handler, + @Nullable AudioRendererEventListener listener) { + this.handler = listener != null ? Assertions.checkNotNull(handler) : null; + this.listener = listener; + } + + /** + * Invokes {@link AudioRendererEventListener#onAudioEnabled(DecoderCounters)}. + */ + public void enabled(final DecoderCounters decoderCounters) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onAudioEnabled(decoderCounters)); + } + } + + /** + * Invokes {@link AudioRendererEventListener#onAudioDecoderInitialized(String, long, long)}. + */ + public void decoderInitialized(final String decoderName, + final long initializedTimestampMs, final long initializationDurationMs) { + if (handler != null) { + handler.post( + () -> + castNonNull(listener) + .onAudioDecoderInitialized( + decoderName, initializedTimestampMs, initializationDurationMs)); + } + } + + /** + * Invokes {@link AudioRendererEventListener#onAudioInputFormatChanged(Format)}. + */ + public void inputFormatChanged(final Format format) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onAudioInputFormatChanged(format)); + } + } + + /** + * Invokes {@link AudioRendererEventListener#onAudioSinkUnderrun(int, long, long)}. + */ + public void audioTrackUnderrun(final int bufferSize, final long bufferSizeMs, + final long elapsedSinceLastFeedMs) { + if (handler != null) { + handler.post( + () -> + castNonNull(listener) + .onAudioSinkUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)); + } + } + + /** + * Invokes {@link AudioRendererEventListener#onAudioDisabled(DecoderCounters)}. + */ + public void disabled(final DecoderCounters counters) { + counters.ensureUpdated(); + if (handler != null) { + handler.post( + () -> { + counters.ensureUpdated(); + castNonNull(listener).onAudioDisabled(counters); + }); + } + } + + /** + * Invokes {@link AudioRendererEventListener#onAudioSessionId(int)}. + */ + public void audioSessionId(final int audioSessionId) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onAudioSessionId(audioSessionId)); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioSink.java new file mode 100644 index 0000000000..db87e28e7f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioSink.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.media.AudioTrack; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import java.nio.ByteBuffer; + +/** + * A sink that consumes audio data. + * + * <p>Before starting playback, specify the input audio format by calling {@link #configure(int, + * int, int, int, int[], int, int)}. + * + * <p>Call {@link #handleBuffer(ByteBuffer, long)} to write data, and {@link #handleDiscontinuity()} + * when the data being fed is discontinuous. Call {@link #play()} to start playing the written data. + * + * <p>Call {@link #configure(int, int, int, int, int[], int, int)} whenever the input format + * changes. The sink will be reinitialized on the next call to {@link #handleBuffer(ByteBuffer, + * long)}. + * + * <p>Call {@link #flush()} to prepare the sink to receive audio data from a new playback position. + * + * <p>Call {@link #playToEndOfStream()} repeatedly to play out all data when no more input buffers + * will be provided via {@link #handleBuffer(ByteBuffer, long)} until the next {@link #flush()}. + * Call {@link #reset()} when the instance is no longer required. + * + * <p>The implementation may be backed by a platform {@link AudioTrack}. In this case, {@link + * #setAudioSessionId(int)}, {@link #setAudioAttributes(AudioAttributes)}, {@link + * #enableTunnelingV21(int)} and/or {@link #disableTunneling()} may be called before writing data to + * the sink. These methods may also be called after writing data to the sink, in which case it will + * be reinitialized as required. For implementations that are not based on platform {@link + * AudioTrack}s, calling methods relating to audio sessions, audio attributes, and tunneling may + * have no effect. + */ +public interface AudioSink { + + /** + * Listener for audio sink events. + */ + interface Listener { + + /** + * Called if the audio sink has started rendering audio to a new platform audio session. + * + * @param audioSessionId The newly generated audio session's identifier. + */ + void onAudioSessionId(int audioSessionId); + + /** + * Called when the audio sink handles a buffer whose timestamp is discontinuous with the last + * buffer handled since it was reset. + */ + void onPositionDiscontinuity(); + + /** + * Called when the audio sink runs out of data. + * <p> + * An audio sink implementation may never call this method (for example, if audio data is + * consumed in batches rather than based on the sink's own clock). + * + * @param bufferSize The size of the sink's buffer, in bytes. + * @param bufferSizeMs The size of the sink's buffer, in milliseconds, if it is configured for + * PCM output. {@link C#TIME_UNSET} if it is configured for encoded audio output, as the + * buffered media can have a variable bitrate so the duration may be unknown. + * @param elapsedSinceLastFeedMs The time since the sink was last fed data, in milliseconds. + */ + void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs); + + } + + /** + * Thrown when a failure occurs configuring the sink. + */ + final class ConfigurationException extends Exception { + + /** + * Creates a new configuration exception with the specified {@code cause} and no message. + */ + public ConfigurationException(Throwable cause) { + super(cause); + } + + /** + * Creates a new configuration exception with the specified {@code message} and no cause. + */ + public ConfigurationException(String message) { + super(message); + } + + } + + /** + * Thrown when a failure occurs initializing the sink. + */ + final class InitializationException extends Exception { + + /** + * The underlying {@link AudioTrack}'s state, if applicable. + */ + public final int audioTrackState; + + /** + * @param audioTrackState The underlying {@link AudioTrack}'s state, if applicable. + * @param sampleRate The requested sample rate in Hz. + * @param channelConfig The requested channel configuration. + * @param bufferSize The requested buffer size in bytes. + */ + public InitializationException(int audioTrackState, int sampleRate, int channelConfig, + int bufferSize) { + super("AudioTrack init failed: " + audioTrackState + ", Config(" + sampleRate + ", " + + channelConfig + ", " + bufferSize + ")"); + this.audioTrackState = audioTrackState; + } + + } + + /** + * Thrown when a failure occurs writing to the sink. + */ + final class WriteException extends Exception { + + /** + * The error value returned from the sink implementation. If the sink writes to a platform + * {@link AudioTrack}, this will be the error value returned from + * {@link AudioTrack#write(byte[], int, int)} or {@link AudioTrack#write(ByteBuffer, int, int)}. + * Otherwise, the meaning of the error code depends on the sink implementation. + */ + public final int errorCode; + + /** + * @param errorCode The error value returned from the sink implementation. + */ + public WriteException(int errorCode) { + super("AudioTrack write failed: " + errorCode); + this.errorCode = errorCode; + } + + } + + /** + * Returned by {@link #getCurrentPositionUs(boolean)} when the position is not set. + */ + long CURRENT_POSITION_NOT_SET = Long.MIN_VALUE; + + /** + * Sets the listener for sink events, which should be the audio renderer. + * + * @param listener The listener for sink events, which should be the audio renderer. + */ + void setListener(Listener listener); + + /** + * Returns whether the sink supports the audio format. + * + * @param channelCount The number of channels, or {@link Format#NO_VALUE} if not known. + * @param encoding The audio encoding, or {@link Format#NO_VALUE} if not known. + * @return Whether the sink supports the audio format. + */ + boolean supportsOutput(int channelCount, @C.Encoding int encoding); + + /** + * Returns the playback position in the stream starting at zero, in microseconds, or + * {@link #CURRENT_POSITION_NOT_SET} if it is not yet available. + * + * @param sourceEnded Specify {@code true} if no more input buffers will be provided. + * @return The playback position relative to the start of playback, in microseconds. + */ + long getCurrentPositionUs(boolean sourceEnded); + + /** + * Configures (or reconfigures) the sink. + * + * @param inputEncoding The encoding of audio data provided in the input buffers. + * @param inputChannelCount The number of channels. + * @param inputSampleRate The sample rate in Hz. + * @param specifiedBufferSize A specific size for the playback buffer in bytes, or 0 to infer a + * suitable buffer size. + * @param outputChannels A mapping from input to output channels that is applied to this sink's + * input as a preprocessing step, if handling PCM input. Specify {@code null} to leave the + * input unchanged. Otherwise, the element at index {@code i} specifies index of the input + * channel to map to output channel {@code i} when preprocessing input buffers. After the map + * is applied the audio data will have {@code outputChannels.length} channels. + * @param trimStartFrames The number of audio frames to trim from the start of data written to the + * sink after this call. + * @param trimEndFrames The number of audio frames to trim from data written to the sink + * immediately preceding the next call to {@link #flush()} or this method. + * @throws ConfigurationException If an error occurs configuring the sink. + */ + void configure( + @C.Encoding int inputEncoding, + int inputChannelCount, + int inputSampleRate, + int specifiedBufferSize, + @Nullable int[] outputChannels, + int trimStartFrames, + int trimEndFrames) + throws ConfigurationException; + + /** + * Starts or resumes consuming audio if initialized. + */ + void play(); + + /** Signals to the sink that the next buffer may be discontinuous with the previous buffer. */ + void handleDiscontinuity(); + + /** + * Attempts to process data from a {@link ByteBuffer}, starting from its current position and + * ending at its limit (exclusive). The position of the {@link ByteBuffer} is advanced by the + * number of bytes that were handled. {@link Listener#onPositionDiscontinuity()} will be called if + * {@code presentationTimeUs} is discontinuous with the last buffer handled since the last reset. + * + * <p>Returns whether the data was handled in full. If the data was not handled in full then the + * same {@link ByteBuffer} must be provided to subsequent calls until it has been fully consumed, + * except in the case of an intervening call to {@link #flush()} (or to {@link #configure(int, + * int, int, int, int[], int, int)} that causes the sink to be flushed). + * + * @param buffer The buffer containing audio data. + * @param presentationTimeUs The presentation timestamp of the buffer in microseconds. + * @return Whether the buffer was handled fully. + * @throws InitializationException If an error occurs initializing the sink. + * @throws WriteException If an error occurs writing the audio data. + */ + boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs) + throws InitializationException, WriteException; + + /** + * Processes any remaining data. {@link #isEnded()} will return {@code true} when no data remains. + * + * @throws WriteException If an error occurs draining data to the sink. + */ + void playToEndOfStream() throws WriteException; + + /** + * Returns whether {@link #playToEndOfStream} has been called and all buffers have been processed. + */ + boolean isEnded(); + + /** + * Returns whether the sink has data pending that has not been consumed yet. + */ + boolean hasPendingData(); + + /** + * Attempts to set the playback parameters. The audio sink may override these parameters if they + * are not supported. + * + * @param playbackParameters The new playback parameters to attempt to set. + */ + void setPlaybackParameters(PlaybackParameters playbackParameters); + + /** + * Gets the active {@link PlaybackParameters}. + */ + PlaybackParameters getPlaybackParameters(); + + /** + * Sets attributes for audio playback. If the attributes have changed and if the sink is not + * configured for use with tunneling, then it is reset and the audio session id is cleared. + * <p> + * If the sink is configured for use with tunneling then the audio attributes are ignored. The + * sink is not reset and the audio session id is not cleared. The passed attributes will be used + * if the sink is later re-configured into non-tunneled mode. + * + * @param audioAttributes The attributes for audio playback. + */ + void setAudioAttributes(AudioAttributes audioAttributes); + + /** Sets the audio session id. */ + void setAudioSessionId(int audioSessionId); + + /** Sets the auxiliary effect. */ + void setAuxEffectInfo(AuxEffectInfo auxEffectInfo); + + /** + * Enables tunneling, if possible. The sink is reset if tunneling was previously disabled or if + * the audio session id has changed. Enabling tunneling is only possible if the sink is based on a + * platform {@link AudioTrack}, and requires platform API version 21 onwards. + * + * @param tunnelingAudioSessionId The audio session id to use. + * @throws IllegalStateException Thrown if enabling tunneling on platform API version < 21. + */ + void enableTunnelingV21(int tunnelingAudioSessionId); + + /** + * Disables tunneling. If tunneling was previously enabled then the sink is reset and any audio + * session id is cleared. + */ + void disableTunneling(); + + /** + * Sets the playback volume. + * + * @param volume A volume in the range [0.0, 1.0]. + */ + void setVolume(float volume); + + /** + * Pauses playback. + */ + void pause(); + + /** + * Flushes the sink, after which it is ready to receive buffers from a new playback position. + * + * <p>The audio session may remain active until {@link #reset()} is called. + */ + void flush(); + + /** Resets the renderer, releasing any resources that it currently holds. */ + void reset(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioTimestampPoller.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioTimestampPoller.java new file mode 100644 index 0000000000..153947fec0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioTimestampPoller.java @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.annotation.TargetApi; +import android.media.AudioTimestamp; +import android.media.AudioTrack; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Polls the {@link AudioTrack} timestamp, if the platform supports it, taking care of polling at + * the appropriate rate to detect when the timestamp starts to advance. + * + * <p>When the audio track isn't paused, call {@link #maybePollTimestamp(long)} regularly to check + * for timestamp updates. If it returns {@code true}, call {@link #getTimestampPositionFrames()} and + * {@link #getTimestampSystemTimeUs()} to access the updated timestamp, then call {@link + * #acceptTimestamp()} or {@link #rejectTimestamp()} to accept or reject it. + * + * <p>If {@link #hasTimestamp()} returns {@code true}, call {@link #getTimestampSystemTimeUs()} to + * get the system time at which the latest timestamp was sampled and {@link + * #getTimestampPositionFrames()} to get its position in frames. If {@link #isTimestampAdvancing()} + * returns {@code true}, the caller should assume that the timestamp has been increasing in real + * time since it was sampled. Otherwise, it may be stationary. + * + * <p>Call {@link #reset()} when pausing or resuming the track. + */ +/* package */ final class AudioTimestampPoller { + + /** Timestamp polling states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_INITIALIZING, + STATE_TIMESTAMP, + STATE_TIMESTAMP_ADVANCING, + STATE_NO_TIMESTAMP, + STATE_ERROR + }) + private @interface State {} + /** State when first initializing. */ + private static final int STATE_INITIALIZING = 0; + /** State when we have a timestamp and we don't know if it's advancing. */ + private static final int STATE_TIMESTAMP = 1; + /** State when we have a timestamp and we know it is advancing. */ + private static final int STATE_TIMESTAMP_ADVANCING = 2; + /** State when the no timestamp is available. */ + private static final int STATE_NO_TIMESTAMP = 3; + /** State when the last timestamp was rejected as invalid. */ + private static final int STATE_ERROR = 4; + + /** The polling interval for {@link #STATE_INITIALIZING} and {@link #STATE_TIMESTAMP}. */ + private static final int FAST_POLL_INTERVAL_US = 5_000; + /** + * The polling interval for {@link #STATE_TIMESTAMP_ADVANCING} and {@link #STATE_NO_TIMESTAMP}. + */ + private static final int SLOW_POLL_INTERVAL_US = 10_000_000; + /** The polling interval for {@link #STATE_ERROR}. */ + private static final int ERROR_POLL_INTERVAL_US = 500_000; + + /** + * The minimum duration to remain in {@link #STATE_INITIALIZING} if no timestamps are being + * returned before transitioning to {@link #STATE_NO_TIMESTAMP}. + */ + private static final int INITIALIZING_DURATION_US = 500_000; + + @Nullable private final AudioTimestampV19 audioTimestamp; + + private @State int state; + private long initializeSystemTimeUs; + private long sampleIntervalUs; + private long lastTimestampSampleTimeUs; + private long initialTimestampPositionFrames; + + /** + * Creates a new audio timestamp poller. + * + * @param audioTrack The audio track that will provide timestamps, if the platform supports it. + */ + public AudioTimestampPoller(AudioTrack audioTrack) { + if (Util.SDK_INT >= 19) { + audioTimestamp = new AudioTimestampV19(audioTrack); + reset(); + } else { + audioTimestamp = null; + updateState(STATE_NO_TIMESTAMP); + } + } + + /** + * Polls the timestamp if required and returns whether it was updated. If {@code true}, the latest + * timestamp is available via {@link #getTimestampSystemTimeUs()} and {@link + * #getTimestampPositionFrames()}, and the caller should call {@link #acceptTimestamp()} if the + * timestamp was valid, or {@link #rejectTimestamp()} otherwise. The values returned by {@link + * #hasTimestamp()} and {@link #isTimestampAdvancing()} may be updated. + * + * @param systemTimeUs The current system time, in microseconds. + * @return Whether the timestamp was updated. + */ + public boolean maybePollTimestamp(long systemTimeUs) { + if (audioTimestamp == null || (systemTimeUs - lastTimestampSampleTimeUs) < sampleIntervalUs) { + return false; + } + lastTimestampSampleTimeUs = systemTimeUs; + boolean updatedTimestamp = audioTimestamp.maybeUpdateTimestamp(); + switch (state) { + case STATE_INITIALIZING: + if (updatedTimestamp) { + if (audioTimestamp.getTimestampSystemTimeUs() >= initializeSystemTimeUs) { + // We have an initial timestamp, but don't know if it's advancing yet. + initialTimestampPositionFrames = audioTimestamp.getTimestampPositionFrames(); + updateState(STATE_TIMESTAMP); + } else { + // Drop the timestamp, as it was sampled before the last reset. + updatedTimestamp = false; + } + } else if (systemTimeUs - initializeSystemTimeUs > INITIALIZING_DURATION_US) { + // We haven't received a timestamp for a while, so they probably aren't available for the + // current audio route. Poll infrequently in case the route changes later. + // TODO: Ideally we should listen for audio route changes in order to detect when a + // timestamp becomes available again. + updateState(STATE_NO_TIMESTAMP); + } + break; + case STATE_TIMESTAMP: + if (updatedTimestamp) { + long timestampPositionFrames = audioTimestamp.getTimestampPositionFrames(); + if (timestampPositionFrames > initialTimestampPositionFrames) { + updateState(STATE_TIMESTAMP_ADVANCING); + } + } else { + reset(); + } + break; + case STATE_TIMESTAMP_ADVANCING: + if (!updatedTimestamp) { + // The audio route may have changed, so reset polling. + reset(); + } + break; + case STATE_NO_TIMESTAMP: + if (updatedTimestamp) { + // The audio route may have changed, so reset polling. + reset(); + } + break; + case STATE_ERROR: + // Do nothing. If the caller accepts any new timestamp we'll reset polling. + break; + default: + throw new IllegalStateException(); + } + return updatedTimestamp; + } + + /** + * Rejects the timestamp last polled in {@link #maybePollTimestamp(long)}. The instance will enter + * the error state and poll timestamps infrequently until the next call to {@link + * #acceptTimestamp()}. + */ + public void rejectTimestamp() { + updateState(STATE_ERROR); + } + + /** + * Accepts the timestamp last polled in {@link #maybePollTimestamp(long)}. If the instance is in + * the error state, it will begin to poll timestamps frequently again. + */ + public void acceptTimestamp() { + if (state == STATE_ERROR) { + reset(); + } + } + + /** + * Returns whether this instance has a timestamp that can be used to calculate the audio track + * position. If {@code true}, call {@link #getTimestampSystemTimeUs()} and {@link + * #getTimestampSystemTimeUs()} to access the timestamp. + */ + public boolean hasTimestamp() { + return state == STATE_TIMESTAMP || state == STATE_TIMESTAMP_ADVANCING; + } + + /** + * Returns whether the timestamp appears to be advancing. If {@code true}, call {@link + * #getTimestampSystemTimeUs()} and {@link #getTimestampSystemTimeUs()} to access the timestamp. A + * current position for the track can be extrapolated based on elapsed real time since the system + * time at which the timestamp was sampled. + */ + public boolean isTimestampAdvancing() { + return state == STATE_TIMESTAMP_ADVANCING; + } + + /** Resets polling. Should be called whenever the audio track is paused or resumed. */ + public void reset() { + if (audioTimestamp != null) { + updateState(STATE_INITIALIZING); + } + } + + /** + * If {@link #maybePollTimestamp(long)} or {@link #hasTimestamp()} returned {@code true}, returns + * the system time at which the latest timestamp was sampled, in microseconds. + */ + public long getTimestampSystemTimeUs() { + return audioTimestamp != null ? audioTimestamp.getTimestampSystemTimeUs() : C.TIME_UNSET; + } + + /** + * If {@link #maybePollTimestamp(long)} or {@link #hasTimestamp()} returned {@code true}, returns + * the latest timestamp's position in frames. + */ + public long getTimestampPositionFrames() { + return audioTimestamp != null ? audioTimestamp.getTimestampPositionFrames() : C.POSITION_UNSET; + } + + private void updateState(@State int state) { + this.state = state; + switch (state) { + case STATE_INITIALIZING: + // Force polling a timestamp immediately, and poll quickly. + lastTimestampSampleTimeUs = 0; + initialTimestampPositionFrames = C.POSITION_UNSET; + initializeSystemTimeUs = System.nanoTime() / 1000; + sampleIntervalUs = FAST_POLL_INTERVAL_US; + break; + case STATE_TIMESTAMP: + sampleIntervalUs = FAST_POLL_INTERVAL_US; + break; + case STATE_TIMESTAMP_ADVANCING: + case STATE_NO_TIMESTAMP: + sampleIntervalUs = SLOW_POLL_INTERVAL_US; + break; + case STATE_ERROR: + sampleIntervalUs = ERROR_POLL_INTERVAL_US; + break; + default: + throw new IllegalStateException(); + } + } + + @TargetApi(19) + private static final class AudioTimestampV19 { + + private final AudioTrack audioTrack; + private final AudioTimestamp audioTimestamp; + + private long rawTimestampFramePositionWrapCount; + private long lastTimestampRawPositionFrames; + private long lastTimestampPositionFrames; + + /** + * Creates a new {@link AudioTimestamp} wrapper. + * + * @param audioTrack The audio track that will provide timestamps. + */ + public AudioTimestampV19(AudioTrack audioTrack) { + this.audioTrack = audioTrack; + audioTimestamp = new AudioTimestamp(); + } + + /** + * Attempts to update the audio track timestamp. Returns {@code true} if the timestamp was + * updated, in which case the updated timestamp system time and position can be accessed with + * {@link #getTimestampSystemTimeUs()} and {@link #getTimestampPositionFrames()}. Returns {@code + * false} if no timestamp is available, in which case those methods should not be called. + */ + public boolean maybeUpdateTimestamp() { + boolean updated = audioTrack.getTimestamp(audioTimestamp); + if (updated) { + long rawPositionFrames = audioTimestamp.framePosition; + if (lastTimestampRawPositionFrames > rawPositionFrames) { + // The value must have wrapped around. + rawTimestampFramePositionWrapCount++; + } + lastTimestampRawPositionFrames = rawPositionFrames; + lastTimestampPositionFrames = + rawPositionFrames + (rawTimestampFramePositionWrapCount << 32); + } + return updated; + } + + public long getTimestampSystemTimeUs() { + return audioTimestamp.nanoTime / 1000; + } + + public long getTimestampPositionFrames() { + return lastTimestampPositionFrames; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java new file mode 100644 index 0000000000..e62e8cf2c5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AudioTrackPositionTracker.java @@ -0,0 +1,545 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.media.AudioTimestamp; +import android.media.AudioTrack; +import android.os.SystemClock; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.Method; + +/** + * Wraps an {@link AudioTrack}, exposing a position based on {@link + * AudioTrack#getPlaybackHeadPosition()} and {@link AudioTrack#getTimestamp(AudioTimestamp)}. + * + * <p>Call {@link #setAudioTrack(AudioTrack, int, int, int)} to set the audio track to wrap. Call + * {@link #mayHandleBuffer(long)} if there is input data to write to the track. If it returns false, + * the audio track position is stabilizing and no data may be written. Call {@link #start()} + * immediately before calling {@link AudioTrack#play()}. Call {@link #pause()} when pausing the + * track. Call {@link #handleEndOfStream(long)} when no more data will be written to the track. When + * the audio track will no longer be used, call {@link #reset()}. + */ +/* package */ final class AudioTrackPositionTracker { + + /** Listener for position tracker events. */ + public interface Listener { + + /** + * Called when the frame position is too far from the expected frame position. + * + * @param audioTimestampPositionFrames The frame position of the last known audio track + * timestamp. + * @param audioTimestampSystemTimeUs The system time associated with the last known audio track + * timestamp, in microseconds. + * @param systemTimeUs The current time. + * @param playbackPositionUs The current playback head position in microseconds. + */ + void onPositionFramesMismatch( + long audioTimestampPositionFrames, + long audioTimestampSystemTimeUs, + long systemTimeUs, + long playbackPositionUs); + + /** + * Called when the system time associated with the last known audio track timestamp is + * unexpectedly far from the current time. + * + * @param audioTimestampPositionFrames The frame position of the last known audio track + * timestamp. + * @param audioTimestampSystemTimeUs The system time associated with the last known audio track + * timestamp, in microseconds. + * @param systemTimeUs The current time. + * @param playbackPositionUs The current playback head position in microseconds. + */ + void onSystemTimeUsMismatch( + long audioTimestampPositionFrames, + long audioTimestampSystemTimeUs, + long systemTimeUs, + long playbackPositionUs); + + /** + * Called when the audio track has provided an invalid latency. + * + * @param latencyUs The reported latency in microseconds. + */ + void onInvalidLatency(long latencyUs); + + /** + * Called when the audio track runs out of data to play. + * + * @param bufferSize The size of the sink's buffer, in bytes. + * @param bufferSizeMs The size of the sink's buffer, in milliseconds, if it is configured for + * PCM output. {@link C#TIME_UNSET} if it is configured for encoded audio output, as the + * buffered media can have a variable bitrate so the duration may be unknown. + */ + void onUnderrun(int bufferSize, long bufferSizeMs); + } + + /** {@link AudioTrack} playback states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({PLAYSTATE_STOPPED, PLAYSTATE_PAUSED, PLAYSTATE_PLAYING}) + private @interface PlayState {} + /** @see AudioTrack#PLAYSTATE_STOPPED */ + private static final int PLAYSTATE_STOPPED = AudioTrack.PLAYSTATE_STOPPED; + /** @see AudioTrack#PLAYSTATE_PAUSED */ + private static final int PLAYSTATE_PAUSED = AudioTrack.PLAYSTATE_PAUSED; + /** @see AudioTrack#PLAYSTATE_PLAYING */ + private static final int PLAYSTATE_PLAYING = AudioTrack.PLAYSTATE_PLAYING; + + /** + * AudioTrack timestamps are deemed spurious if they are offset from the system clock by more than + * this amount. + * + * <p>This is a fail safe that should not be required on correctly functioning devices. + */ + private static final long MAX_AUDIO_TIMESTAMP_OFFSET_US = 5 * C.MICROS_PER_SECOND; + + /** + * AudioTrack latencies are deemed impossibly large if they are greater than this amount. + * + * <p>This is a fail safe that should not be required on correctly functioning devices. + */ + private static final long MAX_LATENCY_US = 5 * C.MICROS_PER_SECOND; + + private static final long FORCE_RESET_WORKAROUND_TIMEOUT_MS = 200; + + private static final int MAX_PLAYHEAD_OFFSET_COUNT = 10; + private static final int MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US = 30000; + private static final int MIN_LATENCY_SAMPLE_INTERVAL_US = 500000; + + private final Listener listener; + private final long[] playheadOffsets; + + @Nullable private AudioTrack audioTrack; + private int outputPcmFrameSize; + private int bufferSize; + @Nullable private AudioTimestampPoller audioTimestampPoller; + private int outputSampleRate; + private boolean needsPassthroughWorkarounds; + private long bufferSizeUs; + + private long smoothedPlayheadOffsetUs; + private long lastPlayheadSampleTimeUs; + + @Nullable private Method getLatencyMethod; + private long latencyUs; + private boolean hasData; + + private boolean isOutputPcm; + private long lastLatencySampleTimeUs; + private long lastRawPlaybackHeadPosition; + private long rawPlaybackHeadWrapCount; + private long passthroughWorkaroundPauseOffset; + private int nextPlayheadOffsetIndex; + private int playheadOffsetCount; + private long stopTimestampUs; + private long forceResetWorkaroundTimeMs; + private long stopPlaybackHeadPosition; + private long endPlaybackHeadPosition; + + /** + * Creates a new audio track position tracker. + * + * @param listener A listener for position tracking events. + */ + public AudioTrackPositionTracker(Listener listener) { + this.listener = Assertions.checkNotNull(listener); + if (Util.SDK_INT >= 18) { + try { + getLatencyMethod = AudioTrack.class.getMethod("getLatency", (Class<?>[]) null); + } catch (NoSuchMethodException e) { + // There's no guarantee this method exists. Do nothing. + } + } + playheadOffsets = new long[MAX_PLAYHEAD_OFFSET_COUNT]; + } + + /** + * Sets the {@link AudioTrack} to wrap. Subsequent method calls on this instance relate to this + * track's position, until the next call to {@link #reset()}. + * + * @param audioTrack The audio track to wrap. + * @param outputEncoding The encoding of the audio track. + * @param outputPcmFrameSize For PCM output encodings, the frame size. The value is ignored + * otherwise. + * @param bufferSize The audio track buffer size in bytes. + */ + public void setAudioTrack( + AudioTrack audioTrack, + @C.Encoding int outputEncoding, + int outputPcmFrameSize, + int bufferSize) { + this.audioTrack = audioTrack; + this.outputPcmFrameSize = outputPcmFrameSize; + this.bufferSize = bufferSize; + audioTimestampPoller = new AudioTimestampPoller(audioTrack); + outputSampleRate = audioTrack.getSampleRate(); + needsPassthroughWorkarounds = needsPassthroughWorkarounds(outputEncoding); + isOutputPcm = Util.isEncodingLinearPcm(outputEncoding); + bufferSizeUs = isOutputPcm ? framesToDurationUs(bufferSize / outputPcmFrameSize) : C.TIME_UNSET; + lastRawPlaybackHeadPosition = 0; + rawPlaybackHeadWrapCount = 0; + passthroughWorkaroundPauseOffset = 0; + hasData = false; + stopTimestampUs = C.TIME_UNSET; + forceResetWorkaroundTimeMs = C.TIME_UNSET; + latencyUs = 0; + } + + public long getCurrentPositionUs(boolean sourceEnded) { + if (Assertions.checkNotNull(this.audioTrack).getPlayState() == PLAYSTATE_PLAYING) { + maybeSampleSyncParams(); + } + + // If the device supports it, use the playback timestamp from AudioTrack.getTimestamp. + // Otherwise, derive a smoothed position by sampling the track's frame position. + long systemTimeUs = System.nanoTime() / 1000; + AudioTimestampPoller audioTimestampPoller = Assertions.checkNotNull(this.audioTimestampPoller); + if (audioTimestampPoller.hasTimestamp()) { + // Calculate the speed-adjusted position using the timestamp (which may be in the future). + long timestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); + long timestampPositionUs = framesToDurationUs(timestampPositionFrames); + if (!audioTimestampPoller.isTimestampAdvancing()) { + return timestampPositionUs; + } + long elapsedSinceTimestampUs = systemTimeUs - audioTimestampPoller.getTimestampSystemTimeUs(); + return timestampPositionUs + elapsedSinceTimestampUs; + } else { + long positionUs; + if (playheadOffsetCount == 0) { + // The AudioTrack has started, but we don't have any samples to compute a smoothed position. + positionUs = getPlaybackHeadPositionUs(); + } else { + // getPlaybackHeadPositionUs() only has a granularity of ~20 ms, so we base the position off + // the system clock (and a smoothed offset between it and the playhead position) so as to + // prevent jitter in the reported positions. + positionUs = systemTimeUs + smoothedPlayheadOffsetUs; + } + if (!sourceEnded) { + positionUs -= latencyUs; + } + return positionUs; + } + } + + /** Starts position tracking. Must be called immediately before {@link AudioTrack#play()}. */ + public void start() { + Assertions.checkNotNull(audioTimestampPoller).reset(); + } + + /** Returns whether the audio track is in the playing state. */ + public boolean isPlaying() { + return Assertions.checkNotNull(audioTrack).getPlayState() == PLAYSTATE_PLAYING; + } + + /** + * Checks the state of the audio track and returns whether the caller can write data to the track. + * Notifies {@link Listener#onUnderrun(int, long)} if the track has underrun. + * + * @param writtenFrames The number of frames that have been written. + * @return Whether the caller can write data to the track. + */ + public boolean mayHandleBuffer(long writtenFrames) { + @PlayState int playState = Assertions.checkNotNull(audioTrack).getPlayState(); + if (needsPassthroughWorkarounds) { + // An AC-3 audio track continues to play data written while it is paused. Stop writing so its + // buffer empties. See [Internal: b/18899620]. + if (playState == PLAYSTATE_PAUSED) { + // We force an underrun to pause the track, so don't notify the listener in this case. + hasData = false; + return false; + } + + // A new AC-3 audio track's playback position continues to increase from the old track's + // position for a short time after is has been released. Avoid writing data until the playback + // head position actually returns to zero. + if (playState == PLAYSTATE_STOPPED && getPlaybackHeadPosition() == 0) { + return false; + } + } + + boolean hadData = hasData; + hasData = hasPendingData(writtenFrames); + if (hadData && !hasData && playState != PLAYSTATE_STOPPED && listener != null) { + listener.onUnderrun(bufferSize, C.usToMs(bufferSizeUs)); + } + + return true; + } + + /** + * Returns an estimate of the number of additional bytes that can be written to the audio track's + * buffer without running out of space. + * + * <p>May only be called if the output encoding is one of the PCM encodings. + * + * @param writtenBytes The number of bytes written to the audio track so far. + * @return An estimate of the number of bytes that can be written. + */ + public int getAvailableBufferSize(long writtenBytes) { + int bytesPending = (int) (writtenBytes - (getPlaybackHeadPosition() * outputPcmFrameSize)); + return bufferSize - bytesPending; + } + + /** Returns whether the track is in an invalid state and must be recreated. */ + public boolean isStalled(long writtenFrames) { + return forceResetWorkaroundTimeMs != C.TIME_UNSET + && writtenFrames > 0 + && SystemClock.elapsedRealtime() - forceResetWorkaroundTimeMs + >= FORCE_RESET_WORKAROUND_TIMEOUT_MS; + } + + /** + * Records the writing position at which the stream ended, so that the reported position can + * continue to increment while remaining data is played out. + * + * @param writtenFrames The number of frames that have been written. + */ + public void handleEndOfStream(long writtenFrames) { + stopPlaybackHeadPosition = getPlaybackHeadPosition(); + stopTimestampUs = SystemClock.elapsedRealtime() * 1000; + endPlaybackHeadPosition = writtenFrames; + } + + /** + * Returns whether the audio track has any pending data to play out at its current position. + * + * @param writtenFrames The number of frames written to the audio track. + * @return Whether the audio track has any pending data to play out. + */ + public boolean hasPendingData(long writtenFrames) { + return writtenFrames > getPlaybackHeadPosition() + || forceHasPendingData(); + } + + /** + * Pauses the audio track position tracker, returning whether the audio track needs to be paused + * to cause playback to pause. If {@code false} is returned the audio track will pause without + * further interaction, as the end of stream has been handled. + */ + public boolean pause() { + resetSyncParams(); + if (stopTimestampUs == C.TIME_UNSET) { + // The audio track is going to be paused, so reset the timestamp poller to ensure it doesn't + // supply an advancing position. + Assertions.checkNotNull(audioTimestampPoller).reset(); + return true; + } + // We've handled the end of the stream already, so there's no need to pause the track. + return false; + } + + /** + * Resets the position tracker. Should be called when the audio track previous passed to {@link + * #setAudioTrack(AudioTrack, int, int, int)} is no longer in use. + */ + public void reset() { + resetSyncParams(); + audioTrack = null; + audioTimestampPoller = null; + } + + private void maybeSampleSyncParams() { + long playbackPositionUs = getPlaybackHeadPositionUs(); + if (playbackPositionUs == 0) { + // The AudioTrack hasn't output anything yet. + return; + } + long systemTimeUs = System.nanoTime() / 1000; + if (systemTimeUs - lastPlayheadSampleTimeUs >= MIN_PLAYHEAD_OFFSET_SAMPLE_INTERVAL_US) { + // Take a new sample and update the smoothed offset between the system clock and the playhead. + playheadOffsets[nextPlayheadOffsetIndex] = playbackPositionUs - systemTimeUs; + nextPlayheadOffsetIndex = (nextPlayheadOffsetIndex + 1) % MAX_PLAYHEAD_OFFSET_COUNT; + if (playheadOffsetCount < MAX_PLAYHEAD_OFFSET_COUNT) { + playheadOffsetCount++; + } + lastPlayheadSampleTimeUs = systemTimeUs; + smoothedPlayheadOffsetUs = 0; + for (int i = 0; i < playheadOffsetCount; i++) { + smoothedPlayheadOffsetUs += playheadOffsets[i] / playheadOffsetCount; + } + } + + if (needsPassthroughWorkarounds) { + // Don't sample the timestamp and latency if this is an AC-3 passthrough AudioTrack on + // platform API versions 21/22, as incorrect values are returned. See [Internal: b/21145353]. + return; + } + + maybePollAndCheckTimestamp(systemTimeUs, playbackPositionUs); + maybeUpdateLatency(systemTimeUs); + } + + private void maybePollAndCheckTimestamp(long systemTimeUs, long playbackPositionUs) { + AudioTimestampPoller audioTimestampPoller = Assertions.checkNotNull(this.audioTimestampPoller); + if (!audioTimestampPoller.maybePollTimestamp(systemTimeUs)) { + return; + } + + // Perform sanity checks on the timestamp and accept/reject it. + long audioTimestampSystemTimeUs = audioTimestampPoller.getTimestampSystemTimeUs(); + long audioTimestampPositionFrames = audioTimestampPoller.getTimestampPositionFrames(); + if (Math.abs(audioTimestampSystemTimeUs - systemTimeUs) > MAX_AUDIO_TIMESTAMP_OFFSET_US) { + listener.onSystemTimeUsMismatch( + audioTimestampPositionFrames, + audioTimestampSystemTimeUs, + systemTimeUs, + playbackPositionUs); + audioTimestampPoller.rejectTimestamp(); + } else if (Math.abs(framesToDurationUs(audioTimestampPositionFrames) - playbackPositionUs) + > MAX_AUDIO_TIMESTAMP_OFFSET_US) { + listener.onPositionFramesMismatch( + audioTimestampPositionFrames, + audioTimestampSystemTimeUs, + systemTimeUs, + playbackPositionUs); + audioTimestampPoller.rejectTimestamp(); + } else { + audioTimestampPoller.acceptTimestamp(); + } + } + + private void maybeUpdateLatency(long systemTimeUs) { + if (isOutputPcm + && getLatencyMethod != null + && systemTimeUs - lastLatencySampleTimeUs >= MIN_LATENCY_SAMPLE_INTERVAL_US) { + try { + // Compute the audio track latency, excluding the latency due to the buffer (leaving + // latency due to the mixer and audio hardware driver). + latencyUs = + castNonNull((Integer) getLatencyMethod.invoke(Assertions.checkNotNull(audioTrack))) + * 1000L + - bufferSizeUs; + // Sanity check that the latency is non-negative. + latencyUs = Math.max(latencyUs, 0); + // Sanity check that the latency isn't too large. + if (latencyUs > MAX_LATENCY_US) { + listener.onInvalidLatency(latencyUs); + latencyUs = 0; + } + } catch (Exception e) { + // The method existed, but doesn't work. Don't try again. + getLatencyMethod = null; + } + lastLatencySampleTimeUs = systemTimeUs; + } + } + + private long framesToDurationUs(long frameCount) { + return (frameCount * C.MICROS_PER_SECOND) / outputSampleRate; + } + + private void resetSyncParams() { + smoothedPlayheadOffsetUs = 0; + playheadOffsetCount = 0; + nextPlayheadOffsetIndex = 0; + lastPlayheadSampleTimeUs = 0; + } + + /** + * If passthrough workarounds are enabled, pausing is implemented by forcing the AudioTrack to + * underrun. In this case, still behave as if we have pending data, otherwise writing won't + * resume. + */ + private boolean forceHasPendingData() { + return needsPassthroughWorkarounds + && Assertions.checkNotNull(audioTrack).getPlayState() == AudioTrack.PLAYSTATE_PAUSED + && getPlaybackHeadPosition() == 0; + } + + /** + * Returns whether to work around problems with passthrough audio tracks. See [Internal: + * b/18899620, b/19187573, b/21145353]. + */ + private static boolean needsPassthroughWorkarounds(@C.Encoding int outputEncoding) { + return Util.SDK_INT < 23 + && (outputEncoding == C.ENCODING_AC3 || outputEncoding == C.ENCODING_E_AC3); + } + + private long getPlaybackHeadPositionUs() { + return framesToDurationUs(getPlaybackHeadPosition()); + } + + /** + * {@link AudioTrack#getPlaybackHeadPosition()} returns a value intended to be interpreted as an + * unsigned 32 bit integer, which also wraps around periodically. This method returns the playback + * head position as a long that will only wrap around if the value exceeds {@link Long#MAX_VALUE} + * (which in practice will never happen). + * + * @return The playback head position, in frames. + */ + private long getPlaybackHeadPosition() { + AudioTrack audioTrack = Assertions.checkNotNull(this.audioTrack); + if (stopTimestampUs != C.TIME_UNSET) { + // Simulate the playback head position up to the total number of frames submitted. + long elapsedTimeSinceStopUs = (SystemClock.elapsedRealtime() * 1000) - stopTimestampUs; + long framesSinceStop = (elapsedTimeSinceStopUs * outputSampleRate) / C.MICROS_PER_SECOND; + return Math.min(endPlaybackHeadPosition, stopPlaybackHeadPosition + framesSinceStop); + } + + int state = audioTrack.getPlayState(); + if (state == PLAYSTATE_STOPPED) { + // The audio track hasn't been started. + return 0; + } + + long rawPlaybackHeadPosition = 0xFFFFFFFFL & audioTrack.getPlaybackHeadPosition(); + if (needsPassthroughWorkarounds) { + // Work around an issue with passthrough/direct AudioTracks on platform API versions 21/22 + // where the playback head position jumps back to zero on paused passthrough/direct audio + // tracks. See [Internal: b/19187573]. + if (state == PLAYSTATE_PAUSED && rawPlaybackHeadPosition == 0) { + passthroughWorkaroundPauseOffset = lastRawPlaybackHeadPosition; + } + rawPlaybackHeadPosition += passthroughWorkaroundPauseOffset; + } + + if (Util.SDK_INT <= 29) { + if (rawPlaybackHeadPosition == 0 + && lastRawPlaybackHeadPosition > 0 + && state == PLAYSTATE_PLAYING) { + // If connecting a Bluetooth audio device fails, the AudioTrack may be left in a state + // where its Java API is in the playing state, but the native track is stopped. When this + // happens the playback head position gets stuck at zero. In this case, return the old + // playback head position and force the track to be reset after + // {@link #FORCE_RESET_WORKAROUND_TIMEOUT_MS} has elapsed. + if (forceResetWorkaroundTimeMs == C.TIME_UNSET) { + forceResetWorkaroundTimeMs = SystemClock.elapsedRealtime(); + } + return lastRawPlaybackHeadPosition; + } else { + forceResetWorkaroundTimeMs = C.TIME_UNSET; + } + } + + if (lastRawPlaybackHeadPosition > rawPlaybackHeadPosition) { + // The value must have wrapped around. + rawPlaybackHeadWrapCount++; + } + lastRawPlaybackHeadPosition = rawPlaybackHeadPosition; + return rawPlaybackHeadPosition + (rawPlaybackHeadWrapCount << 32); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AuxEffectInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AuxEffectInfo.java new file mode 100644 index 0000000000..6039a8c1a8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/AuxEffectInfo.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.media.AudioTrack; +import android.media.audiofx.AudioEffect; +import androidx.annotation.Nullable; + +/** + * Represents auxiliary effect information, which can be used to attach an auxiliary effect to an + * underlying {@link AudioTrack}. + * + * <p>Auxiliary effects can only be applied if the application has the {@code + * android.permission.MODIFY_AUDIO_SETTINGS} permission. Apps are responsible for retaining the + * associated audio effect instance and releasing it when it's no longer needed. See the + * documentation of {@link AudioEffect} for more information. + */ +public final class AuxEffectInfo { + + /** Value for {@link #effectId} representing no auxiliary effect. */ + public static final int NO_AUX_EFFECT_ID = 0; + + /** + * The identifier of the effect, or {@link #NO_AUX_EFFECT_ID} if there is no effect. + * + * @see android.media.AudioTrack#attachAuxEffect(int) + */ + public final int effectId; + /** + * The send level for the effect. + * + * @see android.media.AudioTrack#setAuxEffectSendLevel(float) + */ + public final float sendLevel; + + /** + * Creates an instance with the given effect identifier and send level. + * + * @param effectId The effect identifier. This is the value returned by {@link + * AudioEffect#getId()} on the effect, or {@value NO_AUX_EFFECT_ID} which represents no + * effect. This value is passed to {@link AudioTrack#attachAuxEffect(int)} on the underlying + * audio track. + * @param sendLevel The send level for the effect, where 0 represents no effect and a value of 1 + * is full send. If {@code effectId} is not {@value #NO_AUX_EFFECT_ID}, this value is passed + * to {@link AudioTrack#setAuxEffectSendLevel(float)} on the underlying audio track. + */ + public AuxEffectInfo(int effectId, float sendLevel) { + this.effectId = effectId; + this.sendLevel = sendLevel; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AuxEffectInfo auxEffectInfo = (AuxEffectInfo) o; + return effectId == auxEffectInfo.effectId + && Float.compare(auxEffectInfo.sendLevel, sendLevel) == 0; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + effectId; + result = 31 * result + Float.floatToIntBits(sendLevel); + return result; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/BaseAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/BaseAudioProcessor.java new file mode 100644 index 0000000000..189d8f0265 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/BaseAudioProcessor.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.CallSuper; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Base class for audio processors that keep an output buffer and an internal buffer that is reused + * whenever input is queued. Subclasses should override {@link #onConfigure(AudioFormat)} to return + * the output audio format for the processor if it's active. + */ +public abstract class BaseAudioProcessor implements AudioProcessor { + + /** The current input audio format. */ + protected AudioFormat inputAudioFormat; + /** The current output audio format. */ + protected AudioFormat outputAudioFormat; + + private AudioFormat pendingInputAudioFormat; + private AudioFormat pendingOutputAudioFormat; + private ByteBuffer buffer; + private ByteBuffer outputBuffer; + private boolean inputEnded; + + public BaseAudioProcessor() { + buffer = EMPTY_BUFFER; + outputBuffer = EMPTY_BUFFER; + pendingInputAudioFormat = AudioFormat.NOT_SET; + pendingOutputAudioFormat = AudioFormat.NOT_SET; + inputAudioFormat = AudioFormat.NOT_SET; + outputAudioFormat = AudioFormat.NOT_SET; + } + + @Override + public final AudioFormat configure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + pendingInputAudioFormat = inputAudioFormat; + pendingOutputAudioFormat = onConfigure(inputAudioFormat); + return isActive() ? pendingOutputAudioFormat : AudioFormat.NOT_SET; + } + + @Override + public boolean isActive() { + return pendingOutputAudioFormat != AudioFormat.NOT_SET; + } + + @Override + public final void queueEndOfStream() { + inputEnded = true; + onQueueEndOfStream(); + } + + @CallSuper + @Override + public ByteBuffer getOutput() { + ByteBuffer outputBuffer = this.outputBuffer; + this.outputBuffer = EMPTY_BUFFER; + return outputBuffer; + } + + @CallSuper + @SuppressWarnings("ReferenceEquality") + @Override + public boolean isEnded() { + return inputEnded && outputBuffer == EMPTY_BUFFER; + } + + @Override + public final void flush() { + outputBuffer = EMPTY_BUFFER; + inputEnded = false; + inputAudioFormat = pendingInputAudioFormat; + outputAudioFormat = pendingOutputAudioFormat; + onFlush(); + } + + @Override + public final void reset() { + flush(); + buffer = EMPTY_BUFFER; + pendingInputAudioFormat = AudioFormat.NOT_SET; + pendingOutputAudioFormat = AudioFormat.NOT_SET; + inputAudioFormat = AudioFormat.NOT_SET; + outputAudioFormat = AudioFormat.NOT_SET; + onReset(); + } + + /** + * Replaces the current output buffer with a buffer of at least {@code count} bytes and returns + * it. Callers should write to the returned buffer then {@link ByteBuffer#flip()} it so it can be + * read via {@link #getOutput()}. + */ + protected final ByteBuffer replaceOutputBuffer(int count) { + if (buffer.capacity() < count) { + buffer = ByteBuffer.allocateDirect(count).order(ByteOrder.nativeOrder()); + } else { + buffer.clear(); + } + outputBuffer = buffer; + return buffer; + } + + /** Returns whether the current output buffer has any data remaining. */ + protected final boolean hasPendingOutput() { + return outputBuffer.hasRemaining(); + } + + /** Called when the processor is configured for a new input format. */ + protected AudioFormat onConfigure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + return AudioFormat.NOT_SET; + } + + /** Called when the end-of-stream is queued to the processor. */ + protected void onQueueEndOfStream() { + // Do nothing. + } + + /** Called when the processor is flushed, directly or as part of resetting. */ + protected void onFlush() { + // Do nothing. + } + + /** Called when the processor is reset. */ + protected void onReset() { + // Do nothing. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java new file mode 100644 index 0000000000..e8496d4608 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ChannelMappingAudioProcessor.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; + +/** + * An {@link AudioProcessor} that applies a mapping from input channels onto specified output + * channels. This can be used to reorder, duplicate or discard channels. + */ +@SuppressWarnings("nullness:initialization.fields.uninitialized") +/* package */ final class ChannelMappingAudioProcessor extends BaseAudioProcessor { + + @Nullable private int[] pendingOutputChannels; + @Nullable private int[] outputChannels; + + /** + * Resets the channel mapping. After calling this method, call {@link #configure(AudioFormat)} to + * start using the new channel map. + * + * @param outputChannels The mapping from input to output channel indices, or {@code null} to + * leave the input unchanged. + * @see AudioSink#configure(int, int, int, int, int[], int, int) + */ + public void setChannelMap(@Nullable int[] outputChannels) { + pendingOutputChannels = outputChannels; + } + + @Override + public AudioFormat onConfigure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + @Nullable int[] outputChannels = pendingOutputChannels; + if (outputChannels == null) { + return AudioFormat.NOT_SET; + } + + if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + + boolean active = inputAudioFormat.channelCount != outputChannels.length; + for (int i = 0; i < outputChannels.length; i++) { + int channelIndex = outputChannels[i]; + if (channelIndex >= inputAudioFormat.channelCount) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + active |= (channelIndex != i); + } + return active + ? new AudioFormat(inputAudioFormat.sampleRate, outputChannels.length, C.ENCODING_PCM_16BIT) + : AudioFormat.NOT_SET; + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + int[] outputChannels = Assertions.checkNotNull(this.outputChannels); + int position = inputBuffer.position(); + int limit = inputBuffer.limit(); + int frameCount = (limit - position) / inputAudioFormat.bytesPerFrame; + int outputSize = frameCount * outputAudioFormat.bytesPerFrame; + ByteBuffer buffer = replaceOutputBuffer(outputSize); + while (position < limit) { + for (int channelIndex : outputChannels) { + buffer.putShort(inputBuffer.getShort(position + 2 * channelIndex)); + } + position += inputAudioFormat.bytesPerFrame; + } + inputBuffer.position(limit); + buffer.flip(); + } + + @Override + protected void onFlush() { + outputChannels = pendingOutputChannels; + } + + @Override + protected void onReset() { + outputChannels = null; + pendingOutputChannels = null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/DefaultAudioSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/DefaultAudioSink.java new file mode 100644 index 0000000000..9fc3fbbfd8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/DefaultAudioSink.java @@ -0,0 +1,1474 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.AudioFormat; +import android.media.AudioManager; +import android.media.AudioTrack; +import android.os.ConditionVariable; +import android.os.SystemClock; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.audio.AudioProcessor.UnhandledAudioFormatException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; + +/** + * Plays audio data. The implementation delegates to an {@link AudioTrack} and handles playback + * position smoothing, non-blocking writes and reconfiguration. + * <p> + * If tunneling mode is enabled, care must be taken that audio processors do not output buffers with + * a different duration than their input, and buffer processors must produce output corresponding to + * their last input immediately after that input is queued. This means that, for example, speed + * adjustment is not possible while using tunneling. + */ +public final class DefaultAudioSink implements AudioSink { + + /** + * Thrown when the audio track has provided a spurious timestamp, if {@link + * #failOnSpuriousAudioTimestamp} is set. + */ + public static final class InvalidAudioTrackTimestampException extends RuntimeException { + + /** + * Creates a new invalid timestamp exception with the specified message. + * + * @param message The detail message for this exception. + */ + private InvalidAudioTrackTimestampException(String message) { + super(message); + } + + } + + /** + * Provides a chain of audio processors, which are used for any user-defined processing and + * applying playback parameters (if supported). Because applying playback parameters can skip and + * stretch/compress audio, the sink will query the chain for information on how to transform its + * output position to map it onto a media position, via {@link #getMediaDuration(long)} and {@link + * #getSkippedOutputFrameCount()}. + */ + public interface AudioProcessorChain { + + /** + * Returns the fixed chain of audio processors that will process audio. This method is called + * once during initialization, but audio processors may change state to become active/inactive + * during playback. + */ + AudioProcessor[] getAudioProcessors(); + + /** + * Configures audio processors to apply the specified playback parameters immediately, returning + * the new parameters, which may differ from those passed in. Only called when processors have + * no input pending. + * + * @param playbackParameters The playback parameters to try to apply. + * @return The playback parameters that were actually applied. + */ + PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters); + + /** + * Scales the specified playout duration to take into account speedup due to audio processing, + * returning an input media duration, in arbitrary units. + */ + long getMediaDuration(long playoutDuration); + + /** + * Returns the number of output audio frames skipped since the audio processors were last + * flushed. + */ + long getSkippedOutputFrameCount(); + } + + /** + * The default audio processor chain, which applies a (possibly empty) chain of user-defined audio + * processors followed by {@link SilenceSkippingAudioProcessor} and {@link SonicAudioProcessor}. + */ + public static class DefaultAudioProcessorChain implements AudioProcessorChain { + + private final AudioProcessor[] audioProcessors; + private final SilenceSkippingAudioProcessor silenceSkippingAudioProcessor; + private final SonicAudioProcessor sonicAudioProcessor; + + /** + * Creates a new default chain of audio processors, with the user-defined {@code + * audioProcessors} applied before silence skipping and playback parameters. + */ + public DefaultAudioProcessorChain(AudioProcessor... audioProcessors) { + // The passed-in type may be more specialized than AudioProcessor[], so allocate a new array + // rather than using Arrays.copyOf. + this.audioProcessors = new AudioProcessor[audioProcessors.length + 2]; + System.arraycopy( + /* src= */ audioProcessors, + /* srcPos= */ 0, + /* dest= */ this.audioProcessors, + /* destPos= */ 0, + /* length= */ audioProcessors.length); + silenceSkippingAudioProcessor = new SilenceSkippingAudioProcessor(); + sonicAudioProcessor = new SonicAudioProcessor(); + this.audioProcessors[audioProcessors.length] = silenceSkippingAudioProcessor; + this.audioProcessors[audioProcessors.length + 1] = sonicAudioProcessor; + } + + @Override + public AudioProcessor[] getAudioProcessors() { + return audioProcessors; + } + + @Override + public PlaybackParameters applyPlaybackParameters(PlaybackParameters playbackParameters) { + silenceSkippingAudioProcessor.setEnabled(playbackParameters.skipSilence); + return new PlaybackParameters( + sonicAudioProcessor.setSpeed(playbackParameters.speed), + sonicAudioProcessor.setPitch(playbackParameters.pitch), + playbackParameters.skipSilence); + } + + @Override + public long getMediaDuration(long playoutDuration) { + return sonicAudioProcessor.scaleDurationForSpeedup(playoutDuration); + } + + @Override + public long getSkippedOutputFrameCount() { + return silenceSkippingAudioProcessor.getSkippedFrames(); + } + } + + /** + * A minimum length for the {@link AudioTrack} buffer, in microseconds. + */ + private static final long MIN_BUFFER_DURATION_US = 250000; + /** + * A maximum length for the {@link AudioTrack} buffer, in microseconds. + */ + private static final long MAX_BUFFER_DURATION_US = 750000; + /** + * The length for passthrough {@link AudioTrack} buffers, in microseconds. + */ + private static final long PASSTHROUGH_BUFFER_DURATION_US = 250000; + /** + * A multiplication factor to apply to the minimum buffer size requested by the underlying + * {@link AudioTrack}. + */ + private static final int BUFFER_MULTIPLICATION_FACTOR = 4; + + /** To avoid underruns on some devices (e.g., Broadcom 7271), scale up the AC3 buffer duration. */ + private static final int AC3_BUFFER_MULTIPLICATION_FACTOR = 2; + + /** + * @see AudioTrack#ERROR_BAD_VALUE + */ + private static final int ERROR_BAD_VALUE = AudioTrack.ERROR_BAD_VALUE; + /** + * @see AudioTrack#MODE_STATIC + */ + private static final int MODE_STATIC = AudioTrack.MODE_STATIC; + /** + * @see AudioTrack#MODE_STREAM + */ + private static final int MODE_STREAM = AudioTrack.MODE_STREAM; + /** + * @see AudioTrack#STATE_INITIALIZED + */ + private static final int STATE_INITIALIZED = AudioTrack.STATE_INITIALIZED; + /** + * @see AudioTrack#WRITE_NON_BLOCKING + */ + @SuppressLint("InlinedApi") + private static final int WRITE_NON_BLOCKING = AudioTrack.WRITE_NON_BLOCKING; + + private static final String TAG = "AudioTrack"; + + /** Represents states of the {@link #startMediaTimeUs} value. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({START_NOT_SET, START_IN_SYNC, START_NEED_SYNC}) + private @interface StartMediaTimeState {} + + private static final int START_NOT_SET = 0; + private static final int START_IN_SYNC = 1; + private static final int START_NEED_SYNC = 2; + + /** + * Whether to enable a workaround for an issue where an audio effect does not keep its session + * active across releasing/initializing a new audio track, on platform builds where + * {@link Util#SDK_INT} < 21. + * <p> + * The flag must be set before creating a player. + */ + public static boolean enablePreV21AudioSessionWorkaround = false; + + /** + * Whether to throw an {@link InvalidAudioTrackTimestampException} when a spurious timestamp is + * reported from {@link AudioTrack#getTimestamp}. + * <p> + * The flag must be set before creating a player. Should be set to {@code true} for testing and + * debugging purposes only. + */ + public static boolean failOnSpuriousAudioTimestamp = false; + + @Nullable private final AudioCapabilities audioCapabilities; + private final AudioProcessorChain audioProcessorChain; + private final boolean enableFloatOutput; + private final ChannelMappingAudioProcessor channelMappingAudioProcessor; + private final TrimmingAudioProcessor trimmingAudioProcessor; + private final AudioProcessor[] toIntPcmAvailableAudioProcessors; + private final AudioProcessor[] toFloatPcmAvailableAudioProcessors; + private final ConditionVariable releasingConditionVariable; + private final AudioTrackPositionTracker audioTrackPositionTracker; + private final ArrayDeque<PlaybackParametersCheckpoint> playbackParametersCheckpoints; + + @Nullable private Listener listener; + /** Used to keep the audio session active on pre-V21 builds (see {@link #initialize(long)}). */ + @Nullable private AudioTrack keepSessionIdAudioTrack; + + @Nullable private Configuration pendingConfiguration; + private Configuration configuration; + private AudioTrack audioTrack; + + private AudioAttributes audioAttributes; + @Nullable private PlaybackParameters afterDrainPlaybackParameters; + private PlaybackParameters playbackParameters; + private long playbackParametersOffsetUs; + private long playbackParametersPositionUs; + + @Nullable private ByteBuffer avSyncHeader; + private int bytesUntilNextAvSync; + + private long submittedPcmBytes; + private long submittedEncodedFrames; + private long writtenPcmBytes; + private long writtenEncodedFrames; + private int framesPerEncodedSample; + private @StartMediaTimeState int startMediaTimeState; + private long startMediaTimeUs; + private float volume; + + private AudioProcessor[] activeAudioProcessors; + private ByteBuffer[] outputBuffers; + @Nullable private ByteBuffer inputBuffer; + @Nullable private ByteBuffer outputBuffer; + private byte[] preV21OutputBuffer; + private int preV21OutputBufferOffset; + private int drainingAudioProcessorIndex; + private boolean handledEndOfStream; + private boolean stoppedAudioTrack; + + private boolean playing; + private int audioSessionId; + private AuxEffectInfo auxEffectInfo; + private boolean tunneling; + private long lastFeedElapsedRealtimeMs; + + /** + * Creates a new default audio sink. + * + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio before + * output. May be empty. + */ + public DefaultAudioSink( + @Nullable AudioCapabilities audioCapabilities, AudioProcessor[] audioProcessors) { + this(audioCapabilities, audioProcessors, /* enableFloatOutput= */ false); + } + + /** + * Creates a new default audio sink, optionally using float output for high resolution PCM. + * + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + * @param audioProcessors An array of {@link AudioProcessor}s that will process PCM audio before + * output. May be empty. + * @param enableFloatOutput Whether to enable 32-bit float output. Where possible, 32-bit float + * output will be used if the input is 32-bit float, and also if the input is high resolution + * (24-bit or 32-bit) integer PCM. Audio processing (for example, speed adjustment) will not + * be available when float output is in use. + */ + public DefaultAudioSink( + @Nullable AudioCapabilities audioCapabilities, + AudioProcessor[] audioProcessors, + boolean enableFloatOutput) { + this(audioCapabilities, new DefaultAudioProcessorChain(audioProcessors), enableFloatOutput); + } + + /** + * Creates a new default audio sink, optionally using float output for high resolution PCM and + * with the specified {@code audioProcessorChain}. + * + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + * @param audioProcessorChain An {@link AudioProcessorChain} which is used to apply playback + * parameters adjustments. The instance passed in must not be reused in other sinks. + * @param enableFloatOutput Whether to enable 32-bit float output. Where possible, 32-bit float + * output will be used if the input is 32-bit float, and also if the input is high resolution + * (24-bit or 32-bit) integer PCM. Audio processing (for example, speed adjustment) will not + * be available when float output is in use. + */ + public DefaultAudioSink( + @Nullable AudioCapabilities audioCapabilities, + AudioProcessorChain audioProcessorChain, + boolean enableFloatOutput) { + this.audioCapabilities = audioCapabilities; + this.audioProcessorChain = Assertions.checkNotNull(audioProcessorChain); + this.enableFloatOutput = enableFloatOutput; + releasingConditionVariable = new ConditionVariable(true); + audioTrackPositionTracker = new AudioTrackPositionTracker(new PositionTrackerListener()); + channelMappingAudioProcessor = new ChannelMappingAudioProcessor(); + trimmingAudioProcessor = new TrimmingAudioProcessor(); + ArrayList<AudioProcessor> toIntPcmAudioProcessors = new ArrayList<>(); + Collections.addAll( + toIntPcmAudioProcessors, + new ResamplingAudioProcessor(), + channelMappingAudioProcessor, + trimmingAudioProcessor); + Collections.addAll(toIntPcmAudioProcessors, audioProcessorChain.getAudioProcessors()); + toIntPcmAvailableAudioProcessors = toIntPcmAudioProcessors.toArray(new AudioProcessor[0]); + toFloatPcmAvailableAudioProcessors = new AudioProcessor[] {new FloatResamplingAudioProcessor()}; + volume = 1.0f; + startMediaTimeState = START_NOT_SET; + audioAttributes = AudioAttributes.DEFAULT; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + auxEffectInfo = new AuxEffectInfo(AuxEffectInfo.NO_AUX_EFFECT_ID, 0f); + playbackParameters = PlaybackParameters.DEFAULT; + drainingAudioProcessorIndex = C.INDEX_UNSET; + activeAudioProcessors = new AudioProcessor[0]; + outputBuffers = new ByteBuffer[0]; + playbackParametersCheckpoints = new ArrayDeque<>(); + } + + // AudioSink implementation. + + @Override + public void setListener(Listener listener) { + this.listener = listener; + } + + @Override + public boolean supportsOutput(int channelCount, @C.Encoding int encoding) { + if (Util.isEncodingLinearPcm(encoding)) { + // AudioTrack supports 16-bit integer PCM output in all platform API versions, and float + // output from platform API version 21 only. Other integer PCM encodings are resampled by this + // sink to 16-bit PCM. We assume that the audio framework will downsample any number of + // channels to the output device's required number of channels. + return encoding != C.ENCODING_PCM_FLOAT || Util.SDK_INT >= 21; + } else { + return audioCapabilities != null + && audioCapabilities.supportsEncoding(encoding) + && (channelCount == Format.NO_VALUE + || channelCount <= audioCapabilities.getMaxChannelCount()); + } + } + + @Override + public long getCurrentPositionUs(boolean sourceEnded) { + if (!isInitialized() || startMediaTimeState == START_NOT_SET) { + return CURRENT_POSITION_NOT_SET; + } + long positionUs = audioTrackPositionTracker.getCurrentPositionUs(sourceEnded); + positionUs = Math.min(positionUs, configuration.framesToDurationUs(getWrittenFrames())); + return startMediaTimeUs + applySkipping(applySpeedup(positionUs)); + } + + @Override + public void configure( + @C.Encoding int inputEncoding, + int inputChannelCount, + int inputSampleRate, + int specifiedBufferSize, + @Nullable int[] outputChannels, + int trimStartFrames, + int trimEndFrames) + throws ConfigurationException { + if (Util.SDK_INT < 21 && inputChannelCount == 8 && outputChannels == null) { + // AudioTrack doesn't support 8 channel output before Android L. Discard the last two (side) + // channels to give a 6 channel stream that is supported. + outputChannels = new int[6]; + for (int i = 0; i < outputChannels.length; i++) { + outputChannels[i] = i; + } + } + + boolean isInputPcm = Util.isEncodingLinearPcm(inputEncoding); + boolean processingEnabled = isInputPcm; + int sampleRate = inputSampleRate; + int channelCount = inputChannelCount; + @C.Encoding int encoding = inputEncoding; + boolean useFloatOutput = + enableFloatOutput + && supportsOutput(inputChannelCount, C.ENCODING_PCM_FLOAT) + && Util.isEncodingHighResolutionPcm(inputEncoding); + AudioProcessor[] availableAudioProcessors = + useFloatOutput ? toFloatPcmAvailableAudioProcessors : toIntPcmAvailableAudioProcessors; + if (processingEnabled) { + trimmingAudioProcessor.setTrimFrameCount(trimStartFrames, trimEndFrames); + channelMappingAudioProcessor.setChannelMap(outputChannels); + AudioProcessor.AudioFormat outputFormat = + new AudioProcessor.AudioFormat(sampleRate, channelCount, encoding); + for (AudioProcessor audioProcessor : availableAudioProcessors) { + try { + AudioProcessor.AudioFormat nextFormat = audioProcessor.configure(outputFormat); + if (audioProcessor.isActive()) { + outputFormat = nextFormat; + } + } catch (UnhandledAudioFormatException e) { + throw new ConfigurationException(e); + } + } + sampleRate = outputFormat.sampleRate; + channelCount = outputFormat.channelCount; + encoding = outputFormat.encoding; + } + + int outputChannelConfig = getChannelConfig(channelCount, isInputPcm); + if (outputChannelConfig == AudioFormat.CHANNEL_INVALID) { + throw new ConfigurationException("Unsupported channel count: " + channelCount); + } + + int inputPcmFrameSize = + isInputPcm ? Util.getPcmFrameSize(inputEncoding, inputChannelCount) : C.LENGTH_UNSET; + int outputPcmFrameSize = + isInputPcm ? Util.getPcmFrameSize(encoding, channelCount) : C.LENGTH_UNSET; + boolean canApplyPlaybackParameters = processingEnabled && !useFloatOutput; + Configuration pendingConfiguration = + new Configuration( + isInputPcm, + inputPcmFrameSize, + inputSampleRate, + outputPcmFrameSize, + sampleRate, + outputChannelConfig, + encoding, + specifiedBufferSize, + processingEnabled, + canApplyPlaybackParameters, + availableAudioProcessors); + if (isInitialized()) { + this.pendingConfiguration = pendingConfiguration; + } else { + configuration = pendingConfiguration; + } + } + + private void setupAudioProcessors() { + AudioProcessor[] audioProcessors = configuration.availableAudioProcessors; + ArrayList<AudioProcessor> newAudioProcessors = new ArrayList<>(); + for (AudioProcessor audioProcessor : audioProcessors) { + if (audioProcessor.isActive()) { + newAudioProcessors.add(audioProcessor); + } else { + audioProcessor.flush(); + } + } + int count = newAudioProcessors.size(); + activeAudioProcessors = newAudioProcessors.toArray(new AudioProcessor[count]); + outputBuffers = new ByteBuffer[count]; + flushAudioProcessors(); + } + + private void flushAudioProcessors() { + for (int i = 0; i < activeAudioProcessors.length; i++) { + AudioProcessor audioProcessor = activeAudioProcessors[i]; + audioProcessor.flush(); + outputBuffers[i] = audioProcessor.getOutput(); + } + } + + private void initialize(long presentationTimeUs) throws InitializationException { + // If we're asynchronously releasing a previous audio track then we block until it has been + // released. This guarantees that we cannot end up in a state where we have multiple audio + // track instances. Without this guarantee it would be possible, in extreme cases, to exhaust + // the shared memory that's available for audio track buffers. This would in turn cause the + // initialization of the audio track to fail. + releasingConditionVariable.block(); + + audioTrack = + Assertions.checkNotNull(configuration) + .buildAudioTrack(tunneling, audioAttributes, audioSessionId); + int audioSessionId = audioTrack.getAudioSessionId(); + if (enablePreV21AudioSessionWorkaround) { + if (Util.SDK_INT < 21) { + // The workaround creates an audio track with a two byte buffer on the same session, and + // does not release it until this object is released, which keeps the session active. + if (keepSessionIdAudioTrack != null + && audioSessionId != keepSessionIdAudioTrack.getAudioSessionId()) { + releaseKeepSessionIdAudioTrack(); + } + if (keepSessionIdAudioTrack == null) { + keepSessionIdAudioTrack = initializeKeepSessionIdAudioTrack(audioSessionId); + } + } + } + if (this.audioSessionId != audioSessionId) { + this.audioSessionId = audioSessionId; + if (listener != null) { + listener.onAudioSessionId(audioSessionId); + } + } + + applyPlaybackParameters(playbackParameters, presentationTimeUs); + + audioTrackPositionTracker.setAudioTrack( + audioTrack, + configuration.outputEncoding, + configuration.outputPcmFrameSize, + configuration.bufferSize); + setVolumeInternal(); + + if (auxEffectInfo.effectId != AuxEffectInfo.NO_AUX_EFFECT_ID) { + audioTrack.attachAuxEffect(auxEffectInfo.effectId); + audioTrack.setAuxEffectSendLevel(auxEffectInfo.sendLevel); + } + } + + @Override + public void play() { + playing = true; + if (isInitialized()) { + audioTrackPositionTracker.start(); + audioTrack.play(); + } + } + + @Override + public void handleDiscontinuity() { + // Force resynchronization after a skipped buffer. + if (startMediaTimeState == START_IN_SYNC) { + startMediaTimeState = START_NEED_SYNC; + } + } + + @Override + @SuppressWarnings("ReferenceEquality") + public boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs) + throws InitializationException, WriteException { + Assertions.checkArgument(inputBuffer == null || buffer == inputBuffer); + + if (pendingConfiguration != null) { + if (!drainAudioProcessorsToEndOfStream()) { + // There's still pending data in audio processors to write to the track. + return false; + } else if (!pendingConfiguration.canReuseAudioTrack(configuration)) { + playPendingData(); + if (hasPendingData()) { + // We're waiting for playout on the current audio track to finish. + return false; + } + flush(); + } else { + // The current audio track can be reused for the new configuration. + configuration = pendingConfiguration; + pendingConfiguration = null; + } + // Re-apply playback parameters. + applyPlaybackParameters(playbackParameters, presentationTimeUs); + } + + if (!isInitialized()) { + initialize(presentationTimeUs); + if (playing) { + play(); + } + } + + if (!audioTrackPositionTracker.mayHandleBuffer(getWrittenFrames())) { + return false; + } + + if (inputBuffer == null) { + // We are seeing this buffer for the first time. + if (!buffer.hasRemaining()) { + // The buffer is empty. + return true; + } + + if (!configuration.isInputPcm && framesPerEncodedSample == 0) { + // If this is the first encoded sample, calculate the sample size in frames. + framesPerEncodedSample = getFramesPerEncodedSample(configuration.outputEncoding, buffer); + if (framesPerEncodedSample == 0) { + // We still don't know the number of frames per sample, so drop the buffer. + // For TrueHD this can occur after some seek operations, as not every sample starts with + // a syncframe header. If we chunked samples together so the extracted samples always + // started with a syncframe header, the chunks would be too large. + return true; + } + } + + if (afterDrainPlaybackParameters != null) { + if (!drainAudioProcessorsToEndOfStream()) { + // Don't process any more input until draining completes. + return false; + } + PlaybackParameters newPlaybackParameters = afterDrainPlaybackParameters; + afterDrainPlaybackParameters = null; + applyPlaybackParameters(newPlaybackParameters, presentationTimeUs); + } + + if (startMediaTimeState == START_NOT_SET) { + startMediaTimeUs = Math.max(0, presentationTimeUs); + startMediaTimeState = START_IN_SYNC; + } else { + // Sanity check that presentationTimeUs is consistent with the expected value. + long expectedPresentationTimeUs = + startMediaTimeUs + + configuration.inputFramesToDurationUs( + getSubmittedFrames() - trimmingAudioProcessor.getTrimmedFrameCount()); + if (startMediaTimeState == START_IN_SYNC + && Math.abs(expectedPresentationTimeUs - presentationTimeUs) > 200000) { + Log.e(TAG, "Discontinuity detected [expected " + expectedPresentationTimeUs + ", got " + + presentationTimeUs + "]"); + startMediaTimeState = START_NEED_SYNC; + } + if (startMediaTimeState == START_NEED_SYNC) { + // Adjust startMediaTimeUs to be consistent with the current buffer's start time and the + // number of bytes submitted. + long adjustmentUs = presentationTimeUs - expectedPresentationTimeUs; + startMediaTimeUs += adjustmentUs; + startMediaTimeState = START_IN_SYNC; + if (listener != null && adjustmentUs != 0) { + listener.onPositionDiscontinuity(); + } + } + } + + if (configuration.isInputPcm) { + submittedPcmBytes += buffer.remaining(); + } else { + submittedEncodedFrames += framesPerEncodedSample; + } + + inputBuffer = buffer; + } + + if (configuration.processingEnabled) { + processBuffers(presentationTimeUs); + } else { + writeBuffer(inputBuffer, presentationTimeUs); + } + + if (!inputBuffer.hasRemaining()) { + inputBuffer = null; + return true; + } + + if (audioTrackPositionTracker.isStalled(getWrittenFrames())) { + Log.w(TAG, "Resetting stalled audio track"); + flush(); + return true; + } + + return false; + } + + private void processBuffers(long avSyncPresentationTimeUs) throws WriteException { + int count = activeAudioProcessors.length; + int index = count; + while (index >= 0) { + ByteBuffer input = index > 0 ? outputBuffers[index - 1] + : (inputBuffer != null ? inputBuffer : AudioProcessor.EMPTY_BUFFER); + if (index == count) { + writeBuffer(input, avSyncPresentationTimeUs); + } else { + AudioProcessor audioProcessor = activeAudioProcessors[index]; + audioProcessor.queueInput(input); + ByteBuffer output = audioProcessor.getOutput(); + outputBuffers[index] = output; + if (output.hasRemaining()) { + // Handle the output as input to the next audio processor or the AudioTrack. + index++; + continue; + } + } + + if (input.hasRemaining()) { + // The input wasn't consumed and no output was produced, so give up for now. + return; + } + + // Get more input from upstream. + index--; + } + } + + @SuppressWarnings("ReferenceEquality") + private void writeBuffer(ByteBuffer buffer, long avSyncPresentationTimeUs) throws WriteException { + if (!buffer.hasRemaining()) { + return; + } + if (outputBuffer != null) { + Assertions.checkArgument(outputBuffer == buffer); + } else { + outputBuffer = buffer; + if (Util.SDK_INT < 21) { + int bytesRemaining = buffer.remaining(); + if (preV21OutputBuffer == null || preV21OutputBuffer.length < bytesRemaining) { + preV21OutputBuffer = new byte[bytesRemaining]; + } + int originalPosition = buffer.position(); + buffer.get(preV21OutputBuffer, 0, bytesRemaining); + buffer.position(originalPosition); + preV21OutputBufferOffset = 0; + } + } + int bytesRemaining = buffer.remaining(); + int bytesWritten = 0; + if (Util.SDK_INT < 21) { // isInputPcm == true + // Work out how many bytes we can write without the risk of blocking. + int bytesToWrite = audioTrackPositionTracker.getAvailableBufferSize(writtenPcmBytes); + if (bytesToWrite > 0) { + bytesToWrite = Math.min(bytesRemaining, bytesToWrite); + bytesWritten = audioTrack.write(preV21OutputBuffer, preV21OutputBufferOffset, bytesToWrite); + if (bytesWritten > 0) { + preV21OutputBufferOffset += bytesWritten; + buffer.position(buffer.position() + bytesWritten); + } + } + } else if (tunneling) { + Assertions.checkState(avSyncPresentationTimeUs != C.TIME_UNSET); + bytesWritten = writeNonBlockingWithAvSyncV21(audioTrack, buffer, bytesRemaining, + avSyncPresentationTimeUs); + } else { + bytesWritten = writeNonBlockingV21(audioTrack, buffer, bytesRemaining); + } + + lastFeedElapsedRealtimeMs = SystemClock.elapsedRealtime(); + + if (bytesWritten < 0) { + throw new WriteException(bytesWritten); + } + + if (configuration.isInputPcm) { + writtenPcmBytes += bytesWritten; + } + if (bytesWritten == bytesRemaining) { + if (!configuration.isInputPcm) { + writtenEncodedFrames += framesPerEncodedSample; + } + outputBuffer = null; + } + } + + @Override + public void playToEndOfStream() throws WriteException { + if (!handledEndOfStream && isInitialized() && drainAudioProcessorsToEndOfStream()) { + playPendingData(); + handledEndOfStream = true; + } + } + + private boolean drainAudioProcessorsToEndOfStream() throws WriteException { + boolean audioProcessorNeedsEndOfStream = false; + if (drainingAudioProcessorIndex == C.INDEX_UNSET) { + drainingAudioProcessorIndex = + configuration.processingEnabled ? 0 : activeAudioProcessors.length; + audioProcessorNeedsEndOfStream = true; + } + while (drainingAudioProcessorIndex < activeAudioProcessors.length) { + AudioProcessor audioProcessor = activeAudioProcessors[drainingAudioProcessorIndex]; + if (audioProcessorNeedsEndOfStream) { + audioProcessor.queueEndOfStream(); + } + processBuffers(C.TIME_UNSET); + if (!audioProcessor.isEnded()) { + return false; + } + audioProcessorNeedsEndOfStream = true; + drainingAudioProcessorIndex++; + } + + // Finish writing any remaining output to the track. + if (outputBuffer != null) { + writeBuffer(outputBuffer, C.TIME_UNSET); + if (outputBuffer != null) { + return false; + } + } + drainingAudioProcessorIndex = C.INDEX_UNSET; + return true; + } + + @Override + public boolean isEnded() { + return !isInitialized() || (handledEndOfStream && !hasPendingData()); + } + + @Override + public boolean hasPendingData() { + return isInitialized() && audioTrackPositionTracker.hasPendingData(getWrittenFrames()); + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + if (configuration != null && !configuration.canApplyPlaybackParameters) { + this.playbackParameters = PlaybackParameters.DEFAULT; + return; + } + PlaybackParameters lastSetPlaybackParameters = getPlaybackParameters(); + if (!playbackParameters.equals(lastSetPlaybackParameters)) { + if (isInitialized()) { + // Drain the audio processors so we can determine the frame position at which the new + // parameters apply. + afterDrainPlaybackParameters = playbackParameters; + } else { + // Update the playback parameters now. They will be applied to the audio processors during + // initialization. + this.playbackParameters = playbackParameters; + } + } + } + + @Override + public PlaybackParameters getPlaybackParameters() { + // Mask the already set parameters. + return afterDrainPlaybackParameters != null + ? afterDrainPlaybackParameters + : !playbackParametersCheckpoints.isEmpty() + ? playbackParametersCheckpoints.getLast().playbackParameters + : playbackParameters; + } + + @Override + public void setAudioAttributes(AudioAttributes audioAttributes) { + if (this.audioAttributes.equals(audioAttributes)) { + return; + } + this.audioAttributes = audioAttributes; + if (tunneling) { + // The audio attributes are ignored in tunneling mode, so no need to reset. + return; + } + flush(); + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + } + + @Override + public void setAudioSessionId(int audioSessionId) { + if (this.audioSessionId != audioSessionId) { + this.audioSessionId = audioSessionId; + flush(); + } + } + + @Override + public void setAuxEffectInfo(AuxEffectInfo auxEffectInfo) { + if (this.auxEffectInfo.equals(auxEffectInfo)) { + return; + } + int effectId = auxEffectInfo.effectId; + float sendLevel = auxEffectInfo.sendLevel; + if (audioTrack != null) { + if (this.auxEffectInfo.effectId != effectId) { + audioTrack.attachAuxEffect(effectId); + } + if (effectId != AuxEffectInfo.NO_AUX_EFFECT_ID) { + audioTrack.setAuxEffectSendLevel(sendLevel); + } + } + this.auxEffectInfo = auxEffectInfo; + } + + @Override + public void enableTunnelingV21(int tunnelingAudioSessionId) { + Assertions.checkState(Util.SDK_INT >= 21); + if (!tunneling || audioSessionId != tunnelingAudioSessionId) { + tunneling = true; + audioSessionId = tunnelingAudioSessionId; + flush(); + } + } + + @Override + public void disableTunneling() { + if (tunneling) { + tunneling = false; + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + flush(); + } + } + + @Override + public void setVolume(float volume) { + if (this.volume != volume) { + this.volume = volume; + setVolumeInternal(); + } + } + + private void setVolumeInternal() { + if (!isInitialized()) { + // Do nothing. + } else if (Util.SDK_INT >= 21) { + setVolumeInternalV21(audioTrack, volume); + } else { + setVolumeInternalV3(audioTrack, volume); + } + } + + @Override + public void pause() { + playing = false; + if (isInitialized() && audioTrackPositionTracker.pause()) { + audioTrack.pause(); + } + } + + @Override + public void flush() { + if (isInitialized()) { + submittedPcmBytes = 0; + submittedEncodedFrames = 0; + writtenPcmBytes = 0; + writtenEncodedFrames = 0; + framesPerEncodedSample = 0; + if (afterDrainPlaybackParameters != null) { + playbackParameters = afterDrainPlaybackParameters; + afterDrainPlaybackParameters = null; + } else if (!playbackParametersCheckpoints.isEmpty()) { + playbackParameters = playbackParametersCheckpoints.getLast().playbackParameters; + } + playbackParametersCheckpoints.clear(); + playbackParametersOffsetUs = 0; + playbackParametersPositionUs = 0; + trimmingAudioProcessor.resetTrimmedFrameCount(); + flushAudioProcessors(); + inputBuffer = null; + outputBuffer = null; + stoppedAudioTrack = false; + handledEndOfStream = false; + drainingAudioProcessorIndex = C.INDEX_UNSET; + avSyncHeader = null; + bytesUntilNextAvSync = 0; + startMediaTimeState = START_NOT_SET; + if (audioTrackPositionTracker.isPlaying()) { + audioTrack.pause(); + } + // AudioTrack.release can take some time, so we call it on a background thread. + final AudioTrack toRelease = audioTrack; + audioTrack = null; + if (pendingConfiguration != null) { + configuration = pendingConfiguration; + pendingConfiguration = null; + } + audioTrackPositionTracker.reset(); + releasingConditionVariable.close(); + new Thread() { + @Override + public void run() { + try { + toRelease.flush(); + toRelease.release(); + } finally { + releasingConditionVariable.open(); + } + } + }.start(); + } + } + + @Override + public void reset() { + flush(); + releaseKeepSessionIdAudioTrack(); + for (AudioProcessor audioProcessor : toIntPcmAvailableAudioProcessors) { + audioProcessor.reset(); + } + for (AudioProcessor audioProcessor : toFloatPcmAvailableAudioProcessors) { + audioProcessor.reset(); + } + audioSessionId = C.AUDIO_SESSION_ID_UNSET; + playing = false; + } + + /** + * Releases {@link #keepSessionIdAudioTrack} asynchronously, if it is non-{@code null}. + */ + private void releaseKeepSessionIdAudioTrack() { + if (keepSessionIdAudioTrack == null) { + return; + } + + // AudioTrack.release can take some time, so we call it on a background thread. + final AudioTrack toRelease = keepSessionIdAudioTrack; + keepSessionIdAudioTrack = null; + new Thread() { + @Override + public void run() { + toRelease.release(); + } + }.start(); + } + + private void applyPlaybackParameters( + PlaybackParameters playbackParameters, long presentationTimeUs) { + PlaybackParameters newPlaybackParameters = + configuration.canApplyPlaybackParameters + ? audioProcessorChain.applyPlaybackParameters(playbackParameters) + : PlaybackParameters.DEFAULT; + // Store the position and corresponding media time from which the parameters will apply. + playbackParametersCheckpoints.add( + new PlaybackParametersCheckpoint( + newPlaybackParameters, + /* mediaTimeUs= */ Math.max(0, presentationTimeUs), + /* positionUs= */ configuration.framesToDurationUs(getWrittenFrames()))); + setupAudioProcessors(); + } + + private long applySpeedup(long positionUs) { + @Nullable PlaybackParametersCheckpoint checkpoint = null; + while (!playbackParametersCheckpoints.isEmpty() + && positionUs >= playbackParametersCheckpoints.getFirst().positionUs) { + checkpoint = playbackParametersCheckpoints.remove(); + } + if (checkpoint != null) { + // We are playing (or about to play) media with the new playback parameters, so update them. + playbackParameters = checkpoint.playbackParameters; + playbackParametersPositionUs = checkpoint.positionUs; + playbackParametersOffsetUs = checkpoint.mediaTimeUs - startMediaTimeUs; + } + + if (playbackParameters.speed == 1f) { + return positionUs + playbackParametersOffsetUs - playbackParametersPositionUs; + } + + if (playbackParametersCheckpoints.isEmpty()) { + return playbackParametersOffsetUs + + audioProcessorChain.getMediaDuration(positionUs - playbackParametersPositionUs); + } + + // We are playing data at a previous playback speed, so fall back to multiplying by the speed. + return playbackParametersOffsetUs + + Util.getMediaDurationForPlayoutDuration( + positionUs - playbackParametersPositionUs, playbackParameters.speed); + } + + private long applySkipping(long positionUs) { + return positionUs + + configuration.framesToDurationUs(audioProcessorChain.getSkippedOutputFrameCount()); + } + + private boolean isInitialized() { + return audioTrack != null; + } + + private long getSubmittedFrames() { + return configuration.isInputPcm + ? (submittedPcmBytes / configuration.inputPcmFrameSize) + : submittedEncodedFrames; + } + + private long getWrittenFrames() { + return configuration.isInputPcm + ? (writtenPcmBytes / configuration.outputPcmFrameSize) + : writtenEncodedFrames; + } + + private static AudioTrack initializeKeepSessionIdAudioTrack(int audioSessionId) { + int sampleRate = 4000; // Equal to private AudioTrack.MIN_SAMPLE_RATE. + int channelConfig = AudioFormat.CHANNEL_OUT_MONO; + @C.PcmEncoding int encoding = C.ENCODING_PCM_16BIT; + int bufferSize = 2; // Use a two byte buffer, as it is not actually used for playback. + return new AudioTrack(C.STREAM_TYPE_DEFAULT, sampleRate, channelConfig, encoding, bufferSize, + MODE_STATIC, audioSessionId); + } + + private static int getChannelConfig(int channelCount, boolean isInputPcm) { + if (Util.SDK_INT <= 28 && !isInputPcm) { + // In passthrough mode the channel count used to configure the audio track doesn't affect how + // the stream is handled, except that some devices do overly-strict channel configuration + // checks. Therefore we override the channel count so that a known-working channel + // configuration is chosen in all cases. See [Internal: b/29116190]. + if (channelCount == 7) { + channelCount = 8; + } else if (channelCount == 3 || channelCount == 4 || channelCount == 5) { + channelCount = 6; + } + } + + // Workaround for Nexus Player not reporting support for mono passthrough. + // (See [Internal: b/34268671].) + if (Util.SDK_INT <= 26 && "fugu".equals(Util.DEVICE) && !isInputPcm && channelCount == 1) { + channelCount = 2; + } + + return Util.getAudioTrackChannelConfig(channelCount); + } + + private static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) { + switch (encoding) { + case C.ENCODING_AC3: + return 640 * 1000 / 8; + case C.ENCODING_E_AC3: + case C.ENCODING_E_AC3_JOC: + return 6144 * 1000 / 8; + case C.ENCODING_AC4: + return 2688 * 1000 / 8; + case C.ENCODING_DTS: + // DTS allows an 'open' bitrate, but we assume the maximum listed value: 1536 kbit/s. + return 1536 * 1000 / 8; + case C.ENCODING_DTS_HD: + return 18000 * 1000 / 8; + case C.ENCODING_DOLBY_TRUEHD: + return 24500 * 1000 / 8; + case C.ENCODING_INVALID: + case C.ENCODING_PCM_16BIT: + case C.ENCODING_PCM_24BIT: + case C.ENCODING_PCM_32BIT: + case C.ENCODING_PCM_8BIT: + case C.ENCODING_PCM_FLOAT: + case Format.NO_VALUE: + default: + throw new IllegalArgumentException(); + } + } + + private static int getFramesPerEncodedSample(@C.Encoding int encoding, ByteBuffer buffer) { + switch (encoding) { + case C.ENCODING_MP3: + return MpegAudioHeader.getFrameSampleCount(buffer.get(buffer.position())); + case C.ENCODING_DTS: + case C.ENCODING_DTS_HD: + return DtsUtil.parseDtsAudioSampleCount(buffer); + case C.ENCODING_AC3: + case C.ENCODING_E_AC3: + case C.ENCODING_E_AC3_JOC: + return Ac3Util.parseAc3SyncframeAudioSampleCount(buffer); + case C.ENCODING_AC4: + return Ac4Util.parseAc4SyncframeAudioSampleCount(buffer); + case C.ENCODING_DOLBY_TRUEHD: + int syncframeOffset = Ac3Util.findTrueHdSyncframeOffset(buffer); + return syncframeOffset == C.INDEX_UNSET + ? 0 + : (Ac3Util.parseTrueHdSyncframeAudioSampleCount(buffer, syncframeOffset) + * Ac3Util.TRUEHD_RECHUNK_SAMPLE_COUNT); + default: + throw new IllegalStateException("Unexpected audio encoding: " + encoding); + } + } + + @TargetApi(21) + private static int writeNonBlockingV21(AudioTrack audioTrack, ByteBuffer buffer, int size) { + return audioTrack.write(buffer, size, WRITE_NON_BLOCKING); + } + + @TargetApi(21) + private int writeNonBlockingWithAvSyncV21(AudioTrack audioTrack, ByteBuffer buffer, int size, + long presentationTimeUs) { + if (Util.SDK_INT >= 26) { + // The underlying platform AudioTrack writes AV sync headers directly. + return audioTrack.write(buffer, size, WRITE_NON_BLOCKING, presentationTimeUs * 1000); + } + if (avSyncHeader == null) { + avSyncHeader = ByteBuffer.allocate(16); + avSyncHeader.order(ByteOrder.BIG_ENDIAN); + avSyncHeader.putInt(0x55550001); + } + if (bytesUntilNextAvSync == 0) { + avSyncHeader.putInt(4, size); + avSyncHeader.putLong(8, presentationTimeUs * 1000); + avSyncHeader.position(0); + bytesUntilNextAvSync = size; + } + int avSyncHeaderBytesRemaining = avSyncHeader.remaining(); + if (avSyncHeaderBytesRemaining > 0) { + int result = audioTrack.write(avSyncHeader, avSyncHeaderBytesRemaining, WRITE_NON_BLOCKING); + if (result < 0) { + bytesUntilNextAvSync = 0; + return result; + } + if (result < avSyncHeaderBytesRemaining) { + return 0; + } + } + int result = writeNonBlockingV21(audioTrack, buffer, size); + if (result < 0) { + bytesUntilNextAvSync = 0; + return result; + } + bytesUntilNextAvSync -= result; + return result; + } + + @TargetApi(21) + private static void setVolumeInternalV21(AudioTrack audioTrack, float volume) { + audioTrack.setVolume(volume); + } + + private static void setVolumeInternalV3(AudioTrack audioTrack, float volume) { + audioTrack.setStereoVolume(volume, volume); + } + + private void playPendingData() { + if (!stoppedAudioTrack) { + stoppedAudioTrack = true; + audioTrackPositionTracker.handleEndOfStream(getWrittenFrames()); + audioTrack.stop(); + bytesUntilNextAvSync = 0; + } + } + + /** Stores playback parameters with the position and media time at which they apply. */ + private static final class PlaybackParametersCheckpoint { + + private final PlaybackParameters playbackParameters; + private final long mediaTimeUs; + private final long positionUs; + + private PlaybackParametersCheckpoint(PlaybackParameters playbackParameters, long mediaTimeUs, + long positionUs) { + this.playbackParameters = playbackParameters; + this.mediaTimeUs = mediaTimeUs; + this.positionUs = positionUs; + } + + } + + private final class PositionTrackerListener implements AudioTrackPositionTracker.Listener { + + @Override + public void onPositionFramesMismatch( + long audioTimestampPositionFrames, + long audioTimestampSystemTimeUs, + long systemTimeUs, + long playbackPositionUs) { + String message = + "Spurious audio timestamp (frame position mismatch): " + + audioTimestampPositionFrames + + ", " + + audioTimestampSystemTimeUs + + ", " + + systemTimeUs + + ", " + + playbackPositionUs + + ", " + + getSubmittedFrames() + + ", " + + getWrittenFrames(); + if (failOnSpuriousAudioTimestamp) { + throw new InvalidAudioTrackTimestampException(message); + } + Log.w(TAG, message); + } + + @Override + public void onSystemTimeUsMismatch( + long audioTimestampPositionFrames, + long audioTimestampSystemTimeUs, + long systemTimeUs, + long playbackPositionUs) { + String message = + "Spurious audio timestamp (system clock mismatch): " + + audioTimestampPositionFrames + + ", " + + audioTimestampSystemTimeUs + + ", " + + systemTimeUs + + ", " + + playbackPositionUs + + ", " + + getSubmittedFrames() + + ", " + + getWrittenFrames(); + if (failOnSpuriousAudioTimestamp) { + throw new InvalidAudioTrackTimestampException(message); + } + Log.w(TAG, message); + } + + @Override + public void onInvalidLatency(long latencyUs) { + Log.w(TAG, "Ignoring impossibly large audio latency: " + latencyUs); + } + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs) { + if (listener != null) { + long elapsedSinceLastFeedMs = SystemClock.elapsedRealtime() - lastFeedElapsedRealtimeMs; + listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + } + } + + /** Stores configuration relating to the audio format. */ + private static final class Configuration { + + public final boolean isInputPcm; + public final int inputPcmFrameSize; + public final int inputSampleRate; + public final int outputPcmFrameSize; + public final int outputSampleRate; + public final int outputChannelConfig; + @C.Encoding public final int outputEncoding; + public final int bufferSize; + public final boolean processingEnabled; + public final boolean canApplyPlaybackParameters; + public final AudioProcessor[] availableAudioProcessors; + + public Configuration( + boolean isInputPcm, + int inputPcmFrameSize, + int inputSampleRate, + int outputPcmFrameSize, + int outputSampleRate, + int outputChannelConfig, + int outputEncoding, + int specifiedBufferSize, + boolean processingEnabled, + boolean canApplyPlaybackParameters, + AudioProcessor[] availableAudioProcessors) { + this.isInputPcm = isInputPcm; + this.inputPcmFrameSize = inputPcmFrameSize; + this.inputSampleRate = inputSampleRate; + this.outputPcmFrameSize = outputPcmFrameSize; + this.outputSampleRate = outputSampleRate; + this.outputChannelConfig = outputChannelConfig; + this.outputEncoding = outputEncoding; + this.bufferSize = specifiedBufferSize != 0 ? specifiedBufferSize : getDefaultBufferSize(); + this.processingEnabled = processingEnabled; + this.canApplyPlaybackParameters = canApplyPlaybackParameters; + this.availableAudioProcessors = availableAudioProcessors; + } + + public boolean canReuseAudioTrack(Configuration audioTrackConfiguration) { + return audioTrackConfiguration.outputEncoding == outputEncoding + && audioTrackConfiguration.outputSampleRate == outputSampleRate + && audioTrackConfiguration.outputChannelConfig == outputChannelConfig; + } + + public long inputFramesToDurationUs(long frameCount) { + return (frameCount * C.MICROS_PER_SECOND) / inputSampleRate; + } + + public long framesToDurationUs(long frameCount) { + return (frameCount * C.MICROS_PER_SECOND) / outputSampleRate; + } + + public long durationUsToFrames(long durationUs) { + return (durationUs * outputSampleRate) / C.MICROS_PER_SECOND; + } + + public AudioTrack buildAudioTrack( + boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) + throws InitializationException { + AudioTrack audioTrack; + if (Util.SDK_INT >= 21) { + audioTrack = createAudioTrackV21(tunneling, audioAttributes, audioSessionId); + } else { + int streamType = Util.getStreamTypeForAudioUsage(audioAttributes.usage); + if (audioSessionId == C.AUDIO_SESSION_ID_UNSET) { + audioTrack = + new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + MODE_STREAM); + } else { + // Re-attach to the same audio session. + audioTrack = + new AudioTrack( + streamType, + outputSampleRate, + outputChannelConfig, + outputEncoding, + bufferSize, + MODE_STREAM, + audioSessionId); + } + } + + int state = audioTrack.getState(); + if (state != STATE_INITIALIZED) { + try { + audioTrack.release(); + } catch (Exception e) { + // The track has already failed to initialize, so it wouldn't be that surprising if + // release were to fail too. Swallow the exception. + } + throw new InitializationException(state, outputSampleRate, outputChannelConfig, bufferSize); + } + return audioTrack; + } + + @TargetApi(21) + private AudioTrack createAudioTrackV21( + boolean tunneling, AudioAttributes audioAttributes, int audioSessionId) { + android.media.AudioAttributes attributes; + if (tunneling) { + attributes = + new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_MOVIE) + .setFlags(android.media.AudioAttributes.FLAG_HW_AV_SYNC) + .setUsage(android.media.AudioAttributes.USAGE_MEDIA) + .build(); + } else { + attributes = audioAttributes.getAudioAttributesV21(); + } + AudioFormat format = + new AudioFormat.Builder() + .setChannelMask(outputChannelConfig) + .setEncoding(outputEncoding) + .setSampleRate(outputSampleRate) + .build(); + return new AudioTrack( + attributes, + format, + bufferSize, + MODE_STREAM, + audioSessionId != C.AUDIO_SESSION_ID_UNSET + ? audioSessionId + : AudioManager.AUDIO_SESSION_ID_GENERATE); + } + + private int getDefaultBufferSize() { + if (isInputPcm) { + int minBufferSize = + AudioTrack.getMinBufferSize(outputSampleRate, outputChannelConfig, outputEncoding); + Assertions.checkState(minBufferSize != ERROR_BAD_VALUE); + int multipliedBufferSize = minBufferSize * BUFFER_MULTIPLICATION_FACTOR; + int minAppBufferSize = + (int) durationUsToFrames(MIN_BUFFER_DURATION_US) * outputPcmFrameSize; + int maxAppBufferSize = + (int) + Math.max( + minBufferSize, durationUsToFrames(MAX_BUFFER_DURATION_US) * outputPcmFrameSize); + return Util.constrainValue(multipliedBufferSize, minAppBufferSize, maxAppBufferSize); + } else { + int rate = getMaximumEncodedRateBytesPerSecond(outputEncoding); + if (outputEncoding == C.ENCODING_AC3) { + rate *= AC3_BUFFER_MULTIPLICATION_FACTOR; + } + return (int) (PASSTHROUGH_BUFFER_DURATION_US * rate / C.MICROS_PER_SECOND); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/DtsUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/DtsUtil.java new file mode 100644 index 0000000000..6e5d749fdf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/DtsUtil.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * Utility methods for parsing DTS frames. + */ +public final class DtsUtil { + + private static final int SYNC_VALUE_BE = 0x7FFE8001; + private static final int SYNC_VALUE_14B_BE = 0x1FFFE800; + private static final int SYNC_VALUE_LE = 0xFE7F0180; + private static final int SYNC_VALUE_14B_LE = 0xFF1F00E8; + private static final byte FIRST_BYTE_BE = (byte) (SYNC_VALUE_BE >>> 24); + private static final byte FIRST_BYTE_14B_BE = (byte) (SYNC_VALUE_14B_BE >>> 24); + private static final byte FIRST_BYTE_LE = (byte) (SYNC_VALUE_LE >>> 24); + private static final byte FIRST_BYTE_14B_LE = (byte) (SYNC_VALUE_14B_LE >>> 24); + + /** + * Maps AMODE to the number of channels. See ETSI TS 102 114 table 5.4. + */ + private static final int[] CHANNELS_BY_AMODE = new int[] {1, 2, 2, 2, 2, 3, 3, 4, 4, 5, 6, 6, 6, + 7, 8, 8}; + + /** + * Maps SFREQ to the sampling frequency in Hz. See ETSI TS 102 144 table 5.5. + */ + private static final int[] SAMPLE_RATE_BY_SFREQ = new int[] {-1, 8000, 16000, 32000, -1, -1, + 11025, 22050, 44100, -1, -1, 12000, 24000, 48000, -1, -1}; + + /** + * Maps RATE to 2 * bitrate in kbit/s. See ETSI TS 102 144 table 5.7. + */ + private static final int[] TWICE_BITRATE_KBPS_BY_RATE = new int[] {64, 112, 128, 192, 224, 256, + 384, 448, 512, 640, 768, 896, 1024, 1152, 1280, 1536, 1920, 2048, 2304, 2560, 2688, 2816, + 2823, 2944, 3072, 3840, 4096, 6144, 7680}; + + /** + * Returns whether a given integer matches a DTS sync word. Synchronization and storage modes are + * defined in ETSI TS 102 114 V1.1.1 (2002-08), Section 5.3. + * + * @param word An integer. + * @return Whether a given integer matches a DTS sync word. + */ + public static boolean isSyncWord(int word) { + return word == SYNC_VALUE_BE + || word == SYNC_VALUE_LE + || word == SYNC_VALUE_14B_BE + || word == SYNC_VALUE_14B_LE; + } + + /** + * Returns the DTS format given {@code data} containing the DTS frame according to ETSI TS 102 114 + * subsections 5.3/5.4. + * + * @param frame The DTS frame to parse. + * @param trackId The track identifier to set on the format. + * @param language The language to set on the format. + * @param drmInitData {@link DrmInitData} to be included in the format. + * @return The DTS format parsed from data in the header. + */ + public static Format parseDtsFormat( + byte[] frame, String trackId, @Nullable String language, @Nullable DrmInitData drmInitData) { + ParsableBitArray frameBits = getNormalizedFrameHeader(frame); + frameBits.skipBits(32 + 1 + 5 + 1 + 7 + 14); // SYNC, FTYPE, SHORT, CPF, NBLKS, FSIZE + int amode = frameBits.readBits(6); + int channelCount = CHANNELS_BY_AMODE[amode]; + int sfreq = frameBits.readBits(4); + int sampleRate = SAMPLE_RATE_BY_SFREQ[sfreq]; + int rate = frameBits.readBits(5); + int bitrate = rate >= TWICE_BITRATE_KBPS_BY_RATE.length ? Format.NO_VALUE + : TWICE_BITRATE_KBPS_BY_RATE[rate] * 1000 / 2; + frameBits.skipBits(10); // MIX, DYNF, TIMEF, AUXF, HDCD, EXT_AUDIO_ID, EXT_AUDIO, ASPF + channelCount += frameBits.readBits(2) > 0 ? 1 : 0; // LFF + return Format.createAudioSampleFormat(trackId, MimeTypes.AUDIO_DTS, null, bitrate, + Format.NO_VALUE, channelCount, sampleRate, null, drmInitData, 0, language); + } + + /** + * Returns the number of audio samples represented by the given DTS frame. + * + * @param data The frame to parse. + * @return The number of audio samples represented by the frame. + */ + public static int parseDtsAudioSampleCount(byte[] data) { + int nblks; + switch (data[0]) { + case FIRST_BYTE_LE: + nblks = ((data[5] & 0x01) << 6) | ((data[4] & 0xFC) >> 2); + break; + case FIRST_BYTE_14B_LE: + nblks = ((data[4] & 0x07) << 4) | ((data[7] & 0x3C) >> 2); + break; + case FIRST_BYTE_14B_BE: + nblks = ((data[5] & 0x07) << 4) | ((data[6] & 0x3C) >> 2); + break; + default: + // We blindly assume FIRST_BYTE_BE if none of the others match. + nblks = ((data[4] & 0x01) << 6) | ((data[5] & 0xFC) >> 2); + } + return (nblks + 1) * 32; + } + + /** + * Like {@link #parseDtsAudioSampleCount(byte[])} but reads from a {@link ByteBuffer}. The + * buffer's position is not modified. + * + * @param buffer The {@link ByteBuffer} from which to read. + * @return The number of audio samples represented by the syncframe. + */ + public static int parseDtsAudioSampleCount(ByteBuffer buffer) { + // See ETSI TS 102 114 subsection 5.4.1. + int position = buffer.position(); + int nblks; + switch (buffer.get(position)) { + case FIRST_BYTE_LE: + nblks = ((buffer.get(position + 5) & 0x01) << 6) | ((buffer.get(position + 4) & 0xFC) >> 2); + break; + case FIRST_BYTE_14B_LE: + nblks = ((buffer.get(position + 4) & 0x07) << 4) | ((buffer.get(position + 7) & 0x3C) >> 2); + break; + case FIRST_BYTE_14B_BE: + nblks = ((buffer.get(position + 5) & 0x07) << 4) | ((buffer.get(position + 6) & 0x3C) >> 2); + break; + default: + // We blindly assume FIRST_BYTE_BE if none of the others match. + nblks = ((buffer.get(position + 4) & 0x01) << 6) | ((buffer.get(position + 5) & 0xFC) >> 2); + } + return (nblks + 1) * 32; + } + + /** + * Returns the size in bytes of the given DTS frame. + * + * @param data The frame to parse. + * @return The frame's size in bytes. + */ + public static int getDtsFrameSize(byte[] data) { + int fsize; + boolean uses14BitPerWord = false; + switch (data[0]) { + case FIRST_BYTE_14B_BE: + fsize = (((data[6] & 0x03) << 12) | ((data[7] & 0xFF) << 4) | ((data[8] & 0x3C) >> 2)) + 1; + uses14BitPerWord = true; + break; + case FIRST_BYTE_LE: + fsize = (((data[4] & 0x03) << 12) | ((data[7] & 0xFF) << 4) | ((data[6] & 0xF0) >> 4)) + 1; + break; + case FIRST_BYTE_14B_LE: + fsize = (((data[7] & 0x03) << 12) | ((data[6] & 0xFF) << 4) | ((data[9] & 0x3C) >> 2)) + 1; + uses14BitPerWord = true; + break; + default: + // We blindly assume FIRST_BYTE_BE if none of the others match. + fsize = (((data[5] & 0x03) << 12) | ((data[6] & 0xFF) << 4) | ((data[7] & 0xF0) >> 4)) + 1; + } + + // If the frame is stored in 14-bit mode, adjust the frame size to reflect the actual byte size. + return uses14BitPerWord ? fsize * 16 / 14 : fsize; + } + + private static ParsableBitArray getNormalizedFrameHeader(byte[] frameHeader) { + if (frameHeader[0] == FIRST_BYTE_BE) { + // The frame is already 16-bit mode, big endian. + return new ParsableBitArray(frameHeader); + } + // Data is not normalized, but we don't want to modify frameHeader. + frameHeader = Arrays.copyOf(frameHeader, frameHeader.length); + if (isLittleEndianFrameHeader(frameHeader)) { + // Change endianness. + for (int i = 0; i < frameHeader.length - 1; i += 2) { + byte temp = frameHeader[i]; + frameHeader[i] = frameHeader[i + 1]; + frameHeader[i + 1] = temp; + } + } + ParsableBitArray frameBits = new ParsableBitArray(frameHeader); + if (frameHeader[0] == (byte) (SYNC_VALUE_14B_BE >> 24)) { + // Discard the 2 most significant bits of each 16 bit word. + ParsableBitArray scratchBits = new ParsableBitArray(frameHeader); + while (scratchBits.bitsLeft() >= 16) { + scratchBits.skipBits(2); + frameBits.putInt(scratchBits.readBits(14), 14); + } + } + frameBits.reset(frameHeader); + return frameBits; + } + + private static boolean isLittleEndianFrameHeader(byte[] frameHeader) { + return frameHeader[0] == FIRST_BYTE_LE || frameHeader[0] == FIRST_BYTE_14B_LE; + } + + private DtsUtil() {} + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/FloatResamplingAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/FloatResamplingAudioProcessor.java new file mode 100644 index 0000000000..c2eb62a0ad --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/FloatResamplingAudioProcessor.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; + +/** + * An {@link AudioProcessor} that converts high resolution PCM audio to 32-bit float. The following + * encodings are supported as input: + * + * <ul> + * <li>{@link C#ENCODING_PCM_24BIT} + * <li>{@link C#ENCODING_PCM_32BIT} + * <li>{@link C#ENCODING_PCM_FLOAT} ({@link #isActive()} will return {@code false}) + * </ul> + */ +/* package */ final class FloatResamplingAudioProcessor extends BaseAudioProcessor { + + private static final int FLOAT_NAN_AS_INT = Float.floatToIntBits(Float.NaN); + private static final double PCM_32_BIT_INT_TO_PCM_32_BIT_FLOAT_FACTOR = 1.0 / 0x7FFFFFFF; + + @Override + public AudioFormat onConfigure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + @C.PcmEncoding int encoding = inputAudioFormat.encoding; + if (!Util.isEncodingHighResolutionPcm(encoding)) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + return encoding != C.ENCODING_PCM_FLOAT + ? new AudioFormat( + inputAudioFormat.sampleRate, inputAudioFormat.channelCount, C.ENCODING_PCM_FLOAT) + : AudioFormat.NOT_SET; + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + int position = inputBuffer.position(); + int limit = inputBuffer.limit(); + int size = limit - position; + + ByteBuffer buffer; + switch (inputAudioFormat.encoding) { + case C.ENCODING_PCM_24BIT: + buffer = replaceOutputBuffer((size / 3) * 4); + for (int i = position; i < limit; i += 3) { + int pcm32BitInteger = + ((inputBuffer.get(i) & 0xFF) << 8) + | ((inputBuffer.get(i + 1) & 0xFF) << 16) + | ((inputBuffer.get(i + 2) & 0xFF) << 24); + writePcm32BitFloat(pcm32BitInteger, buffer); + } + break; + case C.ENCODING_PCM_32BIT: + buffer = replaceOutputBuffer(size); + for (int i = position; i < limit; i += 4) { + int pcm32BitInteger = + (inputBuffer.get(i) & 0xFF) + | ((inputBuffer.get(i + 1) & 0xFF) << 8) + | ((inputBuffer.get(i + 2) & 0xFF) << 16) + | ((inputBuffer.get(i + 3) & 0xFF) << 24); + writePcm32BitFloat(pcm32BitInteger, buffer); + } + break; + case C.ENCODING_PCM_8BIT: + case C.ENCODING_PCM_16BIT: + case C.ENCODING_PCM_16BIT_BIG_ENDIAN: + case C.ENCODING_PCM_FLOAT: + case C.ENCODING_INVALID: + case Format.NO_VALUE: + default: + // Never happens. + throw new IllegalStateException(); + } + + inputBuffer.position(inputBuffer.limit()); + buffer.flip(); + } + + /** + * Converts the provided 32-bit integer to a 32-bit float value and writes it to {@code buffer}. + * + * @param pcm32BitInt The 32-bit integer value to convert to 32-bit float in [-1.0, 1.0]. + * @param buffer The output buffer. + */ + private static void writePcm32BitFloat(int pcm32BitInt, ByteBuffer buffer) { + float pcm32BitFloat = (float) (PCM_32_BIT_INT_TO_PCM_32_BIT_FLOAT_FACTOR * pcm32BitInt); + int floatBits = Float.floatToIntBits(pcm32BitFloat); + if (floatBits == FLOAT_NAN_AS_INT) { + floatBits = Float.floatToIntBits((float) 0.0); + } + buffer.putInt(floatBits); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ForwardingAudioSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ForwardingAudioSink.java new file mode 100644 index 0000000000..4e7f9d69f9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ForwardingAudioSink.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import java.nio.ByteBuffer; + +/** An overridable {@link AudioSink} implementation forwarding all methods to another sink. */ +public class ForwardingAudioSink implements AudioSink { + + private final AudioSink sink; + + public ForwardingAudioSink(AudioSink sink) { + this.sink = sink; + } + + @Override + public void setListener(Listener listener) { + sink.setListener(listener); + } + + @Override + public boolean supportsOutput(int channelCount, int encoding) { + return sink.supportsOutput(channelCount, encoding); + } + + @Override + public long getCurrentPositionUs(boolean sourceEnded) { + return sink.getCurrentPositionUs(sourceEnded); + } + + @Override + public void configure( + int inputEncoding, + int inputChannelCount, + int inputSampleRate, + int specifiedBufferSize, + @Nullable int[] outputChannels, + int trimStartFrames, + int trimEndFrames) + throws ConfigurationException { + sink.configure( + inputEncoding, + inputChannelCount, + inputSampleRate, + specifiedBufferSize, + outputChannels, + trimStartFrames, + trimEndFrames); + } + + @Override + public void play() { + sink.play(); + } + + @Override + public void handleDiscontinuity() { + sink.handleDiscontinuity(); + } + + @Override + public boolean handleBuffer(ByteBuffer buffer, long presentationTimeUs) + throws InitializationException, WriteException { + return sink.handleBuffer(buffer, presentationTimeUs); + } + + @Override + public void playToEndOfStream() throws WriteException { + sink.playToEndOfStream(); + } + + @Override + public boolean isEnded() { + return sink.isEnded(); + } + + @Override + public boolean hasPendingData() { + return sink.hasPendingData(); + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + sink.setPlaybackParameters(playbackParameters); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return sink.getPlaybackParameters(); + } + + @Override + public void setAudioAttributes(AudioAttributes audioAttributes) { + sink.setAudioAttributes(audioAttributes); + } + + @Override + public void setAudioSessionId(int audioSessionId) { + sink.setAudioSessionId(audioSessionId); + } + + @Override + public void setAuxEffectInfo(AuxEffectInfo auxEffectInfo) { + sink.setAuxEffectInfo(auxEffectInfo); + } + + @Override + public void enableTunnelingV21(int tunnelingAudioSessionId) { + sink.enableTunnelingV21(tunnelingAudioSessionId); + } + + @Override + public void disableTunneling() { + sink.disableTunneling(); + } + + @Override + public void setVolume(float volume) { + sink.setVolume(volume); + } + + @Override + public void pause() { + sink.pause(); + } + + @Override + public void flush() { + sink.flush(); + } + + @Override + public void reset() { + sink.reset(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java new file mode 100644 index 0000000000..42f7e99b78 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java @@ -0,0 +1,1036 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.media.MediaCodec; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.media.audiofx.Virtualizer; +import android.os.Handler; +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +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.ExoPlayer; +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.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlayerMessage.Target; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; +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.mediacodec.MediaCodecUtil.DecoderQueryException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaFormatUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MediaClock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Decodes and renders audio using {@link MediaCodec} and an {@link AudioSink}. + * + * <p>This renderer accepts the following messages sent via {@link ExoPlayer#createMessage(Target)} + * on the playback thread: + * + * <ul> + * <li>Message with type {@link C#MSG_SET_VOLUME} to set the volume. The message payload should be + * a {@link Float} with 0 being silence and 1 being unity gain. + * <li>Message with type {@link C#MSG_SET_AUDIO_ATTRIBUTES} to set the audio attributes. The + * message payload should be an {@link org.mozilla.thirdparty.com.google.android.exoplayer2audio.AudioAttributes} + * instance that will configure the underlying audio track. + * <li>Message with type {@link C#MSG_SET_AUX_EFFECT_INFO} to set the auxiliary effect. The + * message payload should be an {@link AuxEffectInfo} instance that will configure the + * underlying audio track. + * </ul> + */ +public class MediaCodecAudioRenderer extends MediaCodecRenderer implements MediaClock { + + /** + * Maximum number of tracked pending stream change times. Generally there is zero or one pending + * stream change. We track more to allow for pending changes that have fewer samples than the + * codec latency. + */ + private static final int MAX_PENDING_STREAM_CHANGE_COUNT = 10; + + private static final String TAG = "MediaCodecAudioRenderer"; + /** + * Custom key used to indicate bits per sample by some decoders on Vivo devices. For example + * OMX.vivo.alac.decoder on the Vivo Z1 Pro. + */ + private static final String VIVO_BITS_PER_SAMPLE_KEY = "v-bits-per-sample"; + + private final Context context; + private final EventDispatcher eventDispatcher; + private final AudioSink audioSink; + private final long[] pendingStreamChangeTimesUs; + + private int codecMaxInputSize; + private boolean passthroughEnabled; + private boolean codecNeedsDiscardChannelsWorkaround; + private boolean codecNeedsEosBufferTimestampWorkaround; + private android.media.MediaFormat passthroughMediaFormat; + @Nullable private Format inputFormat; + private long currentPositionUs; + private boolean allowFirstBufferPositionDiscontinuity; + private boolean allowPositionDiscontinuity; + private long lastInputTimeUs; + private int pendingStreamChangeCount; + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + */ + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer(Context context, MediaCodecSelector mediaCodecSelector) { + this( + context, + mediaCodecSelector, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @deprecated Use {@link #MediaCodecAudioRenderer(Context, MediaCodecSelector, boolean, Handler, + * AudioRendererEventListener, AudioSink)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys) { + this( + context, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + /* eventHandler= */ null, + /* eventListener= */ null); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + */ + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener) { + this( + context, + mediaCodecSelector, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + eventHandler, + eventListener); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @deprecated Use {@link #MediaCodecAudioRenderer(Context, MediaCodecSelector, boolean, Handler, + * AudioRendererEventListener, AudioSink)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener) { + this( + context, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + eventHandler, + eventListener, + (AudioCapabilities) null); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + * @param audioProcessors Optional {@link AudioProcessor}s that will process PCM audio before + * output. + * @deprecated Use {@link #MediaCodecAudioRenderer(Context, MediaCodecSelector, boolean, Handler, + * AudioRendererEventListener, AudioSink)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + @Nullable AudioCapabilities audioCapabilities, + AudioProcessor... audioProcessors) { + this( + context, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + eventHandler, + eventListener, + new DefaultAudioSink(audioCapabilities, audioProcessors)); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + * @deprecated Use {@link #MediaCodecAudioRenderer(Context, MediaCodecSelector, boolean, Handler, + * AudioRendererEventListener, AudioSink)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + this( + context, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + /* enableDecoderFallback= */ false, + eventHandler, + eventListener, + audioSink); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is slower/less efficient than + * the primary decoder. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + */ + @SuppressWarnings("deprecation") + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + this( + context, + mediaCodecSelector, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + enableDecoderFallback, + eventHandler, + eventListener, + audioSink); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is slower/less efficient than + * the primary decoder. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioSink The sink to which audio will be output. + * @deprecated Use {@link #MediaCodecAudioRenderer(Context, MediaCodecSelector, boolean, Handler, + * AudioRendererEventListener, AudioSink)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + public MediaCodecAudioRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioSink audioSink) { + super( + C.TRACK_TYPE_AUDIO, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + enableDecoderFallback, + /* assumedMinimumCodecOperatingRate= */ 44100); + this.context = context.getApplicationContext(); + this.audioSink = audioSink; + lastInputTimeUs = C.TIME_UNSET; + pendingStreamChangeTimesUs = new long[MAX_PENDING_STREAM_CHANGE_COUNT]; + eventDispatcher = new EventDispatcher(eventHandler, eventListener); + audioSink.setListener(new AudioSinkListener()); + } + + @Override + @Capabilities + protected int supportsFormat( + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + Format format) + throws DecoderQueryException { + String mimeType = format.sampleMimeType; + if (!MimeTypes.isAudio(mimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + @TunnelingSupport + int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; + boolean supportsFormatDrm = + format.drmInitData == null + || FrameworkMediaCrypto.class.equals(format.exoMediaCryptoType) + || (format.exoMediaCryptoType == null + && supportsFormatDrm(drmSessionManager, format.drmInitData)); + if (supportsFormatDrm + && allowPassthrough(format.channelCount, mimeType) + && mediaCodecSelector.getPassthroughDecoderInfo() != null) { + return RendererCapabilities.create(FORMAT_HANDLED, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); + } + if ((MimeTypes.AUDIO_RAW.equals(mimeType) + && !audioSink.supportsOutput(format.channelCount, format.pcmEncoding)) + || !audioSink.supportsOutput(format.channelCount, C.ENCODING_PCM_16BIT)) { + // Assume the decoder outputs 16-bit PCM, unless the input is raw. + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + List<MediaCodecInfo> decoderInfos = + getDecoderInfos(mediaCodecSelector, format, /* requiresSecureDecoder= */ false); + if (decoderInfos.isEmpty()) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + if (!supportsFormatDrm) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM); + } + // Check capabilities for the first decoder in the list, which takes priority. + MediaCodecInfo decoderInfo = decoderInfos.get(0); + boolean isFormatSupported = decoderInfo.isFormatSupported(format); + @AdaptiveSupport + int adaptiveSupport = + isFormatSupported && decoderInfo.isSeamlessAdaptationSupported(format) + ? ADAPTIVE_SEAMLESS + : ADAPTIVE_NOT_SEAMLESS; + @FormatSupport + int formatSupport = isFormatSupported ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES; + return RendererCapabilities.create(formatSupport, adaptiveSupport, tunnelingSupport); + } + + @Override + protected List<MediaCodecInfo> getDecoderInfos( + MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder) + throws DecoderQueryException { + @Nullable String mimeType = format.sampleMimeType; + if (mimeType == null) { + return Collections.emptyList(); + } + if (allowPassthrough(format.channelCount, mimeType)) { + @Nullable + MediaCodecInfo passthroughDecoderInfo = mediaCodecSelector.getPassthroughDecoderInfo(); + if (passthroughDecoderInfo != null) { + return Collections.singletonList(passthroughDecoderInfo); + } + } + List<MediaCodecInfo> decoderInfos = + mediaCodecSelector.getDecoderInfos( + mimeType, requiresSecureDecoder, /* requiresTunnelingDecoder= */ false); + decoderInfos = MediaCodecUtil.getDecoderInfosSortedByFormatSupport(decoderInfos, format); + if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { + // E-AC3 decoders can decode JOC streams, but in 2-D rather than 3-D. + List<MediaCodecInfo> decoderInfosWithEac3 = new ArrayList<>(decoderInfos); + decoderInfosWithEac3.addAll( + mediaCodecSelector.getDecoderInfos( + MimeTypes.AUDIO_E_AC3, requiresSecureDecoder, /* requiresTunnelingDecoder= */ false)); + decoderInfos = decoderInfosWithEac3; + } + return Collections.unmodifiableList(decoderInfos); + } + + /** + * Returns whether encoded audio passthrough should be used for playing back the input format. + * This implementation returns true if the {@link AudioSink} indicates that encoded audio output + * is supported. + * + * @param channelCount The number of channels in the input media, or {@link Format#NO_VALUE} if + * not known. + * @param mimeType The type of input media. + * @return Whether passthrough playback is supported. + */ + protected boolean allowPassthrough(int channelCount, String mimeType) { + return getPassthroughEncoding(channelCount, mimeType) != C.ENCODING_INVALID; + } + + @Override + protected void configureCodec( + MediaCodecInfo codecInfo, + MediaCodec codec, + Format format, + @Nullable MediaCrypto crypto, + float codecOperatingRate) { + codecMaxInputSize = getCodecMaxInputSize(codecInfo, format, getStreamFormats()); + codecNeedsDiscardChannelsWorkaround = codecNeedsDiscardChannelsWorkaround(codecInfo.name); + codecNeedsEosBufferTimestampWorkaround = codecNeedsEosBufferTimestampWorkaround(codecInfo.name); + passthroughEnabled = codecInfo.passthrough; + String codecMimeType = passthroughEnabled ? MimeTypes.AUDIO_RAW : codecInfo.codecMimeType; + MediaFormat mediaFormat = + getMediaFormat(format, codecMimeType, codecMaxInputSize, codecOperatingRate); + codec.configure(mediaFormat, /* surface= */ null, crypto, /* flags= */ 0); + if (passthroughEnabled) { + // Store the input MIME type if we're using the passthrough codec. + passthroughMediaFormat = mediaFormat; + passthroughMediaFormat.setString(MediaFormat.KEY_MIME, format.sampleMimeType); + } else { + passthroughMediaFormat = null; + } + } + + @Override + protected @KeepCodecResult int canKeepCodec( + MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { + // TODO: We currently rely on recreating the codec when encoder delay or padding is non-zero. + // Re-creating the codec is necessary to guarantee that onOutputFormatChanged is called, which + // is where encoder delay and padding are propagated to the sink. We should find a better way to + // propagate these values, and then allow the codec to be re-used in cases where this would + // otherwise be possible. + if (getCodecMaxInputSize(codecInfo, newFormat) > codecMaxInputSize + || oldFormat.encoderDelay != 0 + || oldFormat.encoderPadding != 0 + || newFormat.encoderDelay != 0 + || newFormat.encoderPadding != 0) { + return KEEP_CODEC_RESULT_NO; + } else if (codecInfo.isSeamlessAdaptationSupported( + oldFormat, newFormat, /* isNewFormatComplete= */ true)) { + return KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION; + } else if (canKeepCodecWithFlush(oldFormat, newFormat)) { + return KEEP_CODEC_RESULT_YES_WITH_FLUSH; + } else { + return KEEP_CODEC_RESULT_NO; + } + } + + /** + * Returns whether the codec can be flushed and reused when switching to a new format. Reuse is + * generally possible when the codec would be configured in an identical way after the format + * change (excluding {@link MediaFormat#KEY_MAX_INPUT_SIZE} and configuration that does not come + * from the {@link Format}). + * + * @param oldFormat The first format. + * @param newFormat The second format. + * @return Whether the codec can be flushed and reused when switching to a new format. + */ + protected boolean canKeepCodecWithFlush(Format oldFormat, Format newFormat) { + // Flush and reuse the codec if the audio format and initialization data matches. For Opus, we + // don't flush and reuse the codec because the decoder may discard samples after flushing, which + // would result in audio being dropped just after a stream change (see [Internal: b/143450854]). + return Util.areEqual(oldFormat.sampleMimeType, newFormat.sampleMimeType) + && oldFormat.channelCount == newFormat.channelCount + && oldFormat.sampleRate == newFormat.sampleRate + && oldFormat.pcmEncoding == newFormat.pcmEncoding + && oldFormat.initializationDataEquals(newFormat) + && !MimeTypes.AUDIO_OPUS.equals(oldFormat.sampleMimeType); + } + + @Override + @Nullable + public MediaClock getMediaClock() { + return this; + } + + @Override + protected float getCodecOperatingRateV23( + float operatingRate, Format format, Format[] streamFormats) { + // Use the highest known stream sample-rate up front, to avoid having to reconfigure the codec + // should an adaptive switch to that stream occur. + int maxSampleRate = -1; + for (Format streamFormat : streamFormats) { + int streamSampleRate = streamFormat.sampleRate; + if (streamSampleRate != Format.NO_VALUE) { + maxSampleRate = Math.max(maxSampleRate, streamSampleRate); + } + } + return maxSampleRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxSampleRate * operatingRate); + } + + @Override + protected void onCodecInitialized(String name, long initializedTimestampMs, + long initializationDurationMs) { + eventDispatcher.decoderInitialized(name, initializedTimestampMs, initializationDurationMs); + } + + @Override + protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { + super.onInputFormatChanged(formatHolder); + inputFormat = formatHolder.format; + eventDispatcher.inputFormatChanged(inputFormat); + } + + @Override + protected void onOutputFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) + throws ExoPlaybackException { + @C.Encoding int encoding; + MediaFormat mediaFormat; + if (passthroughMediaFormat != null) { + mediaFormat = passthroughMediaFormat; + encoding = + getPassthroughEncoding( + mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT), + mediaFormat.getString(MediaFormat.KEY_MIME)); + } else { + mediaFormat = outputMediaFormat; + if (outputMediaFormat.containsKey(VIVO_BITS_PER_SAMPLE_KEY)) { + encoding = Util.getPcmEncoding(outputMediaFormat.getInteger(VIVO_BITS_PER_SAMPLE_KEY)); + } else { + encoding = getPcmEncoding(inputFormat); + } + } + int channelCount = mediaFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT); + int sampleRate = mediaFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE); + int[] channelMap; + if (codecNeedsDiscardChannelsWorkaround && channelCount == 6 && inputFormat.channelCount < 6) { + channelMap = new int[inputFormat.channelCount]; + for (int i = 0; i < inputFormat.channelCount; i++) { + channelMap[i] = i; + } + } else { + channelMap = null; + } + + try { + audioSink.configure( + encoding, + channelCount, + sampleRate, + 0, + channelMap, + inputFormat.encoderDelay, + inputFormat.encoderPadding); + } catch (AudioSink.ConfigurationException e) { + // TODO(internal: b/145658993) Use outputFormat instead. + throw createRendererException(e, inputFormat); + } + } + + /** + * Returns the {@link C.Encoding} constant to use for passthrough of the given format, or {@link + * C#ENCODING_INVALID} if passthrough is not possible. + */ + @C.Encoding + protected int getPassthroughEncoding(int channelCount, String mimeType) { + if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType)) { + // E-AC3 JOC is object-based so the output channel count is arbitrary. + if (audioSink.supportsOutput(/* channelCount= */ Format.NO_VALUE, C.ENCODING_E_AC3_JOC)) { + return MimeTypes.getEncoding(MimeTypes.AUDIO_E_AC3_JOC); + } + // E-AC3 receivers can decode JOC streams, but in 2-D rather than 3-D, so try to fall back. + mimeType = MimeTypes.AUDIO_E_AC3; + } + + @C.Encoding int encoding = MimeTypes.getEncoding(mimeType); + if (audioSink.supportsOutput(channelCount, encoding)) { + return encoding; + } else { + return C.ENCODING_INVALID; + } + } + + /** + * Called when the audio session id becomes known. The default implementation is a no-op. One + * reason for overriding this method would be to instantiate and enable a {@link Virtualizer} in + * order to spatialize the audio channels. For this use case, any {@link Virtualizer} instances + * should be released in {@link #onDisabled()} (if not before). + * + * @see AudioSink.Listener#onAudioSessionId(int) + */ + protected void onAudioSessionId(int audioSessionId) { + // Do nothing. + } + + /** + * @see AudioSink.Listener#onPositionDiscontinuity() + */ + protected void onAudioTrackPositionDiscontinuity() { + // Do nothing. + } + + /** + * @see AudioSink.Listener#onUnderrun(int, long, long) + */ + protected void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, + long elapsedSinceLastFeedMs) { + // Do nothing. + } + + @Override + protected void onEnabled(boolean joining) throws ExoPlaybackException { + super.onEnabled(joining); + eventDispatcher.enabled(decoderCounters); + int tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; + if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { + audioSink.enableTunnelingV21(tunnelingAudioSessionId); + } else { + audioSink.disableTunneling(); + } + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + super.onStreamChanged(formats, offsetUs); + if (lastInputTimeUs != C.TIME_UNSET) { + if (pendingStreamChangeCount == pendingStreamChangeTimesUs.length) { + Log.w( + TAG, + "Too many stream changes, so dropping change at " + + pendingStreamChangeTimesUs[pendingStreamChangeCount - 1]); + } else { + pendingStreamChangeCount++; + } + pendingStreamChangeTimesUs[pendingStreamChangeCount - 1] = lastInputTimeUs; + } + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + super.onPositionReset(positionUs, joining); + audioSink.flush(); + currentPositionUs = positionUs; + allowFirstBufferPositionDiscontinuity = true; + allowPositionDiscontinuity = true; + lastInputTimeUs = C.TIME_UNSET; + pendingStreamChangeCount = 0; + } + + @Override + protected void onStarted() { + super.onStarted(); + audioSink.play(); + } + + @Override + protected void onStopped() { + updateCurrentPosition(); + audioSink.pause(); + super.onStopped(); + } + + @Override + protected void onDisabled() { + try { + lastInputTimeUs = C.TIME_UNSET; + pendingStreamChangeCount = 0; + audioSink.flush(); + } finally { + try { + super.onDisabled(); + } finally { + eventDispatcher.disabled(decoderCounters); + } + } + } + + @Override + protected void onReset() { + try { + super.onReset(); + } finally { + audioSink.reset(); + } + } + + @Override + public boolean isEnded() { + return super.isEnded() && audioSink.isEnded(); + } + + @Override + public boolean isReady() { + return audioSink.hasPendingData() || super.isReady(); + } + + @Override + public long getPositionUs() { + if (getState() == STATE_STARTED) { + updateCurrentPosition(); + } + return currentPositionUs; + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + audioSink.setPlaybackParameters(playbackParameters); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return audioSink.getPlaybackParameters(); + } + + @Override + protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + if (allowFirstBufferPositionDiscontinuity && !buffer.isDecodeOnly()) { + // TODO: Remove this hack once we have a proper fix for [Internal: b/71876314]. + // Allow the position to jump if the first presentable input buffer has a timestamp that + // differs significantly from what was expected. + if (Math.abs(buffer.timeUs - currentPositionUs) > 500000) { + currentPositionUs = buffer.timeUs; + } + allowFirstBufferPositionDiscontinuity = false; + } + lastInputTimeUs = Math.max(buffer.timeUs, lastInputTimeUs); + } + + @CallSuper + @Override + protected void onProcessedOutputBuffer(long presentationTimeUs) { + while (pendingStreamChangeCount != 0 && presentationTimeUs >= pendingStreamChangeTimesUs[0]) { + audioSink.handleDiscontinuity(); + pendingStreamChangeCount--; + System.arraycopy( + pendingStreamChangeTimesUs, + /* srcPos= */ 1, + pendingStreamChangeTimesUs, + /* destPos= */ 0, + pendingStreamChangeCount); + } + } + + @Override + protected boolean processOutputBuffer( + long positionUs, + long elapsedRealtimeUs, + MediaCodec codec, + ByteBuffer buffer, + int bufferIndex, + int bufferFlags, + long bufferPresentationTimeUs, + boolean isDecodeOnlyBuffer, + boolean isLastBuffer, + Format format) + throws ExoPlaybackException { + if (codecNeedsEosBufferTimestampWorkaround + && bufferPresentationTimeUs == 0 + && (bufferFlags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0 + && lastInputTimeUs != C.TIME_UNSET) { + bufferPresentationTimeUs = lastInputTimeUs; + } + + if (passthroughEnabled && (bufferFlags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + // Discard output buffers from the passthrough (raw) decoder containing codec specific data. + codec.releaseOutputBuffer(bufferIndex, false); + return true; + } + + if (isDecodeOnlyBuffer) { + codec.releaseOutputBuffer(bufferIndex, false); + decoderCounters.skippedOutputBufferCount++; + audioSink.handleDiscontinuity(); + return true; + } + + try { + if (audioSink.handleBuffer(buffer, bufferPresentationTimeUs)) { + codec.releaseOutputBuffer(bufferIndex, false); + decoderCounters.renderedOutputBufferCount++; + return true; + } + } catch (AudioSink.InitializationException | AudioSink.WriteException e) { + // TODO(internal: b/145658993) Use outputFormat instead. + throw createRendererException(e, inputFormat); + } + return false; + } + + @Override + protected void renderToEndOfStream() throws ExoPlaybackException { + try { + audioSink.playToEndOfStream(); + } catch (AudioSink.WriteException e) { + // TODO(internal: b/145658993) Use outputFormat instead. + throw createRendererException(e, inputFormat); + } + } + + @Override + public void handleMessage(int messageType, @Nullable Object message) throws ExoPlaybackException { + switch (messageType) { + case C.MSG_SET_VOLUME: + audioSink.setVolume((Float) message); + break; + case C.MSG_SET_AUDIO_ATTRIBUTES: + AudioAttributes audioAttributes = (AudioAttributes) message; + audioSink.setAudioAttributes(audioAttributes); + break; + case C.MSG_SET_AUX_EFFECT_INFO: + AuxEffectInfo auxEffectInfo = (AuxEffectInfo) message; + audioSink.setAuxEffectInfo(auxEffectInfo); + break; + default: + super.handleMessage(messageType, message); + break; + } + } + + /** + * Returns a maximum input size suitable for configuring a codec for {@code format} in a way that + * will allow possible adaptation to other compatible formats in {@code streamFormats}. + * + * @param codecInfo A {@link MediaCodecInfo} describing the decoder. + * @param format The {@link Format} for which the codec is being configured. + * @param streamFormats The possible stream formats. + * @return A suitable maximum input size. + */ + protected int getCodecMaxInputSize( + MediaCodecInfo codecInfo, Format format, Format[] streamFormats) { + int maxInputSize = getCodecMaxInputSize(codecInfo, format); + if (streamFormats.length == 1) { + // The single entry in streamFormats must correspond to the format for which the codec is + // being configured. + return maxInputSize; + } + for (Format streamFormat : streamFormats) { + if (codecInfo.isSeamlessAdaptationSupported( + format, streamFormat, /* isNewFormatComplete= */ false)) { + maxInputSize = Math.max(maxInputSize, getCodecMaxInputSize(codecInfo, streamFormat)); + } + } + return maxInputSize; + } + + /** + * Returns a maximum input buffer size for a given {@link Format}. + * + * @param codecInfo A {@link MediaCodecInfo} describing the decoder. + * @param format The {@link Format}. + * @return A maximum input buffer size in bytes, or {@link Format#NO_VALUE} if a maximum could not + * be determined. + */ + private int getCodecMaxInputSize(MediaCodecInfo codecInfo, Format format) { + if ("OMX.google.raw.decoder".equals(codecInfo.name)) { + // OMX.google.raw.decoder didn't resize its output buffers correctly prior to N, except on + // Android TV running M, so there's no point requesting a non-default input size. Doing so may + // cause a native crash, whereas not doing so will cause a more controlled failure when + // attempting to fill an input buffer. See: https://github.com/google/ExoPlayer/issues/4057. + if (Util.SDK_INT < 24 && !(Util.SDK_INT == 23 && Util.isTv(context))) { + return Format.NO_VALUE; + } + } + return format.maxInputSize; + } + + /** + * Returns the framework {@link MediaFormat} that can be used to configure a {@link MediaCodec} + * for decoding the given {@link Format} for playback. + * + * @param format The {@link Format} of the media. + * @param codecMimeType The MIME type handled by the codec. + * @param codecMaxInputSize The maximum input size supported by the codec. + * @param codecOperatingRate The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if + * no codec operating rate should be set. + * @return The framework {@link MediaFormat}. + */ + @SuppressLint("InlinedApi") + protected MediaFormat getMediaFormat( + Format format, String codecMimeType, int codecMaxInputSize, float codecOperatingRate) { + MediaFormat mediaFormat = new MediaFormat(); + // Set format parameters that should always be set. + mediaFormat.setString(MediaFormat.KEY_MIME, codecMimeType); + mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, format.channelCount); + mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, format.sampleRate); + MediaFormatUtil.setCsdBuffers(mediaFormat, format.initializationData); + // Set codec max values. + MediaFormatUtil.maybeSetInteger(mediaFormat, MediaFormat.KEY_MAX_INPUT_SIZE, codecMaxInputSize); + // Set codec configuration values. + if (Util.SDK_INT >= 23) { + mediaFormat.setInteger(MediaFormat.KEY_PRIORITY, 0 /* realtime priority */); + if (codecOperatingRate != CODEC_OPERATING_RATE_UNSET && !deviceDoesntSupportOperatingRate()) { + mediaFormat.setFloat(MediaFormat.KEY_OPERATING_RATE, codecOperatingRate); + } + } + if (Util.SDK_INT <= 28 && MimeTypes.AUDIO_AC4.equals(format.sampleMimeType)) { + // On some older builds, the AC-4 decoder expects to receive samples formatted as raw frames + // not sync frames. Set a format key to override this. + mediaFormat.setInteger("ac4-is-sync", 1); + } + return mediaFormat; + } + + private void updateCurrentPosition() { + long newCurrentPositionUs = audioSink.getCurrentPositionUs(isEnded()); + if (newCurrentPositionUs != AudioSink.CURRENT_POSITION_NOT_SET) { + currentPositionUs = + allowPositionDiscontinuity + ? newCurrentPositionUs + : Math.max(currentPositionUs, newCurrentPositionUs); + allowPositionDiscontinuity = false; + } + } + + /** + * Returns whether the device's decoders are known to not support setting the codec operating + * rate. + * + * <p>See <a href="https://github.com/google/ExoPlayer/issues/5821">GitHub issue #5821</a>. + */ + private static boolean deviceDoesntSupportOperatingRate() { + return Util.SDK_INT == 23 + && ("ZTE B2017G".equals(Util.MODEL) || "AXON 7 mini".equals(Util.MODEL)); + } + + /** + * Returns whether the decoder is known to output six audio channels when provided with input with + * fewer than six channels. + * <p> + * See [Internal: b/35655036]. + */ + private static boolean codecNeedsDiscardChannelsWorkaround(String codecName) { + // The workaround applies to Samsung Galaxy S6 and Samsung Galaxy S7. + return Util.SDK_INT < 24 && "OMX.SEC.aac.dec".equals(codecName) + && "samsung".equals(Util.MANUFACTURER) + && (Util.DEVICE.startsWith("zeroflte") || Util.DEVICE.startsWith("herolte") + || Util.DEVICE.startsWith("heroqlte")); + } + + /** + * Returns whether the decoder may output a non-empty buffer with timestamp 0 as the end of stream + * buffer. + * + * <p>See <a href="https://github.com/google/ExoPlayer/issues/5045">GitHub issue #5045</a>. + */ + private static boolean codecNeedsEosBufferTimestampWorkaround(String codecName) { + return Util.SDK_INT < 21 + && "OMX.SEC.mp3.dec".equals(codecName) + && "samsung".equals(Util.MANUFACTURER) + && (Util.DEVICE.startsWith("baffin") + || Util.DEVICE.startsWith("grand") + || Util.DEVICE.startsWith("fortuna") + || Util.DEVICE.startsWith("gprimelte") + || Util.DEVICE.startsWith("j2y18lte") + || Util.DEVICE.startsWith("ms01")); + } + + @C.Encoding + private static int getPcmEncoding(Format format) { + // If the format is anything other than PCM then we assume that the audio decoder will output + // 16-bit PCM. + return MimeTypes.AUDIO_RAW.equals(format.sampleMimeType) + ? format.pcmEncoding + : C.ENCODING_PCM_16BIT; + } + + private final class AudioSinkListener implements AudioSink.Listener { + + @Override + public void onAudioSessionId(int audioSessionId) { + eventDispatcher.audioSessionId(audioSessionId); + MediaCodecAudioRenderer.this.onAudioSessionId(audioSessionId); + } + + @Override + public void onPositionDiscontinuity() { + onAudioTrackPositionDiscontinuity(); + // We are out of sync so allow currentPositionUs to jump backwards. + MediaCodecAudioRenderer.this.allowPositionDiscontinuity = true; + } + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java new file mode 100644 index 0000000000..efd8a30d61 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/ResamplingAudioProcessor.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import java.nio.ByteBuffer; + +/** + * An {@link AudioProcessor} that converts different PCM audio encodings to 16-bit integer PCM. The + * following encodings are supported as input: + * + * <ul> + * <li>{@link C#ENCODING_PCM_8BIT} + * <li>{@link C#ENCODING_PCM_16BIT} ({@link #isActive()} will return {@code false}) + * <li>{@link C#ENCODING_PCM_16BIT_BIG_ENDIAN} + * <li>{@link C#ENCODING_PCM_24BIT} + * <li>{@link C#ENCODING_PCM_32BIT} + * <li>{@link C#ENCODING_PCM_FLOAT} + * </ul> + */ +/* package */ final class ResamplingAudioProcessor extends BaseAudioProcessor { + + @Override + public AudioFormat onConfigure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + @C.PcmEncoding int encoding = inputAudioFormat.encoding; + if (encoding != C.ENCODING_PCM_8BIT + && encoding != C.ENCODING_PCM_16BIT + && encoding != C.ENCODING_PCM_16BIT_BIG_ENDIAN + && encoding != C.ENCODING_PCM_24BIT + && encoding != C.ENCODING_PCM_32BIT + && encoding != C.ENCODING_PCM_FLOAT) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + return encoding != C.ENCODING_PCM_16BIT + ? new AudioFormat( + inputAudioFormat.sampleRate, inputAudioFormat.channelCount, C.ENCODING_PCM_16BIT) + : AudioFormat.NOT_SET; + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + // Prepare the output buffer. + int position = inputBuffer.position(); + int limit = inputBuffer.limit(); + int size = limit - position; + int resampledSize; + switch (inputAudioFormat.encoding) { + case C.ENCODING_PCM_8BIT: + resampledSize = size * 2; + break; + case C.ENCODING_PCM_16BIT_BIG_ENDIAN: + resampledSize = size; + break; + case C.ENCODING_PCM_24BIT: + resampledSize = (size / 3) * 2; + break; + case C.ENCODING_PCM_32BIT: + case C.ENCODING_PCM_FLOAT: + resampledSize = size / 2; + break; + case C.ENCODING_PCM_16BIT: + case C.ENCODING_INVALID: + case Format.NO_VALUE: + default: + throw new IllegalStateException(); + } + + // Resample the little endian input and update the input/output buffers. + ByteBuffer buffer = replaceOutputBuffer(resampledSize); + switch (inputAudioFormat.encoding) { + case C.ENCODING_PCM_8BIT: + // 8 -> 16 bit resampling. Shift each byte from [0, 256) to [-128, 128) and scale up. + for (int i = position; i < limit; i++) { + buffer.put((byte) 0); + buffer.put((byte) ((inputBuffer.get(i) & 0xFF) - 128)); + } + break; + case C.ENCODING_PCM_16BIT_BIG_ENDIAN: + // Big endian to little endian resampling. Swap the byte order. + for (int i = position; i < limit; i += 2) { + buffer.put(inputBuffer.get(i + 1)); + buffer.put(inputBuffer.get(i)); + } + break; + case C.ENCODING_PCM_24BIT: + // 24 -> 16 bit resampling. Drop the least significant byte. + for (int i = position; i < limit; i += 3) { + buffer.put(inputBuffer.get(i + 1)); + buffer.put(inputBuffer.get(i + 2)); + } + break; + case C.ENCODING_PCM_32BIT: + // 32 -> 16 bit resampling. Drop the two least significant bytes. + for (int i = position; i < limit; i += 4) { + buffer.put(inputBuffer.get(i + 2)); + buffer.put(inputBuffer.get(i + 3)); + } + break; + case C.ENCODING_PCM_FLOAT: + // 32 bit floating point -> 16 bit resampling. Floating point values are in the range + // [-1.0, 1.0], so need to be scaled by Short.MAX_VALUE. + for (int i = position; i < limit; i += 4) { + short value = (short) (inputBuffer.getFloat(i) * Short.MAX_VALUE); + buffer.put((byte) (value & 0xFF)); + buffer.put((byte) ((value >> 8) & 0xFF)); + } + break; + case C.ENCODING_PCM_16BIT: + case C.ENCODING_INVALID: + case Format.NO_VALUE: + default: + // Never happens. + throw new IllegalStateException(); + } + inputBuffer.position(inputBuffer.limit()); + buffer.flip(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java new file mode 100644 index 0000000000..6a2c5ae9a6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SilenceSkippingAudioProcessor.java @@ -0,0 +1,352 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; + +/** + * An {@link AudioProcessor} that skips silence in the input stream. Input and output are 16-bit + * PCM. + */ +public final class SilenceSkippingAudioProcessor extends BaseAudioProcessor { + + /** + * The minimum duration of audio that must be below {@link #SILENCE_THRESHOLD_LEVEL} to classify + * that part of audio as silent, in microseconds. + */ + private static final long MINIMUM_SILENCE_DURATION_US = 150_000; + /** + * The duration of silence by which to extend non-silent sections, in microseconds. The value must + * not exceed {@link #MINIMUM_SILENCE_DURATION_US}. + */ + private static final long PADDING_SILENCE_US = 20_000; + /** + * The absolute level below which an individual PCM sample is classified as silent. Note: the + * specified value will be rounded so that the threshold check only depends on the more + * significant byte, for efficiency. + */ + private static final short SILENCE_THRESHOLD_LEVEL = 1024; + + /** + * Threshold for classifying an individual PCM sample as silent based on its more significant + * byte. This is {@link #SILENCE_THRESHOLD_LEVEL} divided by 256 with rounding. + */ + private static final byte SILENCE_THRESHOLD_LEVEL_MSB = (SILENCE_THRESHOLD_LEVEL + 128) >> 8; + + /** Trimming states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_NOISY, + STATE_MAYBE_SILENT, + STATE_SILENT, + }) + private @interface State {} + /** State when the input is not silent. */ + private static final int STATE_NOISY = 0; + /** State when the input may be silent but we haven't read enough yet to know. */ + private static final int STATE_MAYBE_SILENT = 1; + /** State when the input is silent. */ + private static final int STATE_SILENT = 2; + + private int bytesPerFrame; + + private boolean enabled; + + /** + * Buffers audio data that may be classified as silence while in {@link #STATE_MAYBE_SILENT}. If + * the input becomes noisy before the buffer has filled, it will be output. Otherwise, the buffer + * contents will be dropped and the state will transition to {@link #STATE_SILENT}. + */ + private byte[] maybeSilenceBuffer; + + /** + * Stores the latest part of the input while silent. It will be output as padding if the next + * input is noisy. + */ + private byte[] paddingBuffer; + + @State private int state; + private int maybeSilenceBufferSize; + private int paddingSize; + private boolean hasOutputNoise; + private long skippedFrames; + + /** Creates a new silence trimming audio processor. */ + public SilenceSkippingAudioProcessor() { + maybeSilenceBuffer = Util.EMPTY_BYTE_ARRAY; + paddingBuffer = Util.EMPTY_BYTE_ARRAY; + } + + /** + * Sets whether to skip silence in the input. This method may only be called after draining data + * through the processor. The value returned by {@link #isActive()} may change, and the processor + * must be {@link #flush() flushed} before queueing more data. + * + * @param enabled Whether to skip silence in the input. + */ + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** + * Returns the total number of frames of input audio that were skipped due to being classified as + * silence since the last call to {@link #flush()}. + */ + public long getSkippedFrames() { + return skippedFrames; + } + + // AudioProcessor implementation. + + @Override + public AudioFormat onConfigure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + return enabled ? inputAudioFormat : AudioFormat.NOT_SET; + } + + @Override + public boolean isActive() { + return enabled; + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + while (inputBuffer.hasRemaining() && !hasPendingOutput()) { + switch (state) { + case STATE_NOISY: + processNoisy(inputBuffer); + break; + case STATE_MAYBE_SILENT: + processMaybeSilence(inputBuffer); + break; + case STATE_SILENT: + processSilence(inputBuffer); + break; + default: + throw new IllegalStateException(); + } + } + } + + @Override + protected void onQueueEndOfStream() { + if (maybeSilenceBufferSize > 0) { + // We haven't received enough silence to transition to the silent state, so output the buffer. + output(maybeSilenceBuffer, maybeSilenceBufferSize); + } + if (!hasOutputNoise) { + skippedFrames += paddingSize / bytesPerFrame; + } + } + + @Override + protected void onFlush() { + if (enabled) { + bytesPerFrame = inputAudioFormat.bytesPerFrame; + int maybeSilenceBufferSize = durationUsToFrames(MINIMUM_SILENCE_DURATION_US) * bytesPerFrame; + if (maybeSilenceBuffer.length != maybeSilenceBufferSize) { + maybeSilenceBuffer = new byte[maybeSilenceBufferSize]; + } + paddingSize = durationUsToFrames(PADDING_SILENCE_US) * bytesPerFrame; + if (paddingBuffer.length != paddingSize) { + paddingBuffer = new byte[paddingSize]; + } + } + state = STATE_NOISY; + skippedFrames = 0; + maybeSilenceBufferSize = 0; + hasOutputNoise = false; + } + + @Override + protected void onReset() { + enabled = false; + paddingSize = 0; + maybeSilenceBuffer = Util.EMPTY_BYTE_ARRAY; + paddingBuffer = Util.EMPTY_BYTE_ARRAY; + } + + // Internal methods. + + /** + * Incrementally processes new input from {@code inputBuffer} while in {@link #STATE_NOISY}, + * updating the state if needed. + */ + private void processNoisy(ByteBuffer inputBuffer) { + int limit = inputBuffer.limit(); + + // Check if there's any noise within the maybe silence buffer duration. + inputBuffer.limit(Math.min(limit, inputBuffer.position() + maybeSilenceBuffer.length)); + int noiseLimit = findNoiseLimit(inputBuffer); + if (noiseLimit == inputBuffer.position()) { + // The buffer contains the start of possible silence. + state = STATE_MAYBE_SILENT; + } else { + inputBuffer.limit(noiseLimit); + output(inputBuffer); + } + + // Restore the limit. + inputBuffer.limit(limit); + } + + /** + * Incrementally processes new input from {@code inputBuffer} while in {@link + * #STATE_MAYBE_SILENT}, updating the state if needed. + */ + private void processMaybeSilence(ByteBuffer inputBuffer) { + int limit = inputBuffer.limit(); + int noisePosition = findNoisePosition(inputBuffer); + int maybeSilenceInputSize = noisePosition - inputBuffer.position(); + int maybeSilenceBufferRemaining = maybeSilenceBuffer.length - maybeSilenceBufferSize; + if (noisePosition < limit && maybeSilenceInputSize < maybeSilenceBufferRemaining) { + // The maybe silence buffer isn't full, so output it and switch back to the noisy state. + output(maybeSilenceBuffer, maybeSilenceBufferSize); + maybeSilenceBufferSize = 0; + state = STATE_NOISY; + } else { + // Fill as much of the maybe silence buffer as possible. + int bytesToWrite = Math.min(maybeSilenceInputSize, maybeSilenceBufferRemaining); + inputBuffer.limit(inputBuffer.position() + bytesToWrite); + inputBuffer.get(maybeSilenceBuffer, maybeSilenceBufferSize, bytesToWrite); + maybeSilenceBufferSize += bytesToWrite; + if (maybeSilenceBufferSize == maybeSilenceBuffer.length) { + // We've reached a period of silence, so skip it, taking in to account padding for both + // the noisy to silent transition and any future silent to noisy transition. + if (hasOutputNoise) { + output(maybeSilenceBuffer, paddingSize); + skippedFrames += (maybeSilenceBufferSize - paddingSize * 2) / bytesPerFrame; + } else { + skippedFrames += (maybeSilenceBufferSize - paddingSize) / bytesPerFrame; + } + updatePaddingBuffer(inputBuffer, maybeSilenceBuffer, maybeSilenceBufferSize); + maybeSilenceBufferSize = 0; + state = STATE_SILENT; + } + + // Restore the limit. + inputBuffer.limit(limit); + } + } + + /** + * Incrementally processes new input from {@code inputBuffer} while in {@link #STATE_SILENT}, + * updating the state if needed. + */ + private void processSilence(ByteBuffer inputBuffer) { + int limit = inputBuffer.limit(); + int noisyPosition = findNoisePosition(inputBuffer); + inputBuffer.limit(noisyPosition); + skippedFrames += inputBuffer.remaining() / bytesPerFrame; + updatePaddingBuffer(inputBuffer, paddingBuffer, paddingSize); + if (noisyPosition < limit) { + // Output the padding, which may include previous input as well as new input, then transition + // back to the noisy state. + output(paddingBuffer, paddingSize); + state = STATE_NOISY; + + // Restore the limit. + inputBuffer.limit(limit); + } + } + + /** + * Copies {@code length} elements from {@code data} to populate a new output buffer from the + * processor. + */ + private void output(byte[] data, int length) { + replaceOutputBuffer(length).put(data, 0, length).flip(); + if (length > 0) { + hasOutputNoise = true; + } + } + + /** + * Copies remaining bytes from {@code data} to populate a new output buffer from the processor. + */ + private void output(ByteBuffer data) { + int length = data.remaining(); + replaceOutputBuffer(length).put(data).flip(); + if (length > 0) { + hasOutputNoise = true; + } + } + + /** + * Fills {@link #paddingBuffer} using data from {@code input}, plus any additional buffered data + * at the end of {@code buffer} (up to its {@code size}) required to fill it, advancing the input + * position. + */ + private void updatePaddingBuffer(ByteBuffer input, byte[] buffer, int size) { + int fromInputSize = Math.min(input.remaining(), paddingSize); + int fromBufferSize = paddingSize - fromInputSize; + System.arraycopy( + /* src= */ buffer, + /* srcPos= */ size - fromBufferSize, + /* dest= */ paddingBuffer, + /* destPos= */ 0, + /* length= */ fromBufferSize); + input.position(input.limit() - fromInputSize); + input.get(paddingBuffer, fromBufferSize, fromInputSize); + } + + /** + * Returns the number of input frames corresponding to {@code durationUs} microseconds of audio. + */ + private int durationUsToFrames(long durationUs) { + return (int) ((durationUs * inputAudioFormat.sampleRate) / C.MICROS_PER_SECOND); + } + + /** + * Returns the earliest byte position in [position, limit) of {@code buffer} that contains a frame + * classified as a noisy frame, or the limit of the buffer if no such frame exists. + */ + private int findNoisePosition(ByteBuffer buffer) { + // The input is in ByteOrder.nativeOrder(), which is little endian on Android. + for (int i = buffer.position() + 1; i < buffer.limit(); i += 2) { + if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + // Round to the start of the frame. + return bytesPerFrame * (i / bytesPerFrame); + } + } + return buffer.limit(); + } + + /** + * Returns the earliest byte position in [position, limit) of {@code buffer} such that all frames + * from the byte position to the limit are classified as silent. + */ + private int findNoiseLimit(ByteBuffer buffer) { + // The input is in ByteOrder.nativeOrder(), which is little endian on Android. + for (int i = buffer.limit() - 1; i >= buffer.position(); i -= 2) { + if (Math.abs(buffer.get(i)) > SILENCE_THRESHOLD_LEVEL_MSB) { + // Return the start of the next frame. + return bytesPerFrame * (i / bytesPerFrame) + bytesPerFrame; + } + } + return buffer.position(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java new file mode 100644 index 0000000000..5e86e0ad78 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SimpleDecoderAudioRenderer.java @@ -0,0 +1,758 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import android.media.audiofx.Virtualizer; +import android.os.Handler; +import android.os.SystemClock; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +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.ExoPlayer; +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.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlayerMessage.Target; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.SimpleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.SimpleOutputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MediaClock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Decodes and renders audio using a {@link SimpleDecoder}. + * + * <p>This renderer accepts the following messages sent via {@link ExoPlayer#createMessage(Target)} + * on the playback thread: + * + * <ul> + * <li>Message with type {@link C#MSG_SET_VOLUME} to set the volume. The message payload should be + * a {@link Float} with 0 being silence and 1 being unity gain. + * <li>Message with type {@link C#MSG_SET_AUDIO_ATTRIBUTES} to set the audio attributes. The + * message payload should be an {@link org.mozilla.thirdparty.com.google.android.exoplayer2audio.AudioAttributes} + * instance that will configure the underlying audio track. + * <li>Message with type {@link C#MSG_SET_AUX_EFFECT_INFO} to set the auxiliary effect. The + * message payload should be an {@link AuxEffectInfo} instance that will configure the + * underlying audio track. + * </ul> + */ +public abstract class SimpleDecoderAudioRenderer extends BaseRenderer implements MediaClock { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + REINITIALIZATION_STATE_NONE, + REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM, + REINITIALIZATION_STATE_WAIT_END_OF_STREAM + }) + private @interface ReinitializationState {} + /** + * The decoder does not need to be re-initialized. + */ + private static final int REINITIALIZATION_STATE_NONE = 0; + /** + * The input format has changed in a way that requires the decoder to be re-initialized, but we + * haven't yet signaled an end of stream to the existing decoder. We need to do so in order to + * ensure that it outputs any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM = 1; + /** + * The input format has changed in a way that requires the decoder to be re-initialized, and we've + * signaled an end of stream to the existing decoder. We're waiting for the decoder to output an + * end of stream signal to indicate that it has output any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_WAIT_END_OF_STREAM = 2; + + private final DrmSessionManager<ExoMediaCrypto> drmSessionManager; + private final boolean playClearSamplesWithoutKeys; + private final EventDispatcher eventDispatcher; + private final AudioSink audioSink; + private final DecoderInputBuffer flagsOnlyBuffer; + + private boolean drmResourcesAcquired; + private DecoderCounters decoderCounters; + private Format inputFormat; + private int encoderDelay; + private int encoderPadding; + private SimpleDecoder<DecoderInputBuffer, ? extends SimpleOutputBuffer, + ? extends AudioDecoderException> decoder; + private DecoderInputBuffer inputBuffer; + private SimpleOutputBuffer outputBuffer; + @Nullable private DrmSession<ExoMediaCrypto> decoderDrmSession; + @Nullable private DrmSession<ExoMediaCrypto> sourceDrmSession; + + @ReinitializationState private int decoderReinitializationState; + private boolean decoderReceivedBuffers; + private boolean audioTrackNeedsConfigure; + + private long currentPositionUs; + private boolean allowFirstBufferPositionDiscontinuity; + private boolean allowPositionDiscontinuity; + private boolean inputStreamEnded; + private boolean outputStreamEnded; + private boolean waitingForKeys; + + public SimpleDecoderAudioRenderer() { + this(/* eventHandler= */ null, /* eventListener= */ null); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output. + */ + public SimpleDecoderAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + AudioProcessor... audioProcessors) { + this( + eventHandler, + eventListener, + /* audioCapabilities= */ null, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + audioProcessors); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + */ + public SimpleDecoderAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + @Nullable AudioCapabilities audioCapabilities) { + this( + eventHandler, + eventListener, + audioCapabilities, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param audioCapabilities The audio capabilities for playback on this device. May be null if the + * default capabilities (no encoded audio passthrough support) should be assumed. + * @param drmSessionManager For use with encrypted media. May be null if support for encrypted + * media is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param audioProcessors Optional {@link AudioProcessor}s that will process audio before output. + */ + public SimpleDecoderAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + @Nullable AudioCapabilities audioCapabilities, + @Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + AudioProcessor... audioProcessors) { + this(eventHandler, eventListener, drmSessionManager, + playClearSamplesWithoutKeys, new DefaultAudioSink(audioCapabilities, audioProcessors)); + } + + /** + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param drmSessionManager For use with encrypted media. May be null if support for encrypted + * media is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param audioSink The sink to which audio will be output. + */ + public SimpleDecoderAudioRenderer( + @Nullable Handler eventHandler, + @Nullable AudioRendererEventListener eventListener, + @Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + AudioSink audioSink) { + super(C.TRACK_TYPE_AUDIO); + this.drmSessionManager = drmSessionManager; + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + eventDispatcher = new EventDispatcher(eventHandler, eventListener); + this.audioSink = audioSink; + audioSink.setListener(new AudioSinkListener()); + flagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance(); + decoderReinitializationState = REINITIALIZATION_STATE_NONE; + audioTrackNeedsConfigure = true; + } + + @Override + @Nullable + public MediaClock getMediaClock() { + return this; + } + + @Override + @Capabilities + public final int supportsFormat(Format format) { + if (!MimeTypes.isAudio(format.sampleMimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + @FormatSupport int formatSupport = supportsFormatInternal(drmSessionManager, format); + if (formatSupport <= FORMAT_UNSUPPORTED_DRM) { + return RendererCapabilities.create(formatSupport); + } + @TunnelingSupport + int tunnelingSupport = Util.SDK_INT >= 21 ? TUNNELING_SUPPORTED : TUNNELING_NOT_SUPPORTED; + return RendererCapabilities.create(formatSupport, ADAPTIVE_NOT_SEAMLESS, tunnelingSupport); + } + + /** + * Returns the {@link FormatSupport} for the given {@link Format}. + * + * @param drmSessionManager The renderer's {@link DrmSessionManager}. + * @param format The format, which has an audio {@link Format#sampleMimeType}. + * @return The {@link FormatSupport} for this {@link Format}. + */ + @FormatSupport + protected abstract int supportsFormatInternal( + @Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, Format format); + + /** + * Returns whether the sink supports the audio format. + * + * @see AudioSink#supportsOutput(int, int) + */ + protected final boolean supportsOutput(int channelCount, @C.Encoding int encoding) { + return audioSink.supportsOutput(channelCount, encoding); + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + if (outputStreamEnded) { + try { + audioSink.playToEndOfStream(); + } catch (AudioSink.WriteException e) { + throw createRendererException(e, inputFormat); + } + return; + } + + // Try and read a format if we don't have one already. + if (inputFormat == null) { + // We don't have a format yet, so try and read one. + FormatHolder formatHolder = getFormatHolder(); + flagsOnlyBuffer.clear(); + int result = readSource(formatHolder, flagsOnlyBuffer, true); + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(formatHolder); + } else if (result == C.RESULT_BUFFER_READ) { + // End of stream read having not read a format. + Assertions.checkState(flagsOnlyBuffer.isEndOfStream()); + inputStreamEnded = true; + processEndOfStream(); + return; + } else { + // We still don't have a format and can't make progress without one. + return; + } + } + + // If we don't have a decoder yet, we need to instantiate one. + maybeInitDecoder(); + + if (decoder != null) { + try { + // Rendering loop. + TraceUtil.beginSection("drainAndFeed"); + while (drainOutputBuffer()) {} + while (feedInputBuffer()) {} + TraceUtil.endSection(); + } catch (AudioDecoderException | AudioSink.ConfigurationException + | AudioSink.InitializationException | AudioSink.WriteException e) { + throw createRendererException(e, inputFormat); + } + decoderCounters.ensureUpdated(); + } + } + + /** + * Called when the audio session id becomes known. The default implementation is a no-op. One + * reason for overriding this method would be to instantiate and enable a {@link Virtualizer} in + * order to spatialize the audio channels. For this use case, any {@link Virtualizer} instances + * should be released in {@link #onDisabled()} (if not before). + * + * @see AudioSink.Listener#onAudioSessionId(int) + */ + protected void onAudioSessionId(int audioSessionId) { + // Do nothing. + } + + /** + * @see AudioSink.Listener#onPositionDiscontinuity() + */ + protected void onAudioTrackPositionDiscontinuity() { + // Do nothing. + } + + /** + * @see AudioSink.Listener#onUnderrun(int, long, long) + */ + protected void onAudioTrackUnderrun(int bufferSize, long bufferSizeMs, + long elapsedSinceLastFeedMs) { + // Do nothing. + } + + /** + * Creates a decoder for the given format. + * + * @param format The format for which a decoder is required. + * @param mediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted content. + * Maybe null and can be ignored if decoder does not handle encrypted content. + * @return The decoder. + * @throws AudioDecoderException If an error occurred creating a suitable decoder. + */ + protected abstract SimpleDecoder< + DecoderInputBuffer, ? extends SimpleOutputBuffer, ? extends AudioDecoderException> + createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) + throws AudioDecoderException; + + /** + * Returns the format of audio buffers output by the decoder. Will not be called until the first + * output buffer has been dequeued, so the decoder may use input data to determine the format. + */ + protected abstract Format getOutputFormat(); + + /** + * Returns whether the existing decoder can be kept for a new format. + * + * @param oldFormat The previous format. + * @param newFormat The new format. + * @return True if the existing decoder can be kept. + */ + protected boolean canKeepCodec(Format oldFormat, Format newFormat) { + return false; + } + + private boolean drainOutputBuffer() throws ExoPlaybackException, AudioDecoderException, + AudioSink.ConfigurationException, AudioSink.InitializationException, + AudioSink.WriteException { + if (outputBuffer == null) { + outputBuffer = decoder.dequeueOutputBuffer(); + if (outputBuffer == null) { + return false; + } + if (outputBuffer.skippedOutputBufferCount > 0) { + decoderCounters.skippedOutputBufferCount += outputBuffer.skippedOutputBufferCount; + audioSink.handleDiscontinuity(); + } + } + + if (outputBuffer.isEndOfStream()) { + if (decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) { + // We're waiting to re-initialize the decoder, and have now processed all final buffers. + releaseDecoder(); + maybeInitDecoder(); + // The audio track may need to be recreated once the new output format is known. + audioTrackNeedsConfigure = true; + } else { + outputBuffer.release(); + outputBuffer = null; + processEndOfStream(); + } + return false; + } + + if (audioTrackNeedsConfigure) { + Format outputFormat = getOutputFormat(); + audioSink.configure(outputFormat.pcmEncoding, outputFormat.channelCount, + outputFormat.sampleRate, 0, null, encoderDelay, encoderPadding); + audioTrackNeedsConfigure = false; + } + + if (audioSink.handleBuffer(outputBuffer.data, outputBuffer.timeUs)) { + decoderCounters.renderedOutputBufferCount++; + outputBuffer.release(); + outputBuffer = null; + return true; + } + + return false; + } + + private boolean feedInputBuffer() throws AudioDecoderException, ExoPlaybackException { + if (decoder == null || decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM + || inputStreamEnded) { + // We need to reinitialize the decoder or the input stream has ended. + return false; + } + + if (inputBuffer == null) { + inputBuffer = decoder.dequeueInputBuffer(); + if (inputBuffer == null) { + return false; + } + } + + if (decoderReinitializationState == REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM) { + inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + decoderReinitializationState = REINITIALIZATION_STATE_WAIT_END_OF_STREAM; + return false; + } + + int result; + FormatHolder formatHolder = getFormatHolder(); + if (waitingForKeys) { + // We've already read an encrypted sample into buffer, and are waiting for keys. + result = C.RESULT_BUFFER_READ; + } else { + result = readSource(formatHolder, inputBuffer, false); + } + + if (result == C.RESULT_NOTHING_READ) { + return false; + } + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(formatHolder); + return true; + } + if (inputBuffer.isEndOfStream()) { + inputStreamEnded = true; + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + return false; + } + boolean bufferEncrypted = inputBuffer.isEncrypted(); + waitingForKeys = shouldWaitForKeys(bufferEncrypted); + if (waitingForKeys) { + return false; + } + inputBuffer.flip(); + onQueueInputBuffer(inputBuffer); + decoder.queueInputBuffer(inputBuffer); + decoderReceivedBuffers = true; + decoderCounters.inputBufferCount++; + inputBuffer = null; + return true; + } + + private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { + if (decoderDrmSession == null + || (!bufferEncrypted + && (playClearSamplesWithoutKeys || decoderDrmSession.playClearSamplesWithoutKeys()))) { + return false; + } + @DrmSession.State int drmSessionState = decoderDrmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw createRendererException(decoderDrmSession.getError(), inputFormat); + } + return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; + } + + private void processEndOfStream() throws ExoPlaybackException { + outputStreamEnded = true; + try { + audioSink.playToEndOfStream(); + } catch (AudioSink.WriteException e) { + // TODO(internal: b/145658993) Use outputFormat for the call from drainOutputBuffer. + throw createRendererException(e, inputFormat); + } + } + + private void flushDecoder() throws ExoPlaybackException { + waitingForKeys = false; + if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { + releaseDecoder(); + maybeInitDecoder(); + } else { + inputBuffer = null; + if (outputBuffer != null) { + outputBuffer.release(); + outputBuffer = null; + } + decoder.flush(); + decoderReceivedBuffers = false; + } + } + + @Override + public boolean isEnded() { + return outputStreamEnded && audioSink.isEnded(); + } + + @Override + public boolean isReady() { + return audioSink.hasPendingData() + || (inputFormat != null && !waitingForKeys && (isSourceReady() || outputBuffer != null)); + } + + @Override + public long getPositionUs() { + if (getState() == STATE_STARTED) { + updateCurrentPosition(); + } + return currentPositionUs; + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + audioSink.setPlaybackParameters(playbackParameters); + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return audioSink.getPlaybackParameters(); + } + + @Override + protected void onEnabled(boolean joining) throws ExoPlaybackException { + if (drmSessionManager != null && !drmResourcesAcquired) { + drmResourcesAcquired = true; + drmSessionManager.prepare(); + } + decoderCounters = new DecoderCounters(); + eventDispatcher.enabled(decoderCounters); + int tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; + if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { + audioSink.enableTunnelingV21(tunnelingAudioSessionId); + } else { + audioSink.disableTunneling(); + } + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + audioSink.flush(); + currentPositionUs = positionUs; + allowFirstBufferPositionDiscontinuity = true; + allowPositionDiscontinuity = true; + inputStreamEnded = false; + outputStreamEnded = false; + if (decoder != null) { + flushDecoder(); + } + } + + @Override + protected void onStarted() { + audioSink.play(); + } + + @Override + protected void onStopped() { + updateCurrentPosition(); + audioSink.pause(); + } + + @Override + protected void onDisabled() { + inputFormat = null; + audioTrackNeedsConfigure = true; + waitingForKeys = false; + try { + setSourceDrmSession(null); + releaseDecoder(); + audioSink.reset(); + } finally { + eventDispatcher.disabled(decoderCounters); + } + } + + @Override + protected void onReset() { + if (drmSessionManager != null && drmResourcesAcquired) { + drmResourcesAcquired = false; + drmSessionManager.release(); + } + } + + @Override + public void handleMessage(int messageType, @Nullable Object message) throws ExoPlaybackException { + switch (messageType) { + case C.MSG_SET_VOLUME: + audioSink.setVolume((Float) message); + break; + case C.MSG_SET_AUDIO_ATTRIBUTES: + AudioAttributes audioAttributes = (AudioAttributes) message; + audioSink.setAudioAttributes(audioAttributes); + break; + case C.MSG_SET_AUX_EFFECT_INFO: + AuxEffectInfo auxEffectInfo = (AuxEffectInfo) message; + audioSink.setAuxEffectInfo(auxEffectInfo); + break; + default: + super.handleMessage(messageType, message); + break; + } + } + + private void maybeInitDecoder() throws ExoPlaybackException { + if (decoder != null) { + return; + } + + setDecoderDrmSession(sourceDrmSession); + + ExoMediaCrypto mediaCrypto = null; + if (decoderDrmSession != null) { + mediaCrypto = decoderDrmSession.getMediaCrypto(); + if (mediaCrypto == null) { + DrmSessionException drmError = decoderDrmSession.getError(); + if (drmError != null) { + // Continue for now. We may be able to avoid failure if the session recovers, or if a new + // input format causes the session to be replaced before it's used. + } else { + // The drm session isn't open yet. + return; + } + } + } + + try { + long codecInitializingTimestamp = SystemClock.elapsedRealtime(); + TraceUtil.beginSection("createAudioDecoder"); + decoder = createDecoder(inputFormat, mediaCrypto); + TraceUtil.endSection(); + long codecInitializedTimestamp = SystemClock.elapsedRealtime(); + eventDispatcher.decoderInitialized(decoder.getName(), codecInitializedTimestamp, + codecInitializedTimestamp - codecInitializingTimestamp); + decoderCounters.decoderInitCount++; + } catch (AudioDecoderException e) { + throw createRendererException(e, inputFormat); + } + } + + private void releaseDecoder() { + inputBuffer = null; + outputBuffer = null; + decoderReinitializationState = REINITIALIZATION_STATE_NONE; + decoderReceivedBuffers = false; + if (decoder != null) { + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + } + setDecoderDrmSession(null); + } + + private void setSourceDrmSession(@Nullable DrmSession<ExoMediaCrypto> session) { + DrmSession.replaceSession(sourceDrmSession, session); + sourceDrmSession = session; + } + + private void setDecoderDrmSession(@Nullable DrmSession<ExoMediaCrypto> session) { + DrmSession.replaceSession(decoderDrmSession, session); + decoderDrmSession = session; + } + + @SuppressWarnings("unchecked") + private void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { + Format newFormat = Assertions.checkNotNull(formatHolder.format); + if (formatHolder.includesDrmSession) { + setSourceDrmSession((DrmSession<ExoMediaCrypto>) formatHolder.drmSession); + } else { + sourceDrmSession = + getUpdatedSourceDrmSession(inputFormat, newFormat, drmSessionManager, sourceDrmSession); + } + Format oldFormat = inputFormat; + inputFormat = newFormat; + + if (!canKeepCodec(oldFormat, inputFormat)) { + if (decoderReceivedBuffers) { + // Signal end of stream and wait for any final output buffers before re-initialization. + decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; + } else { + // There aren't any final output buffers, so release the decoder immediately. + releaseDecoder(); + maybeInitDecoder(); + audioTrackNeedsConfigure = true; + } + } + + encoderDelay = inputFormat.encoderDelay; + encoderPadding = inputFormat.encoderPadding; + + eventDispatcher.inputFormatChanged(inputFormat); + } + + private void onQueueInputBuffer(DecoderInputBuffer buffer) { + if (allowFirstBufferPositionDiscontinuity && !buffer.isDecodeOnly()) { + // TODO: Remove this hack once we have a proper fix for [Internal: b/71876314]. + // Allow the position to jump if the first presentable input buffer has a timestamp that + // differs significantly from what was expected. + if (Math.abs(buffer.timeUs - currentPositionUs) > 500000) { + currentPositionUs = buffer.timeUs; + } + allowFirstBufferPositionDiscontinuity = false; + } + } + + private void updateCurrentPosition() { + long newCurrentPositionUs = audioSink.getCurrentPositionUs(isEnded()); + if (newCurrentPositionUs != AudioSink.CURRENT_POSITION_NOT_SET) { + currentPositionUs = + allowPositionDiscontinuity + ? newCurrentPositionUs + : Math.max(currentPositionUs, newCurrentPositionUs); + allowPositionDiscontinuity = false; + } + } + + private final class AudioSinkListener implements AudioSink.Listener { + + @Override + public void onAudioSessionId(int audioSessionId) { + eventDispatcher.audioSessionId(audioSessionId); + SimpleDecoderAudioRenderer.this.onAudioSessionId(audioSessionId); + } + + @Override + public void onPositionDiscontinuity() { + onAudioTrackPositionDiscontinuity(); + // We are out of sync so allow currentPositionUs to jump backwards. + SimpleDecoderAudioRenderer.this.allowPositionDiscontinuity = true; + } + + @Override + public void onUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + eventDispatcher.audioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + onAudioTrackUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Sonic.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Sonic.java new file mode 100644 index 0000000000..1a0dad4b45 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/Sonic.java @@ -0,0 +1,506 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * Copyright (C) 2010 Bill Cox, Sonic Library + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.nio.ShortBuffer; +import java.util.Arrays; + +/** + * Sonic audio stream processor for time/pitch stretching. + * <p> + * Based on https://github.com/waywardgeek/sonic. + */ +/* package */ final class Sonic { + + private static final int MINIMUM_PITCH = 65; + private static final int MAXIMUM_PITCH = 400; + private static final int AMDF_FREQUENCY = 4000; + private static final int BYTES_PER_SAMPLE = 2; + + private final int inputSampleRateHz; + private final int channelCount; + private final float speed; + private final float pitch; + private final float rate; + private final int minPeriod; + private final int maxPeriod; + private final int maxRequiredFrameCount; + private final short[] downSampleBuffer; + + private short[] inputBuffer; + private int inputFrameCount; + private short[] outputBuffer; + private int outputFrameCount; + private short[] pitchBuffer; + private int pitchFrameCount; + private int oldRatePosition; + private int newRatePosition; + private int remainingInputToCopyFrameCount; + private int prevPeriod; + private int prevMinDiff; + private int minDiff; + private int maxDiff; + + /** + * Creates a new Sonic audio stream processor. + * + * @param inputSampleRateHz The sample rate of input audio, in hertz. + * @param channelCount The number of channels in the input audio. + * @param speed The speedup factor for output audio. + * @param pitch The pitch factor for output audio. + * @param outputSampleRateHz The sample rate for output audio, in hertz. + */ + public Sonic( + int inputSampleRateHz, int channelCount, float speed, float pitch, int outputSampleRateHz) { + this.inputSampleRateHz = inputSampleRateHz; + this.channelCount = channelCount; + this.speed = speed; + this.pitch = pitch; + rate = (float) inputSampleRateHz / outputSampleRateHz; + minPeriod = inputSampleRateHz / MAXIMUM_PITCH; + maxPeriod = inputSampleRateHz / MINIMUM_PITCH; + maxRequiredFrameCount = 2 * maxPeriod; + downSampleBuffer = new short[maxRequiredFrameCount]; + inputBuffer = new short[maxRequiredFrameCount * channelCount]; + outputBuffer = new short[maxRequiredFrameCount * channelCount]; + pitchBuffer = new short[maxRequiredFrameCount * channelCount]; + } + + /** + * Queues remaining data from {@code buffer}, and advances its position by the number of bytes + * consumed. + * + * @param buffer A {@link ShortBuffer} containing input data between its position and limit. + */ + public void queueInput(ShortBuffer buffer) { + int framesToWrite = buffer.remaining() / channelCount; + int bytesToWrite = framesToWrite * channelCount * 2; + inputBuffer = ensureSpaceForAdditionalFrames(inputBuffer, inputFrameCount, framesToWrite); + buffer.get(inputBuffer, inputFrameCount * channelCount, bytesToWrite / 2); + inputFrameCount += framesToWrite; + processStreamInput(); + } + + /** + * Gets available output, outputting to the start of {@code buffer}. The buffer's position will be + * advanced by the number of bytes written. + * + * @param buffer A {@link ShortBuffer} into which output will be written. + */ + public void getOutput(ShortBuffer buffer) { + int framesToRead = Math.min(buffer.remaining() / channelCount, outputFrameCount); + buffer.put(outputBuffer, 0, framesToRead * channelCount); + outputFrameCount -= framesToRead; + System.arraycopy( + outputBuffer, + framesToRead * channelCount, + outputBuffer, + 0, + outputFrameCount * channelCount); + } + + /** + * Forces generating output using whatever data has been queued already. No extra delay will be + * added to the output, but flushing in the middle of words could introduce distortion. + */ + public void queueEndOfStream() { + int remainingFrameCount = inputFrameCount; + float s = speed / pitch; + float r = rate * pitch; + int expectedOutputFrames = + outputFrameCount + (int) ((remainingFrameCount / s + pitchFrameCount) / r + 0.5f); + + // Add enough silence to flush both input and pitch buffers. + inputBuffer = + ensureSpaceForAdditionalFrames( + inputBuffer, inputFrameCount, remainingFrameCount + 2 * maxRequiredFrameCount); + for (int xSample = 0; xSample < 2 * maxRequiredFrameCount * channelCount; xSample++) { + inputBuffer[remainingFrameCount * channelCount + xSample] = 0; + } + inputFrameCount += 2 * maxRequiredFrameCount; + processStreamInput(); + // Throw away any extra frames we generated due to the silence we added. + if (outputFrameCount > expectedOutputFrames) { + outputFrameCount = expectedOutputFrames; + } + // Empty input and pitch buffers. + inputFrameCount = 0; + remainingInputToCopyFrameCount = 0; + pitchFrameCount = 0; + } + + /** Clears state in preparation for receiving a new stream of input buffers. */ + public void flush() { + inputFrameCount = 0; + outputFrameCount = 0; + pitchFrameCount = 0; + oldRatePosition = 0; + newRatePosition = 0; + remainingInputToCopyFrameCount = 0; + prevPeriod = 0; + prevMinDiff = 0; + minDiff = 0; + maxDiff = 0; + } + + /** Returns the size of output that can be read with {@link #getOutput(ShortBuffer)}, in bytes. */ + public int getOutputSize() { + return outputFrameCount * channelCount * BYTES_PER_SAMPLE; + } + + // Internal methods. + + /** + * Returns {@code buffer} or a copy of it, such that there is enough space in the returned buffer + * to store {@code newFrameCount} additional frames. + * + * @param buffer The buffer. + * @param frameCount The number of frames already in the buffer. + * @param additionalFrameCount The number of additional frames that need to be stored in the + * buffer. + * @return A buffer with enough space for the additional frames. + */ + private short[] ensureSpaceForAdditionalFrames( + short[] buffer, int frameCount, int additionalFrameCount) { + int currentCapacityFrames = buffer.length / channelCount; + if (frameCount + additionalFrameCount <= currentCapacityFrames) { + return buffer; + } else { + int newCapacityFrames = 3 * currentCapacityFrames / 2 + additionalFrameCount; + return Arrays.copyOf(buffer, newCapacityFrames * channelCount); + } + } + + private void removeProcessedInputFrames(int positionFrames) { + int remainingFrames = inputFrameCount - positionFrames; + System.arraycopy( + inputBuffer, positionFrames * channelCount, inputBuffer, 0, remainingFrames * channelCount); + inputFrameCount = remainingFrames; + } + + private void copyToOutput(short[] samples, int positionFrames, int frameCount) { + outputBuffer = ensureSpaceForAdditionalFrames(outputBuffer, outputFrameCount, frameCount); + System.arraycopy( + samples, + positionFrames * channelCount, + outputBuffer, + outputFrameCount * channelCount, + frameCount * channelCount); + outputFrameCount += frameCount; + } + + private int copyInputToOutput(int positionFrames) { + int frameCount = Math.min(maxRequiredFrameCount, remainingInputToCopyFrameCount); + copyToOutput(inputBuffer, positionFrames, frameCount); + remainingInputToCopyFrameCount -= frameCount; + return frameCount; + } + + private void downSampleInput(short[] samples, int position, int skip) { + // If skip is greater than one, average skip samples together and write them to the down-sample + // buffer. If channelCount is greater than one, mix the channels together as we down sample. + int frameCount = maxRequiredFrameCount / skip; + int samplesPerValue = channelCount * skip; + position *= channelCount; + for (int i = 0; i < frameCount; i++) { + int value = 0; + for (int j = 0; j < samplesPerValue; j++) { + value += samples[position + i * samplesPerValue + j]; + } + value /= samplesPerValue; + downSampleBuffer[i] = (short) value; + } + } + + private int findPitchPeriodInRange(short[] samples, int position, int minPeriod, int maxPeriod) { + // Find the best frequency match in the range, and given a sample skip multiple. For now, just + // find the pitch of the first channel. + int bestPeriod = 0; + int worstPeriod = 255; + int minDiff = 1; + int maxDiff = 0; + position *= channelCount; + for (int period = minPeriod; period <= maxPeriod; period++) { + int diff = 0; + for (int i = 0; i < period; i++) { + short sVal = samples[position + i]; + short pVal = samples[position + period + i]; + diff += Math.abs(sVal - pVal); + } + // Note that the highest number of samples we add into diff will be less than 256, since we + // skip samples. Thus, diff is a 24 bit number, and we can safely multiply by numSamples + // without overflow. + if (diff * bestPeriod < minDiff * period) { + minDiff = diff; + bestPeriod = period; + } + if (diff * worstPeriod > maxDiff * period) { + maxDiff = diff; + worstPeriod = period; + } + } + this.minDiff = minDiff / bestPeriod; + this.maxDiff = maxDiff / worstPeriod; + return bestPeriod; + } + + /** + * Returns whether the previous pitch period estimate is a better approximation, which can occur + * at the abrupt end of voiced words. + */ + private boolean previousPeriodBetter(int minDiff, int maxDiff) { + if (minDiff == 0 || prevPeriod == 0) { + return false; + } + if (maxDiff > minDiff * 3) { + // Got a reasonable match this period. + return false; + } + if (minDiff * 2 <= prevMinDiff * 3) { + // Mismatch is not that much greater this period. + return false; + } + return true; + } + + private int findPitchPeriod(short[] samples, int position) { + // Find the pitch period. This is a critical step, and we may have to try multiple ways to get a + // good answer. This version uses AMDF. To improve speed, we down sample by an integer factor + // get in the 11 kHz range, and then do it again with a narrower frequency range without down + // sampling. + int period; + int retPeriod; + int skip = inputSampleRateHz > AMDF_FREQUENCY ? inputSampleRateHz / AMDF_FREQUENCY : 1; + if (channelCount == 1 && skip == 1) { + period = findPitchPeriodInRange(samples, position, minPeriod, maxPeriod); + } else { + downSampleInput(samples, position, skip); + period = findPitchPeriodInRange(downSampleBuffer, 0, minPeriod / skip, maxPeriod / skip); + if (skip != 1) { + period *= skip; + int minP = period - (skip * 4); + int maxP = period + (skip * 4); + if (minP < minPeriod) { + minP = minPeriod; + } + if (maxP > maxPeriod) { + maxP = maxPeriod; + } + if (channelCount == 1) { + period = findPitchPeriodInRange(samples, position, minP, maxP); + } else { + downSampleInput(samples, position, 1); + period = findPitchPeriodInRange(downSampleBuffer, 0, minP, maxP); + } + } + } + if (previousPeriodBetter(minDiff, maxDiff)) { + retPeriod = prevPeriod; + } else { + retPeriod = period; + } + prevMinDiff = minDiff; + prevPeriod = period; + return retPeriod; + } + + private void moveNewSamplesToPitchBuffer(int originalOutputFrameCount) { + int frameCount = outputFrameCount - originalOutputFrameCount; + pitchBuffer = ensureSpaceForAdditionalFrames(pitchBuffer, pitchFrameCount, frameCount); + System.arraycopy( + outputBuffer, + originalOutputFrameCount * channelCount, + pitchBuffer, + pitchFrameCount * channelCount, + frameCount * channelCount); + outputFrameCount = originalOutputFrameCount; + pitchFrameCount += frameCount; + } + + private void removePitchFrames(int frameCount) { + if (frameCount == 0) { + return; + } + System.arraycopy( + pitchBuffer, + frameCount * channelCount, + pitchBuffer, + 0, + (pitchFrameCount - frameCount) * channelCount); + pitchFrameCount -= frameCount; + } + + private short interpolate(short[] in, int inPos, int oldSampleRate, int newSampleRate) { + short left = in[inPos]; + short right = in[inPos + channelCount]; + int position = newRatePosition * oldSampleRate; + int leftPosition = oldRatePosition * newSampleRate; + int rightPosition = (oldRatePosition + 1) * newSampleRate; + int ratio = rightPosition - position; + int width = rightPosition - leftPosition; + return (short) ((ratio * left + (width - ratio) * right) / width); + } + + private void adjustRate(float rate, int originalOutputFrameCount) { + if (outputFrameCount == originalOutputFrameCount) { + return; + } + int newSampleRate = (int) (inputSampleRateHz / rate); + int oldSampleRate = inputSampleRateHz; + // Set these values to help with the integer math. + while (newSampleRate > (1 << 14) || oldSampleRate > (1 << 14)) { + newSampleRate /= 2; + oldSampleRate /= 2; + } + moveNewSamplesToPitchBuffer(originalOutputFrameCount); + // Leave at least one pitch sample in the buffer. + for (int position = 0; position < pitchFrameCount - 1; position++) { + while ((oldRatePosition + 1) * newSampleRate > newRatePosition * oldSampleRate) { + outputBuffer = + ensureSpaceForAdditionalFrames( + outputBuffer, outputFrameCount, /* additionalFrameCount= */ 1); + for (int i = 0; i < channelCount; i++) { + outputBuffer[outputFrameCount * channelCount + i] = + interpolate(pitchBuffer, position * channelCount + i, oldSampleRate, newSampleRate); + } + newRatePosition++; + outputFrameCount++; + } + oldRatePosition++; + if (oldRatePosition == oldSampleRate) { + oldRatePosition = 0; + Assertions.checkState(newRatePosition == newSampleRate); + newRatePosition = 0; + } + } + removePitchFrames(pitchFrameCount - 1); + } + + private int skipPitchPeriod(short[] samples, int position, float speed, int period) { + // Skip over a pitch period, and copy period/speed samples to the output. + int newFrameCount; + if (speed >= 2.0f) { + newFrameCount = (int) (period / (speed - 1.0f)); + } else { + newFrameCount = period; + remainingInputToCopyFrameCount = (int) (period * (2.0f - speed) / (speed - 1.0f)); + } + outputBuffer = ensureSpaceForAdditionalFrames(outputBuffer, outputFrameCount, newFrameCount); + overlapAdd( + newFrameCount, + channelCount, + outputBuffer, + outputFrameCount, + samples, + position, + samples, + position + period); + outputFrameCount += newFrameCount; + return newFrameCount; + } + + private int insertPitchPeriod(short[] samples, int position, float speed, int period) { + // Insert a pitch period, and determine how much input to copy directly. + int newFrameCount; + if (speed < 0.5f) { + newFrameCount = (int) (period * speed / (1.0f - speed)); + } else { + newFrameCount = period; + remainingInputToCopyFrameCount = (int) (period * (2.0f * speed - 1.0f) / (1.0f - speed)); + } + outputBuffer = + ensureSpaceForAdditionalFrames(outputBuffer, outputFrameCount, period + newFrameCount); + System.arraycopy( + samples, + position * channelCount, + outputBuffer, + outputFrameCount * channelCount, + period * channelCount); + overlapAdd( + newFrameCount, + channelCount, + outputBuffer, + outputFrameCount + period, + samples, + position + period, + samples, + position); + outputFrameCount += period + newFrameCount; + return newFrameCount; + } + + private void changeSpeed(float speed) { + if (inputFrameCount < maxRequiredFrameCount) { + return; + } + int frameCount = inputFrameCount; + int positionFrames = 0; + do { + if (remainingInputToCopyFrameCount > 0) { + positionFrames += copyInputToOutput(positionFrames); + } else { + int period = findPitchPeriod(inputBuffer, positionFrames); + if (speed > 1.0) { + positionFrames += period + skipPitchPeriod(inputBuffer, positionFrames, speed, period); + } else { + positionFrames += insertPitchPeriod(inputBuffer, positionFrames, speed, period); + } + } + } while (positionFrames + maxRequiredFrameCount <= frameCount); + removeProcessedInputFrames(positionFrames); + } + + private void processStreamInput() { + // Resample as many pitch periods as we have buffered on the input. + int originalOutputFrameCount = outputFrameCount; + float s = speed / pitch; + float r = rate * pitch; + if (s > 1.00001 || s < 0.99999) { + changeSpeed(s); + } else { + copyToOutput(inputBuffer, 0, inputFrameCount); + inputFrameCount = 0; + } + if (r != 1.0f) { + adjustRate(r, originalOutputFrameCount); + } + } + + private static void overlapAdd( + int frameCount, + int channelCount, + short[] out, + int outPosition, + short[] rampDown, + int rampDownPosition, + short[] rampUp, + int rampUpPosition) { + for (int i = 0; i < channelCount; i++) { + int o = outPosition * channelCount + i; + int u = rampUpPosition * channelCount + i; + int d = rampDownPosition * channelCount + i; + for (int t = 0; t < frameCount; t++) { + out[o] = (short) ((rampDown[d] * (frameCount - t) + rampUp[u] * t) / frameCount); + o += channelCount; + d += channelCount; + u += channelCount; + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SonicAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SonicAudioProcessor.java new file mode 100644 index 0000000000..88a4d884bf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/SonicAudioProcessor.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.ShortBuffer; + +/** + * An {@link AudioProcessor} that uses the Sonic library to modify audio speed/pitch/sample rate. + */ +public final class SonicAudioProcessor implements AudioProcessor { + + /** + * The maximum allowed playback speed in {@link #setSpeed(float)}. + */ + public static final float MAXIMUM_SPEED = 8.0f; + /** + * The minimum allowed playback speed in {@link #setSpeed(float)}. + */ + public static final float MINIMUM_SPEED = 0.1f; + /** + * The maximum allowed pitch in {@link #setPitch(float)}. + */ + public static final float MAXIMUM_PITCH = 8.0f; + /** + * The minimum allowed pitch in {@link #setPitch(float)}. + */ + public static final float MINIMUM_PITCH = 0.1f; + /** + * Indicates that the output sample rate should be the same as the input. + */ + public static final int SAMPLE_RATE_NO_CHANGE = -1; + + /** + * The threshold below which the difference between two pitch/speed factors is negligible. + */ + private static final float CLOSE_THRESHOLD = 0.01f; + + /** + * The minimum number of output bytes at which the speedup is calculated using the input/output + * byte counts, rather than using the current playback parameters speed. + */ + private static final int MIN_BYTES_FOR_SPEEDUP_CALCULATION = 1024; + + private int pendingOutputSampleRate; + private float speed; + private float pitch; + + private AudioFormat pendingInputAudioFormat; + private AudioFormat pendingOutputAudioFormat; + private AudioFormat inputAudioFormat; + private AudioFormat outputAudioFormat; + + private boolean pendingSonicRecreation; + @Nullable private Sonic sonic; + private ByteBuffer buffer; + private ShortBuffer shortBuffer; + private ByteBuffer outputBuffer; + private long inputBytes; + private long outputBytes; + private boolean inputEnded; + + /** + * Creates a new Sonic audio processor. + */ + public SonicAudioProcessor() { + speed = 1f; + pitch = 1f; + pendingInputAudioFormat = AudioFormat.NOT_SET; + pendingOutputAudioFormat = AudioFormat.NOT_SET; + inputAudioFormat = AudioFormat.NOT_SET; + outputAudioFormat = AudioFormat.NOT_SET; + buffer = EMPTY_BUFFER; + shortBuffer = buffer.asShortBuffer(); + outputBuffer = EMPTY_BUFFER; + pendingOutputSampleRate = SAMPLE_RATE_NO_CHANGE; + } + + /** + * Sets the playback speed. This method may only be called after draining data through the + * processor. The value returned by {@link #isActive()} may change, and the processor must be + * {@link #flush() flushed} before queueing more data. + * + * @param speed The requested new playback speed. + * @return The actual new playback speed. + */ + public float setSpeed(float speed) { + speed = Util.constrainValue(speed, MINIMUM_SPEED, MAXIMUM_SPEED); + if (this.speed != speed) { + this.speed = speed; + pendingSonicRecreation = true; + } + return speed; + } + + /** + * Sets the playback pitch. This method may only be called after draining data through the + * processor. The value returned by {@link #isActive()} may change, and the processor must be + * {@link #flush() flushed} before queueing more data. + * + * @param pitch The requested new pitch. + * @return The actual new pitch. + */ + public float setPitch(float pitch) { + pitch = Util.constrainValue(pitch, MINIMUM_PITCH, MAXIMUM_PITCH); + if (this.pitch != pitch) { + this.pitch = pitch; + pendingSonicRecreation = true; + } + return pitch; + } + + /** + * Sets the sample rate for output audio, in Hertz. Pass {@link #SAMPLE_RATE_NO_CHANGE} to output + * audio at the same sample rate as the input. After calling this method, call {@link + * #configure(AudioFormat)} to configure the processor with the new sample rate. + * + * @param sampleRateHz The sample rate for output audio, in Hertz. + * @see #configure(AudioFormat) + */ + public void setOutputSampleRateHz(int sampleRateHz) { + pendingOutputSampleRate = sampleRateHz; + } + + /** + * Returns the specified duration scaled to take into account the speedup factor of this instance, + * in the same units as {@code duration}. + * + * @param duration The duration to scale taking into account speedup. + * @return The specified duration scaled to take into account speedup, in the same units as + * {@code duration}. + */ + public long scaleDurationForSpeedup(long duration) { + if (outputBytes >= MIN_BYTES_FOR_SPEEDUP_CALCULATION) { + return outputAudioFormat.sampleRate == inputAudioFormat.sampleRate + ? Util.scaleLargeTimestamp(duration, inputBytes, outputBytes) + : Util.scaleLargeTimestamp( + duration, + inputBytes * outputAudioFormat.sampleRate, + outputBytes * inputAudioFormat.sampleRate); + } else { + return (long) ((double) speed * duration); + } + } + + @Override + public AudioFormat configure(AudioFormat inputAudioFormat) throws UnhandledAudioFormatException { + if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + int outputSampleRateHz = + pendingOutputSampleRate == SAMPLE_RATE_NO_CHANGE + ? inputAudioFormat.sampleRate + : pendingOutputSampleRate; + pendingInputAudioFormat = inputAudioFormat; + pendingOutputAudioFormat = + new AudioFormat(outputSampleRateHz, inputAudioFormat.channelCount, C.ENCODING_PCM_16BIT); + pendingSonicRecreation = true; + return pendingOutputAudioFormat; + } + + @Override + public boolean isActive() { + return pendingOutputAudioFormat.sampleRate != Format.NO_VALUE + && (Math.abs(speed - 1f) >= CLOSE_THRESHOLD + || Math.abs(pitch - 1f) >= CLOSE_THRESHOLD + || pendingOutputAudioFormat.sampleRate != pendingInputAudioFormat.sampleRate); + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + Sonic sonic = Assertions.checkNotNull(this.sonic); + if (inputBuffer.hasRemaining()) { + ShortBuffer shortBuffer = inputBuffer.asShortBuffer(); + int inputSize = inputBuffer.remaining(); + inputBytes += inputSize; + sonic.queueInput(shortBuffer); + inputBuffer.position(inputBuffer.position() + inputSize); + } + int outputSize = sonic.getOutputSize(); + if (outputSize > 0) { + if (buffer.capacity() < outputSize) { + buffer = ByteBuffer.allocateDirect(outputSize).order(ByteOrder.nativeOrder()); + shortBuffer = buffer.asShortBuffer(); + } else { + buffer.clear(); + shortBuffer.clear(); + } + sonic.getOutput(shortBuffer); + outputBytes += outputSize; + buffer.limit(outputSize); + outputBuffer = buffer; + } + } + + @Override + public void queueEndOfStream() { + if (sonic != null) { + sonic.queueEndOfStream(); + } + inputEnded = true; + } + + @Override + public ByteBuffer getOutput() { + ByteBuffer outputBuffer = this.outputBuffer; + this.outputBuffer = EMPTY_BUFFER; + return outputBuffer; + } + + @Override + public boolean isEnded() { + return inputEnded && (sonic == null || sonic.getOutputSize() == 0); + } + + @Override + public void flush() { + if (isActive()) { + inputAudioFormat = pendingInputAudioFormat; + outputAudioFormat = pendingOutputAudioFormat; + if (pendingSonicRecreation) { + sonic = + new Sonic( + inputAudioFormat.sampleRate, + inputAudioFormat.channelCount, + speed, + pitch, + outputAudioFormat.sampleRate); + } else if (sonic != null) { + sonic.flush(); + } + } + outputBuffer = EMPTY_BUFFER; + inputBytes = 0; + outputBytes = 0; + inputEnded = false; + } + + @Override + public void reset() { + speed = 1f; + pitch = 1f; + pendingInputAudioFormat = AudioFormat.NOT_SET; + pendingOutputAudioFormat = AudioFormat.NOT_SET; + inputAudioFormat = AudioFormat.NOT_SET; + outputAudioFormat = AudioFormat.NOT_SET; + buffer = EMPTY_BUFFER; + shortBuffer = buffer.asShortBuffer(); + outputBuffer = EMPTY_BUFFER; + pendingOutputSampleRate = SAMPLE_RATE_NO_CHANGE; + pendingSonicRecreation = false; + sonic = null; + inputBytes = 0; + outputBytes = 0; + inputEnded = false; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/TeeAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/TeeAudioProcessor.java new file mode 100644 index 0000000000..42f151c5be --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/TeeAudioProcessor.java @@ -0,0 +1,235 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Audio processor that outputs its input unmodified and also outputs its input to a given sink. + * This is intended to be used for diagnostics and debugging. + * + * <p>This audio processor can be inserted into the audio processor chain to access audio data + * before/after particular processing steps have been applied. For example, to get audio output + * after playback speed adjustment and silence skipping have been applied it is necessary to pass a + * custom {@link org.mozilla.thirdparty.com.google.android.exoplayer2audio.DefaultAudioSink.AudioProcessorChain} when + * creating the audio sink, and include this audio processor after all other audio processors. + */ +public final class TeeAudioProcessor extends BaseAudioProcessor { + + /** A sink for audio buffers handled by the audio processor. */ + public interface AudioBufferSink { + + /** Called when the audio processor is flushed with a format of subsequent input. */ + void flush(int sampleRateHz, int channelCount, @C.PcmEncoding int encoding); + + /** + * Called when data is written to the audio processor. + * + * @param buffer A read-only buffer containing input which the audio processor will handle. + */ + void handleBuffer(ByteBuffer buffer); + } + + private final AudioBufferSink audioBufferSink; + + /** + * Creates a new tee audio processor, sending incoming data to the given {@link AudioBufferSink}. + * + * @param audioBufferSink The audio buffer sink that will receive input queued to this audio + * processor. + */ + public TeeAudioProcessor(AudioBufferSink audioBufferSink) { + this.audioBufferSink = Assertions.checkNotNull(audioBufferSink); + } + + @Override + public AudioFormat onConfigure(AudioFormat inputAudioFormat) { + // This processor is always active (if passed to the sink) and outputs its input. + return inputAudioFormat; + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + int remaining = inputBuffer.remaining(); + if (remaining == 0) { + return; + } + audioBufferSink.handleBuffer(inputBuffer.asReadOnlyBuffer()); + replaceOutputBuffer(remaining).put(inputBuffer).flip(); + } + + @Override + protected void onQueueEndOfStream() { + flushSinkIfActive(); + } + + @Override + protected void onReset() { + flushSinkIfActive(); + } + + private void flushSinkIfActive() { + if (isActive()) { + audioBufferSink.flush( + inputAudioFormat.sampleRate, inputAudioFormat.channelCount, inputAudioFormat.encoding); + } + } + + /** + * A sink for audio buffers that writes output audio as .wav files with a given path prefix. When + * new audio data is handled after flushing the audio processor, a counter is incremented and its + * value is appended to the output file name. + * + * <p>Note: if writing to external storage it's necessary to grant the {@code + * WRITE_EXTERNAL_STORAGE} permission. + */ + public static final class WavFileAudioBufferSink implements AudioBufferSink { + + private static final String TAG = "WaveFileAudioBufferSink"; + + private static final int FILE_SIZE_MINUS_8_OFFSET = 4; + private static final int FILE_SIZE_MINUS_44_OFFSET = 40; + private static final int HEADER_LENGTH = 44; + + private final String outputFileNamePrefix; + private final byte[] scratchBuffer; + private final ByteBuffer scratchByteBuffer; + + private int sampleRateHz; + private int channelCount; + @C.PcmEncoding private int encoding; + @Nullable private RandomAccessFile randomAccessFile; + private int counter; + private int bytesWritten; + + /** + * Creates a new audio buffer sink that writes to .wav files with the given prefix. + * + * @param outputFileNamePrefix The prefix for output files. + */ + public WavFileAudioBufferSink(String outputFileNamePrefix) { + this.outputFileNamePrefix = outputFileNamePrefix; + scratchBuffer = new byte[1024]; + scratchByteBuffer = ByteBuffer.wrap(scratchBuffer).order(ByteOrder.LITTLE_ENDIAN); + } + + @Override + public void flush(int sampleRateHz, int channelCount, @C.PcmEncoding int encoding) { + try { + reset(); + } catch (IOException e) { + Log.e(TAG, "Error resetting", e); + } + this.sampleRateHz = sampleRateHz; + this.channelCount = channelCount; + this.encoding = encoding; + } + + @Override + public void handleBuffer(ByteBuffer buffer) { + try { + maybePrepareFile(); + writeBuffer(buffer); + } catch (IOException e) { + Log.e(TAG, "Error writing data", e); + } + } + + private void maybePrepareFile() throws IOException { + if (randomAccessFile != null) { + return; + } + RandomAccessFile randomAccessFile = new RandomAccessFile(getNextOutputFileName(), "rw"); + writeFileHeader(randomAccessFile); + this.randomAccessFile = randomAccessFile; + bytesWritten = HEADER_LENGTH; + } + + private void writeFileHeader(RandomAccessFile randomAccessFile) throws IOException { + // Write the start of the header as big endian data. + randomAccessFile.writeInt(WavUtil.RIFF_FOURCC); + randomAccessFile.writeInt(-1); + randomAccessFile.writeInt(WavUtil.WAVE_FOURCC); + randomAccessFile.writeInt(WavUtil.FMT_FOURCC); + + // Write the rest of the header as little endian data. + scratchByteBuffer.clear(); + scratchByteBuffer.putInt(16); + scratchByteBuffer.putShort((short) WavUtil.getTypeForPcmEncoding(encoding)); + scratchByteBuffer.putShort((short) channelCount); + scratchByteBuffer.putInt(sampleRateHz); + int bytesPerSample = Util.getPcmFrameSize(encoding, channelCount); + scratchByteBuffer.putInt(bytesPerSample * sampleRateHz); + scratchByteBuffer.putShort((short) bytesPerSample); + scratchByteBuffer.putShort((short) (8 * bytesPerSample / channelCount)); + randomAccessFile.write(scratchBuffer, 0, scratchByteBuffer.position()); + + // Write the start of the data chunk as big endian data. + randomAccessFile.writeInt(WavUtil.DATA_FOURCC); + randomAccessFile.writeInt(-1); + } + + private void writeBuffer(ByteBuffer buffer) throws IOException { + RandomAccessFile randomAccessFile = Assertions.checkNotNull(this.randomAccessFile); + while (buffer.hasRemaining()) { + int bytesToWrite = Math.min(buffer.remaining(), scratchBuffer.length); + buffer.get(scratchBuffer, 0, bytesToWrite); + randomAccessFile.write(scratchBuffer, 0, bytesToWrite); + bytesWritten += bytesToWrite; + } + } + + private void reset() throws IOException { + RandomAccessFile randomAccessFile = this.randomAccessFile; + if (randomAccessFile == null) { + return; + } + + try { + scratchByteBuffer.clear(); + scratchByteBuffer.putInt(bytesWritten - 8); + randomAccessFile.seek(FILE_SIZE_MINUS_8_OFFSET); + randomAccessFile.write(scratchBuffer, 0, 4); + + scratchByteBuffer.clear(); + scratchByteBuffer.putInt(bytesWritten - 44); + randomAccessFile.seek(FILE_SIZE_MINUS_44_OFFSET); + randomAccessFile.write(scratchBuffer, 0, 4); + } catch (IOException e) { + // The file may still be playable, so just log a warning. + Log.w(TAG, "Error updating file size", e); + } + + try { + randomAccessFile.close(); + } finally { + this.randomAccessFile = null; + } + } + + private String getNextOutputFileName() { + return Util.formatInvariant("%s-%04d.wav", outputFileNamePrefix, counter++); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java new file mode 100644 index 0000000000..1326cf63ee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/TrimmingAudioProcessor.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; + +/** Audio processor for trimming samples from the start/end of data. */ +/* package */ final class TrimmingAudioProcessor extends BaseAudioProcessor { + + @C.PcmEncoding private static final int OUTPUT_ENCODING = C.ENCODING_PCM_16BIT; + + private int trimStartFrames; + private int trimEndFrames; + private boolean reconfigurationPending; + + private int pendingTrimStartBytes; + private byte[] endBuffer; + private int endBufferSize; + private long trimmedFrameCount; + + /** Creates a new audio processor for trimming samples from the start/end of data. */ + public TrimmingAudioProcessor() { + endBuffer = Util.EMPTY_BYTE_ARRAY; + } + + /** + * Sets the number of audio frames to trim from the start and end of audio passed to this + * processor. After calling this method, call {@link #configure(AudioFormat)} to apply the new + * trimming frame counts. + * + * @param trimStartFrames The number of audio frames to trim from the start of audio. + * @param trimEndFrames The number of audio frames to trim from the end of audio. + * @see AudioSink#configure(int, int, int, int, int[], int, int) + */ + public void setTrimFrameCount(int trimStartFrames, int trimEndFrames) { + this.trimStartFrames = trimStartFrames; + this.trimEndFrames = trimEndFrames; + } + + /** Sets the trimmed frame count returned by {@link #getTrimmedFrameCount()} to zero. */ + public void resetTrimmedFrameCount() { + trimmedFrameCount = 0; + } + + /** + * Returns the number of audio frames trimmed since the last call to {@link + * #resetTrimmedFrameCount()}. + */ + public long getTrimmedFrameCount() { + return trimmedFrameCount; + } + + @Override + public AudioFormat onConfigure(AudioFormat inputAudioFormat) + throws UnhandledAudioFormatException { + if (inputAudioFormat.encoding != OUTPUT_ENCODING) { + throw new UnhandledAudioFormatException(inputAudioFormat); + } + reconfigurationPending = true; + return trimStartFrames != 0 || trimEndFrames != 0 ? inputAudioFormat : AudioFormat.NOT_SET; + } + + @Override + public void queueInput(ByteBuffer inputBuffer) { + int position = inputBuffer.position(); + int limit = inputBuffer.limit(); + int remaining = limit - position; + + if (remaining == 0) { + return; + } + + // Trim any pending start bytes from the input buffer. + int trimBytes = Math.min(remaining, pendingTrimStartBytes); + trimmedFrameCount += trimBytes / inputAudioFormat.bytesPerFrame; + pendingTrimStartBytes -= trimBytes; + inputBuffer.position(position + trimBytes); + if (pendingTrimStartBytes > 0) { + // Nothing to output yet. + return; + } + remaining -= trimBytes; + + // endBuffer must be kept as full as possible, so that we trim the right amount of media if we + // don't receive any more input. After taking into account the number of bytes needed to keep + // endBuffer as full as possible, the output should be any surplus bytes currently in endBuffer + // followed by any surplus bytes in the new inputBuffer. + int remainingBytesToOutput = endBufferSize + remaining - endBuffer.length; + ByteBuffer buffer = replaceOutputBuffer(remainingBytesToOutput); + + // Output from endBuffer. + int endBufferBytesToOutput = Util.constrainValue(remainingBytesToOutput, 0, endBufferSize); + buffer.put(endBuffer, 0, endBufferBytesToOutput); + remainingBytesToOutput -= endBufferBytesToOutput; + + // Output from inputBuffer, restoring its limit afterwards. + int inputBufferBytesToOutput = Util.constrainValue(remainingBytesToOutput, 0, remaining); + inputBuffer.limit(inputBuffer.position() + inputBufferBytesToOutput); + buffer.put(inputBuffer); + inputBuffer.limit(limit); + remaining -= inputBufferBytesToOutput; + + // Compact endBuffer, then repopulate it using the new input. + endBufferSize -= endBufferBytesToOutput; + System.arraycopy(endBuffer, endBufferBytesToOutput, endBuffer, 0, endBufferSize); + inputBuffer.get(endBuffer, endBufferSize, remaining); + endBufferSize += remaining; + + buffer.flip(); + } + + @Override + public ByteBuffer getOutput() { + if (super.isEnded() && endBufferSize > 0) { + // Because audio processors may be drained in the middle of the stream we assume that the + // contents of the end buffer need to be output. For gapless transitions, configure will + // always be called, so the end buffer is cleared in onQueueEndOfStream. + replaceOutputBuffer(endBufferSize).put(endBuffer, 0, endBufferSize).flip(); + endBufferSize = 0; + } + return super.getOutput(); + } + + @Override + public boolean isEnded() { + return super.isEnded() && endBufferSize == 0; + } + + @Override + protected void onQueueEndOfStream() { + if (reconfigurationPending) { + // Trim audio in the end buffer. + if (endBufferSize > 0) { + trimmedFrameCount += endBufferSize / inputAudioFormat.bytesPerFrame; + } + endBufferSize = 0; + } + } + + @Override + protected void onFlush() { + if (reconfigurationPending) { + // This is the initial flush after reconfiguration. Prepare to trim bytes from the start/end. + reconfigurationPending = false; + endBuffer = new byte[trimEndFrames * inputAudioFormat.bytesPerFrame]; + pendingTrimStartBytes = trimStartFrames * inputAudioFormat.bytesPerFrame; + } else { + // This is a flush during playback (after the initial flush). We assume this was caused by a + // seek to a non-zero position and clear pending start bytes. This assumption may be wrong (we + // may be seeking to zero), but playing data that should have been trimmed shouldn't be + // noticeable after a seek. Ideally we would check the timestamp of the first input buffer + // queued after flushing to decide whether to trim (see also [Internal: b/77292509]). + pendingTrimStartBytes = 0; + } + endBufferSize = 0; + } + + @Override + protected void onReset() { + endBuffer = Util.EMPTY_BYTE_ARRAY; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/WavUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/WavUtil.java new file mode 100644 index 0000000000..d1245761aa --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/WavUtil.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** Utilities for handling WAVE files. */ +public final class WavUtil { + + /** Four character code for "RIFF". */ + public static final int RIFF_FOURCC = 0x52494646; + /** Four character code for "WAVE". */ + public static final int WAVE_FOURCC = 0x57415645; + /** Four character code for "fmt ". */ + public static final int FMT_FOURCC = 0x666d7420; + /** Four character code for "data". */ + public static final int DATA_FOURCC = 0x64617461; + + /** WAVE type value for integer PCM audio data. */ + public static final int TYPE_PCM = 0x0001; + /** WAVE type value for float PCM audio data. */ + public static final int TYPE_FLOAT = 0x0003; + /** WAVE type value for 8-bit ITU-T G.711 A-law audio data. */ + public static final int TYPE_ALAW = 0x0006; + /** WAVE type value for 8-bit ITU-T G.711 mu-law audio data. */ + public static final int TYPE_MLAW = 0x0007; + /** WAVE type value for IMA ADPCM audio data. */ + public static final int TYPE_IMA_ADPCM = 0x0011; + /** WAVE type value for extended WAVE format. */ + public static final int TYPE_WAVE_FORMAT_EXTENSIBLE = 0xFFFE; + + /** + * Returns the WAVE format type value for the given {@link C.PcmEncoding}. + * + * @param pcmEncoding The {@link C.PcmEncoding} value. + * @return The corresponding WAVE format type. + * @throws IllegalArgumentException If {@code pcmEncoding} is not a {@link C.PcmEncoding}, or if + * it's {@link C#ENCODING_INVALID} or {@link Format#NO_VALUE}. + */ + public static int getTypeForPcmEncoding(@C.PcmEncoding int pcmEncoding) { + switch (pcmEncoding) { + case C.ENCODING_PCM_8BIT: + case C.ENCODING_PCM_16BIT: + case C.ENCODING_PCM_24BIT: + case C.ENCODING_PCM_32BIT: + return TYPE_PCM; + case C.ENCODING_PCM_FLOAT: + return TYPE_FLOAT; + case C.ENCODING_PCM_16BIT_BIG_ENDIAN: // Not TYPE_PCM, because TYPE_PCM is little endian. + case C.ENCODING_INVALID: + case Format.NO_VALUE: + default: + throw new IllegalArgumentException(); + } + } + + /** + * Returns the {@link C.PcmEncoding} for the given WAVE format type value, or {@link + * C#ENCODING_INVALID} if the type is not a known PCM type. + */ + public static @C.PcmEncoding int getPcmEncodingForType(int type, int bitsPerSample) { + switch (type) { + case TYPE_PCM: + case TYPE_WAVE_FORMAT_EXTENSIBLE: + return Util.getPcmEncoding(bitsPerSample); + case TYPE_FLOAT: + return bitsPerSample == 32 ? C.ENCODING_PCM_FLOAT : C.ENCODING_INVALID; + default: + return C.ENCODING_INVALID; + } + } + + private WavUtil() { + // Prevent instantiation. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/package-info.java new file mode 100644 index 0000000000..95c29d7333 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/audio/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.audio; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DatabaseIOException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DatabaseIOException.java new file mode 100644 index 0000000000..4c03addf22 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DatabaseIOException.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.database; + +import android.database.SQLException; +import java.io.IOException; + +/** An {@link IOException} whose cause is an {@link SQLException}. */ +public final class DatabaseIOException extends IOException { + + public DatabaseIOException(SQLException cause) { + super(cause); + } + + public DatabaseIOException(SQLException cause, String message) { + super(message, cause); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DatabaseProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DatabaseProvider.java new file mode 100644 index 0000000000..81deccaf93 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DatabaseProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.database; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; + +/** + * Provides {@link SQLiteDatabase} instances to ExoPlayer components, which may read and write + * tables prefixed with {@link #TABLE_PREFIX}. + */ +public interface DatabaseProvider { + + /** Prefix for tables that can be read and written by ExoPlayer components. */ + String TABLE_PREFIX = "ExoPlayer"; + + /** + * Creates and/or opens a database that will be used for reading and writing. + * + * <p>Once opened successfully, the database is cached, so you can call this method every time you + * need to write to the database. Errors such as bad permissions or a full disk may cause this + * method to fail, but future attempts may succeed if the problem is fixed. + * + * @throws SQLiteException If the database cannot be opened for writing. + * @return A read/write database object. + */ + SQLiteDatabase getWritableDatabase(); + + /** + * Creates and/or opens a database. This will be the same object returned by {@link + * #getWritableDatabase()} unless some problem, such as a full disk, requires the database to be + * opened read-only. In that case, a read-only database object will be returned. If the problem is + * fixed, a future call to {@link #getWritableDatabase()} may succeed, in which case the read-only + * database object will be closed and the read/write object will be returned in the future. + * + * <p>Once opened successfully, the database is cached, so you can call this method every time you + * need to read from the database. + * + * @throws SQLiteException If the database cannot be opened. + * @return A database object valid until {@link #getWritableDatabase()} is called. + */ + SQLiteDatabase getReadableDatabase(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java new file mode 100644 index 0000000000..8da3de15c8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/DefaultDatabaseProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.database; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +/** A {@link DatabaseProvider} that provides instances obtained from a {@link SQLiteOpenHelper}. */ +public final class DefaultDatabaseProvider implements DatabaseProvider { + + private final SQLiteOpenHelper sqliteOpenHelper; + + /** + * @param sqliteOpenHelper An {@link SQLiteOpenHelper} from which to obtain database instances. + */ + public DefaultDatabaseProvider(SQLiteOpenHelper sqliteOpenHelper) { + this.sqliteOpenHelper = sqliteOpenHelper; + } + + @Override + public SQLiteDatabase getWritableDatabase() { + return sqliteOpenHelper.getWritableDatabase(); + } + + @Override + public SQLiteDatabase getReadableDatabase() { + return sqliteOpenHelper.getReadableDatabase(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/ExoDatabaseProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/ExoDatabaseProvider.java new file mode 100644 index 0000000000..037442b102 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/ExoDatabaseProvider.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.database; + +import android.content.Context; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; + +/** + * An {@link SQLiteOpenHelper} that provides instances of a standalone ExoPlayer database. + * + * <p>Suitable for use by applications that do not already have their own database, or that would + * prefer to keep ExoPlayer tables isolated in their own database. Other applications should prefer + * to use {@link DefaultDatabaseProvider} with their own {@link SQLiteOpenHelper}. + */ +public final class ExoDatabaseProvider extends SQLiteOpenHelper implements DatabaseProvider { + + /** The file name used for the standalone ExoPlayer database. */ + public static final String DATABASE_NAME = "exoplayer_internal.db"; + + private static final int VERSION = 1; + private static final String TAG = "ExoDatabaseProvider"; + + /** + * Provides instances of the database located by passing {@link #DATABASE_NAME} to {@link + * Context#getDatabasePath(String)}. + * + * @param context Any context. + */ + public ExoDatabaseProvider(Context context) { + super(context.getApplicationContext(), DATABASE_NAME, /* factory= */ null, VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + // Features create their own tables. + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + // Features handle their own upgrades. + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + wipeDatabase(db); + } + + /** + * Makes a best effort to wipe the existing database. The wipe may be incomplete if the database + * contains foreign key constraints. + */ + private static void wipeDatabase(SQLiteDatabase db) { + String[] columns = {"type", "name"}; + try (Cursor cursor = + db.query( + "sqlite_master", + columns, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null)) { + while (cursor.moveToNext()) { + String type = cursor.getString(0); + String name = cursor.getString(1); + if (!"sqlite_sequence".equals(name)) { + // If it's not an SQL-controlled entity, drop it + String sql = "DROP " + type + " IF EXISTS " + name; + try { + db.execSQL(sql); + } catch (SQLException e) { + Log.e(TAG, "Error executing " + sql, e); + } + } + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/VersionTable.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/VersionTable.java new file mode 100644 index 0000000000..d3174e67b2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/VersionTable.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.database; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import androidx.annotation.IntDef; +import androidx.annotation.VisibleForTesting; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Utility methods for accessing versions of ExoPlayer database components. This allows them to be + * versioned independently to the version of the containing database. + */ +public final class VersionTable { + + /** Returned by {@link #getVersion(SQLiteDatabase, int, String)} if the version is unset. */ + public static final int VERSION_UNSET = -1; + /** Version of tables used for offline functionality. */ + public static final int FEATURE_OFFLINE = 0; + /** Version of tables used for cache content metadata. */ + public static final int FEATURE_CACHE_CONTENT_METADATA = 1; + /** Version of tables used for cache file metadata. */ + public static final int FEATURE_CACHE_FILE_METADATA = 2; + + private static final String TABLE_NAME = DatabaseProvider.TABLE_PREFIX + "Versions"; + + private static final String COLUMN_FEATURE = "feature"; + private static final String COLUMN_INSTANCE_UID = "instance_uid"; + private static final String COLUMN_VERSION = "version"; + + private static final String WHERE_FEATURE_AND_INSTANCE_UID_EQUALS = + COLUMN_FEATURE + " = ? AND " + COLUMN_INSTANCE_UID + " = ?"; + + private static final String PRIMARY_KEY = + "PRIMARY KEY (" + COLUMN_FEATURE + ", " + COLUMN_INSTANCE_UID + ")"; + private static final String SQL_CREATE_TABLE_IF_NOT_EXISTS = + "CREATE TABLE IF NOT EXISTS " + + TABLE_NAME + + " (" + + COLUMN_FEATURE + + " INTEGER NOT NULL," + + COLUMN_INSTANCE_UID + + " TEXT NOT NULL," + + COLUMN_VERSION + + " INTEGER NOT NULL," + + PRIMARY_KEY + + ")"; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({FEATURE_OFFLINE, FEATURE_CACHE_CONTENT_METADATA, FEATURE_CACHE_FILE_METADATA}) + private @interface Feature {} + + private VersionTable() {} + + /** + * Sets the version of a specified instance of a specified feature. + * + * @param writableDatabase The database to update. + * @param feature The feature. + * @param instanceUid The unique identifier of the instance of the feature. + * @param version The version. + * @throws DatabaseIOException If an error occurs executing the SQL. + */ + public static void setVersion( + SQLiteDatabase writableDatabase, @Feature int feature, String instanceUid, int version) + throws DatabaseIOException { + try { + writableDatabase.execSQL(SQL_CREATE_TABLE_IF_NOT_EXISTS); + ContentValues values = new ContentValues(); + values.put(COLUMN_FEATURE, feature); + values.put(COLUMN_INSTANCE_UID, instanceUid); + values.put(COLUMN_VERSION, version); + writableDatabase.replaceOrThrow(TABLE_NAME, /* nullColumnHack= */ null, values); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** + * Removes the version of a specified instance of a feature. + * + * @param writableDatabase The database to update. + * @param feature The feature. + * @param instanceUid The unique identifier of the instance of the feature. + * @throws DatabaseIOException If an error occurs executing the SQL. + */ + public static void removeVersion( + SQLiteDatabase writableDatabase, @Feature int feature, String instanceUid) + throws DatabaseIOException { + try { + if (!tableExists(writableDatabase, TABLE_NAME)) { + return; + } + writableDatabase.delete( + TABLE_NAME, + WHERE_FEATURE_AND_INSTANCE_UID_EQUALS, + featureAndInstanceUidArguments(feature, instanceUid)); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** + * Returns the version of a specified instance of a feature, or {@link #VERSION_UNSET} if no + * version is set. + * + * @param database The database to query. + * @param feature The feature. + * @param instanceUid The unique identifier of the instance of the feature. + * @return The version, or {@link #VERSION_UNSET} if no version is set. + * @throws DatabaseIOException If an error occurs executing the SQL. + */ + public static int getVersion(SQLiteDatabase database, @Feature int feature, String instanceUid) + throws DatabaseIOException { + try { + if (!tableExists(database, TABLE_NAME)) { + return VERSION_UNSET; + } + try (Cursor cursor = + database.query( + TABLE_NAME, + new String[] {COLUMN_VERSION}, + WHERE_FEATURE_AND_INSTANCE_UID_EQUALS, + featureAndInstanceUidArguments(feature, instanceUid), + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null)) { + if (cursor.getCount() == 0) { + return VERSION_UNSET; + } + cursor.moveToNext(); + return cursor.getInt(/* COLUMN_VERSION index */ 0); + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + @VisibleForTesting + /* package */ static boolean tableExists(SQLiteDatabase readableDatabase, String tableName) { + long count = + DatabaseUtils.queryNumEntries( + readableDatabase, "sqlite_master", "tbl_name = ?", new String[] {tableName}); + return count > 0; + } + + private static String[] featureAndInstanceUidArguments(int feature, String instance) { + return new String[] {Integer.toString(feature), instance}; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/package-info.java new file mode 100644 index 0000000000..85e0dfa5e3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/database/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.database; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/Buffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/Buffer.java new file mode 100644 index 0000000000..ac254fae96 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/Buffer.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** + * Base class for buffers with flags. + */ +public abstract class Buffer { + + @C.BufferFlags + private int flags; + + /** + * Clears the buffer. + */ + public void clear() { + flags = 0; + } + + /** + * Returns whether the {@link C#BUFFER_FLAG_DECODE_ONLY} flag is set. + */ + public final boolean isDecodeOnly() { + return getFlag(C.BUFFER_FLAG_DECODE_ONLY); + } + + /** + * Returns whether the {@link C#BUFFER_FLAG_END_OF_STREAM} flag is set. + */ + public final boolean isEndOfStream() { + return getFlag(C.BUFFER_FLAG_END_OF_STREAM); + } + + /** + * Returns whether the {@link C#BUFFER_FLAG_KEY_FRAME} flag is set. + */ + public final boolean isKeyFrame() { + return getFlag(C.BUFFER_FLAG_KEY_FRAME); + } + + /** Returns whether the {@link C#BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA} flag is set. */ + public final boolean hasSupplementalData() { + return getFlag(C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA); + } + + /** + * Replaces this buffer's flags with {@code flags}. + * + * @param flags The flags to set, which should be a combination of the {@code C.BUFFER_FLAG_*} + * constants. + */ + public final void setFlags(@C.BufferFlags int flags) { + this.flags = flags; + } + + /** + * Adds the {@code flag} to this buffer's flags. + * + * @param flag The flag to add to this buffer's flags, which should be one of the + * {@code C.BUFFER_FLAG_*} constants. + */ + public final void addFlag(@C.BufferFlags int flag) { + flags |= flag; + } + + /** + * Removes the {@code flag} from this buffer's flags, if it is set. + * + * @param flag The flag to remove. + */ + public final void clearFlag(@C.BufferFlags int flag) { + flags &= ~flag; + } + + /** + * Returns whether the specified flag has been set on this buffer. + * + * @param flag The flag to check. + * @return Whether the flag is set. + */ + protected final boolean getFlag(@C.BufferFlags int flag) { + return (flags & flag) == flag; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/CryptoInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/CryptoInfo.java new file mode 100644 index 0000000000..1bfb0fb06e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/CryptoInfo.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import android.annotation.TargetApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Compatibility wrapper for {@link android.media.MediaCodec.CryptoInfo}. + */ +public final class CryptoInfo { + + /** + * The 16 byte initialization vector. If the initialization vector of the content is shorter than + * 16 bytes, 0 byte padding is appended to extend the vector to the required 16 byte length. + * + * @see android.media.MediaCodec.CryptoInfo#iv + */ + public byte[] iv; + /** + * The 16 byte key id. + * + * @see android.media.MediaCodec.CryptoInfo#key + */ + public byte[] key; + /** + * The type of encryption that has been applied. Must be one of the {@link C.CryptoMode} values. + * + * @see android.media.MediaCodec.CryptoInfo#mode + */ + @C.CryptoMode public int mode; + /** + * The number of leading unencrypted bytes in each sub-sample. If null, all bytes are treated as + * encrypted and {@link #numBytesOfEncryptedData} must be specified. + * + * @see android.media.MediaCodec.CryptoInfo#numBytesOfClearData + */ + public int[] numBytesOfClearData; + /** + * The number of trailing encrypted bytes in each sub-sample. If null, all bytes are treated as + * clear and {@link #numBytesOfClearData} must be specified. + * + * @see android.media.MediaCodec.CryptoInfo#numBytesOfEncryptedData + */ + public int[] numBytesOfEncryptedData; + /** + * The number of subSamples that make up the buffer's contents. + * + * @see android.media.MediaCodec.CryptoInfo#numSubSamples + */ + public int numSubSamples; + /** + * @see android.media.MediaCodec.CryptoInfo.Pattern + */ + public int encryptedBlocks; + /** + * @see android.media.MediaCodec.CryptoInfo.Pattern + */ + public int clearBlocks; + + private final android.media.MediaCodec.CryptoInfo frameworkCryptoInfo; + private final PatternHolderV24 patternHolder; + + public CryptoInfo() { + frameworkCryptoInfo = new android.media.MediaCodec.CryptoInfo(); + patternHolder = Util.SDK_INT >= 24 ? new PatternHolderV24(frameworkCryptoInfo) : null; + } + + /** + * @see android.media.MediaCodec.CryptoInfo#set(int, int[], int[], byte[], byte[], int) + */ + public void set(int numSubSamples, int[] numBytesOfClearData, int[] numBytesOfEncryptedData, + byte[] key, byte[] iv, @C.CryptoMode int mode, int encryptedBlocks, int clearBlocks) { + this.numSubSamples = numSubSamples; + this.numBytesOfClearData = numBytesOfClearData; + this.numBytesOfEncryptedData = numBytesOfEncryptedData; + this.key = key; + this.iv = iv; + this.mode = mode; + this.encryptedBlocks = encryptedBlocks; + this.clearBlocks = clearBlocks; + // Update frameworkCryptoInfo fields directly because CryptoInfo.set performs an unnecessary + // object allocation on Android N. + frameworkCryptoInfo.numSubSamples = numSubSamples; + frameworkCryptoInfo.numBytesOfClearData = numBytesOfClearData; + frameworkCryptoInfo.numBytesOfEncryptedData = numBytesOfEncryptedData; + frameworkCryptoInfo.key = key; + frameworkCryptoInfo.iv = iv; + frameworkCryptoInfo.mode = mode; + if (Util.SDK_INT >= 24) { + patternHolder.set(encryptedBlocks, clearBlocks); + } + } + + /** + * Returns an equivalent {@link android.media.MediaCodec.CryptoInfo} instance. + * + * <p>Successive calls to this method on a single {@link CryptoInfo} will return the same + * instance. Changes to the {@link CryptoInfo} will be reflected in the returned object. The + * return object should not be modified directly. + * + * @return The equivalent {@link android.media.MediaCodec.CryptoInfo} instance. + */ + public android.media.MediaCodec.CryptoInfo getFrameworkCryptoInfo() { + return frameworkCryptoInfo; + } + + /** @deprecated Use {@link #getFrameworkCryptoInfo()}. */ + @Deprecated + public android.media.MediaCodec.CryptoInfo getFrameworkCryptoInfoV16() { + return getFrameworkCryptoInfo(); + } + + @TargetApi(24) + private static final class PatternHolderV24 { + + private final android.media.MediaCodec.CryptoInfo frameworkCryptoInfo; + private final android.media.MediaCodec.CryptoInfo.Pattern pattern; + + private PatternHolderV24(android.media.MediaCodec.CryptoInfo frameworkCryptoInfo) { + this.frameworkCryptoInfo = frameworkCryptoInfo; + pattern = new android.media.MediaCodec.CryptoInfo.Pattern(0, 0); + } + + private void set(int encryptedBlocks, int clearBlocks) { + pattern.set(encryptedBlocks, clearBlocks); + frameworkCryptoInfo.setPattern(pattern); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/Decoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/Decoder.java new file mode 100644 index 0000000000..8040c04ebe --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/Decoder.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import androidx.annotation.Nullable; + +/** + * A media decoder. + * + * @param <I> The type of buffer input to the decoder. + * @param <O> The type of buffer output from the decoder. + * @param <E> The type of exception thrown from the decoder. + */ +public interface Decoder<I, O, E extends Exception> { + + /** + * Returns the name of the decoder. + * + * @return The name of the decoder. + */ + String getName(); + + /** + * Dequeues the next input buffer to be filled and queued to the decoder. + * + * @return The input buffer, which will have been cleared, or null if a buffer isn't available. + * @throws E If a decoder error has occurred. + */ + @Nullable + I dequeueInputBuffer() throws E; + + /** + * Queues an input buffer to the decoder. + * + * @param inputBuffer The input buffer. + * @throws E If a decoder error has occurred. + */ + void queueInputBuffer(I inputBuffer) throws E; + + /** + * Dequeues the next output buffer from the decoder. + * + * @return The output buffer, or null if an output buffer isn't available. + * @throws E If a decoder error has occurred. + */ + @Nullable + O dequeueOutputBuffer() throws E; + + /** + * Flushes the decoder. Ownership of dequeued input buffers is returned to the decoder. The caller + * is still responsible for releasing any dequeued output buffers. + */ + void flush(); + + /** + * Releases the decoder. Must be called when the decoder is no longer needed. + */ + void release(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/DecoderCounters.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/DecoderCounters.java new file mode 100644 index 0000000000..f8bdb9b29a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/DecoderCounters.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +/** + * Maintains decoder event counts, for debugging purposes only. + * <p> + * Counters should be written from the playback thread only. Counters may be read from any thread. + * To ensure that the counter values are made visible across threads, users of this class should + * invoke {@link #ensureUpdated()} prior to reading and after writing. + */ +public final class DecoderCounters { + + /** + * The number of times a decoder has been initialized. + */ + public int decoderInitCount; + /** + * The number of times a decoder has been released. + */ + public int decoderReleaseCount; + /** + * The number of queued input buffers. + */ + public int inputBufferCount; + /** + * The number of skipped input buffers. + * <p> + * A skipped input buffer is an input buffer that was deliberately not sent to the decoder. + */ + public int skippedInputBufferCount; + /** + * The number of rendered output buffers. + */ + public int renderedOutputBufferCount; + /** + * The number of skipped output buffers. + * <p> + * A skipped output buffer is an output buffer that was deliberately not rendered. + */ + public int skippedOutputBufferCount; + /** + * The number of dropped buffers. + * <p> + * A dropped buffer is an buffer that was supposed to be decoded/rendered, but was instead + * dropped because it could not be rendered in time. + */ + public int droppedBufferCount; + /** + * The maximum number of dropped buffers without an interleaving rendered output buffer. + * <p> + * Skipped output buffers are ignored for the purposes of calculating this value. + */ + public int maxConsecutiveDroppedBufferCount; + /** + * The number of times all buffers to a keyframe were dropped. + * <p> + * Each time buffers to a keyframe are dropped, this counter is increased by one, and the dropped + * buffer counters are increased by one (for the current output buffer) plus the number of buffers + * dropped from the source to advance to the keyframe. + */ + public int droppedToKeyframeCount; + + /** + * Should be called to ensure counter values are made visible across threads. The playback thread + * should call this method after updating the counter values. Any other thread should call this + * method before reading the counters. + */ + public synchronized void ensureUpdated() { + // Do nothing. The use of synchronized ensures a memory barrier should another thread also + // call this method. + } + + /** + * Merges the counts from {@code other} into this instance. + * + * @param other The {@link DecoderCounters} to merge into this instance. + */ + public void merge(DecoderCounters other) { + decoderInitCount += other.decoderInitCount; + decoderReleaseCount += other.decoderReleaseCount; + inputBufferCount += other.inputBufferCount; + skippedInputBufferCount += other.skippedInputBufferCount; + renderedOutputBufferCount += other.renderedOutputBufferCount; + skippedOutputBufferCount += other.skippedOutputBufferCount; + droppedBufferCount += other.droppedBufferCount; + maxConsecutiveDroppedBufferCount = Math.max(maxConsecutiveDroppedBufferCount, + other.maxConsecutiveDroppedBufferCount); + droppedToKeyframeCount += other.droppedToKeyframeCount; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java new file mode 100644 index 0000000000..254ecfdec8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/DecoderInputBuffer.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; + +/** + * Holds input for a decoder. + */ +public class DecoderInputBuffer extends Buffer { + + /** + * The buffer replacement mode, which may disable replacement. One of {@link + * #BUFFER_REPLACEMENT_MODE_DISABLED}, {@link #BUFFER_REPLACEMENT_MODE_NORMAL} or {@link + * #BUFFER_REPLACEMENT_MODE_DIRECT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + BUFFER_REPLACEMENT_MODE_DISABLED, + BUFFER_REPLACEMENT_MODE_NORMAL, + BUFFER_REPLACEMENT_MODE_DIRECT + }) + public @interface BufferReplacementMode {} + /** + * Disallows buffer replacement. + */ + public static final int BUFFER_REPLACEMENT_MODE_DISABLED = 0; + /** + * Allows buffer replacement using {@link ByteBuffer#allocate(int)}. + */ + public static final int BUFFER_REPLACEMENT_MODE_NORMAL = 1; + /** + * Allows buffer replacement using {@link ByteBuffer#allocateDirect(int)}. + */ + public static final int BUFFER_REPLACEMENT_MODE_DIRECT = 2; + + /** + * {@link CryptoInfo} for encrypted data. + */ + public final CryptoInfo cryptoInfo; + + /** The buffer's data, or {@code null} if no data has been set. */ + @Nullable public ByteBuffer data; + + // TODO: Remove this temporary signaling once end-of-stream propagation for clips using content + // protection is fixed. See [Internal: b/153326944] for details. + /** + * Whether the last attempt to read a sample into this buffer failed due to not yet having the DRM + * keys associated with the next sample. + */ + public boolean waitingForKeys; + + /** + * The time at which the sample should be presented. + */ + public long timeUs; + + /** + * Supplemental data related to the buffer, if {@link #hasSupplementalData()} returns true. If + * present, the buffer is populated with supplemental data from position 0 to its limit. + */ + @Nullable public ByteBuffer supplementalData; + + @BufferReplacementMode private final int bufferReplacementMode; + + /** + * Creates a new instance for which {@link #isFlagsOnly()} will return true. + * + * @return A new flags only input buffer. + */ + public static DecoderInputBuffer newFlagsOnlyInstance() { + return new DecoderInputBuffer(BUFFER_REPLACEMENT_MODE_DISABLED); + } + + /** + * @param bufferReplacementMode Determines the behavior of {@link #ensureSpaceForWrite(int)}. One + * of {@link #BUFFER_REPLACEMENT_MODE_DISABLED}, {@link #BUFFER_REPLACEMENT_MODE_NORMAL} and + * {@link #BUFFER_REPLACEMENT_MODE_DIRECT}. + */ + public DecoderInputBuffer(@BufferReplacementMode int bufferReplacementMode) { + this.cryptoInfo = new CryptoInfo(); + this.bufferReplacementMode = bufferReplacementMode; + } + + /** + * Clears {@link #supplementalData} and ensures that it's large enough to accommodate {@code + * length} bytes. + * + * @param length The length of the supplemental data that must be accommodated, in bytes. + */ + @EnsuresNonNull("supplementalData") + public void resetSupplementalData(int length) { + if (supplementalData == null || supplementalData.capacity() < length) { + supplementalData = ByteBuffer.allocate(length); + } else { + supplementalData.clear(); + } + } + + /** + * Ensures that {@link #data} is large enough to accommodate a write of a given length at its + * current position. + * + * <p>If the capacity of {@link #data} is sufficient this method does nothing. If the capacity is + * insufficient then an attempt is made to replace {@link #data} with a new {@link ByteBuffer} + * whose capacity is sufficient. Data up to the current position is copied to the new buffer. + * + * @param length The length of the write that must be accommodated, in bytes. + * @throws IllegalStateException If there is insufficient capacity to accommodate the write and + * the buffer replacement mode of the holder is {@link #BUFFER_REPLACEMENT_MODE_DISABLED}. + */ + @EnsuresNonNull("data") + public void ensureSpaceForWrite(int length) { + if (data == null) { + data = createReplacementByteBuffer(length); + return; + } + // Check whether the current buffer is sufficient. + int capacity = data.capacity(); + int position = data.position(); + int requiredCapacity = position + length; + if (capacity >= requiredCapacity) { + return; + } + // Instantiate a new buffer if possible. + ByteBuffer newData = createReplacementByteBuffer(requiredCapacity); + newData.order(data.order()); + // Copy data up to the current position from the old buffer to the new one. + if (position > 0) { + data.flip(); + newData.put(data); + } + // Set the new buffer. + data = newData; + } + + /** + * Returns whether the buffer is only able to hold flags, meaning {@link #data} is null and + * its replacement mode is {@link #BUFFER_REPLACEMENT_MODE_DISABLED}. + */ + public final boolean isFlagsOnly() { + return data == null && bufferReplacementMode == BUFFER_REPLACEMENT_MODE_DISABLED; + } + + /** + * Returns whether the {@link C#BUFFER_FLAG_ENCRYPTED} flag is set. + */ + public final boolean isEncrypted() { + return getFlag(C.BUFFER_FLAG_ENCRYPTED); + } + + /** + * Flips {@link #data} and {@link #supplementalData} in preparation for being queued to a decoder. + * + * @see java.nio.Buffer#flip() + */ + public final void flip() { + data.flip(); + if (supplementalData != null) { + supplementalData.flip(); + } + } + + @Override + public void clear() { + super.clear(); + if (data != null) { + data.clear(); + } + if (supplementalData != null) { + supplementalData.clear(); + } + waitingForKeys = false; + } + + private ByteBuffer createReplacementByteBuffer(int requiredCapacity) { + if (bufferReplacementMode == BUFFER_REPLACEMENT_MODE_NORMAL) { + return ByteBuffer.allocate(requiredCapacity); + } else if (bufferReplacementMode == BUFFER_REPLACEMENT_MODE_DIRECT) { + return ByteBuffer.allocateDirect(requiredCapacity); + } else { + int currentCapacity = data == null ? 0 : data.capacity(); + throw new IllegalStateException("Buffer too small (" + currentCapacity + " < " + + requiredCapacity + ")"); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/OutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/OutputBuffer.java new file mode 100644 index 0000000000..73a8a7d2fd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/OutputBuffer.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +/** + * Output buffer decoded by a {@link Decoder}. + */ +public abstract class OutputBuffer extends Buffer { + + /** + * The presentation timestamp for the buffer, in microseconds. + */ + public long timeUs; + + /** + * The number of buffers immediately prior to this one that were skipped in the {@link Decoder}. + */ + public int skippedOutputBufferCount; + + /** + * Releases the output buffer for reuse. Must be called when the buffer is no longer needed. + */ + public abstract void release(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/SimpleDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/SimpleDecoder.java new file mode 100644 index 0000000000..a193ad3c8e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/SimpleDecoder.java @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayDeque; + +/** Base class for {@link Decoder}s that use their own decode thread. */ +@SuppressWarnings("UngroupedOverloads") +public abstract class SimpleDecoder< + I extends DecoderInputBuffer, O extends OutputBuffer, E extends Exception> + implements Decoder<I, O, E> { + + private final Thread decodeThread; + + private final Object lock; + private final ArrayDeque<I> queuedInputBuffers; + private final ArrayDeque<O> queuedOutputBuffers; + private final I[] availableInputBuffers; + private final O[] availableOutputBuffers; + + private int availableInputBufferCount; + private int availableOutputBufferCount; + private I dequeuedInputBuffer; + + private E exception; + private boolean flushed; + private boolean released; + private int skippedOutputBufferCount; + + /** + * @param inputBuffers An array of nulls that will be used to store references to input buffers. + * @param outputBuffers An array of nulls that will be used to store references to output buffers. + */ + protected SimpleDecoder(I[] inputBuffers, O[] outputBuffers) { + lock = new Object(); + queuedInputBuffers = new ArrayDeque<>(); + queuedOutputBuffers = new ArrayDeque<>(); + availableInputBuffers = inputBuffers; + availableInputBufferCount = inputBuffers.length; + for (int i = 0; i < availableInputBufferCount; i++) { + availableInputBuffers[i] = createInputBuffer(); + } + availableOutputBuffers = outputBuffers; + availableOutputBufferCount = outputBuffers.length; + for (int i = 0; i < availableOutputBufferCount; i++) { + availableOutputBuffers[i] = createOutputBuffer(); + } + decodeThread = new Thread() { + @Override + public void run() { + SimpleDecoder.this.run(); + } + }; + decodeThread.start(); + } + + /** + * Sets the initial size of each input buffer. + * <p> + * This method should only be called before the decoder is used (i.e. before the first call to + * {@link #dequeueInputBuffer()}. + * + * @param size The required input buffer size. + */ + protected final void setInitialInputBufferSize(int size) { + Assertions.checkState(availableInputBufferCount == availableInputBuffers.length); + for (I inputBuffer : availableInputBuffers) { + inputBuffer.ensureSpaceForWrite(size); + } + } + + @Override + @Nullable + public final I dequeueInputBuffer() throws E { + synchronized (lock) { + maybeThrowException(); + Assertions.checkState(dequeuedInputBuffer == null); + dequeuedInputBuffer = availableInputBufferCount == 0 ? null + : availableInputBuffers[--availableInputBufferCount]; + return dequeuedInputBuffer; + } + } + + @Override + public final void queueInputBuffer(I inputBuffer) throws E { + synchronized (lock) { + maybeThrowException(); + Assertions.checkArgument(inputBuffer == dequeuedInputBuffer); + queuedInputBuffers.addLast(inputBuffer); + maybeNotifyDecodeLoop(); + dequeuedInputBuffer = null; + } + } + + @Override + @Nullable + public final O dequeueOutputBuffer() throws E { + synchronized (lock) { + maybeThrowException(); + if (queuedOutputBuffers.isEmpty()) { + return null; + } + return queuedOutputBuffers.removeFirst(); + } + } + + /** + * Releases an output buffer back to the decoder. + * + * @param outputBuffer The output buffer being released. + */ + @CallSuper + protected void releaseOutputBuffer(O outputBuffer) { + synchronized (lock) { + releaseOutputBufferInternal(outputBuffer); + maybeNotifyDecodeLoop(); + } + } + + @Override + public final void flush() { + synchronized (lock) { + flushed = true; + skippedOutputBufferCount = 0; + if (dequeuedInputBuffer != null) { + releaseInputBufferInternal(dequeuedInputBuffer); + dequeuedInputBuffer = null; + } + while (!queuedInputBuffers.isEmpty()) { + releaseInputBufferInternal(queuedInputBuffers.removeFirst()); + } + while (!queuedOutputBuffers.isEmpty()) { + queuedOutputBuffers.removeFirst().release(); + } + exception = null; + } + } + + @CallSuper + @Override + public void release() { + synchronized (lock) { + released = true; + lock.notify(); + } + try { + decodeThread.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Throws a decode exception, if there is one. + * + * @throws E The decode exception. + */ + private void maybeThrowException() throws E { + if (exception != null) { + throw exception; + } + } + + /** + * Notifies the decode loop if there exists a queued input buffer and an available output buffer + * to decode into. + * <p> + * Should only be called whilst synchronized on the lock object. + */ + private void maybeNotifyDecodeLoop() { + if (canDecodeBuffer()) { + lock.notify(); + } + } + + private void run() { + try { + while (decode()) { + // Do nothing. + } + } catch (InterruptedException e) { + // Not expected. + throw new IllegalStateException(e); + } + } + + private boolean decode() throws InterruptedException { + I inputBuffer; + O outputBuffer; + boolean resetDecoder; + + // Wait until we have an input buffer to decode, and an output buffer to decode into. + synchronized (lock) { + while (!released && !canDecodeBuffer()) { + lock.wait(); + } + if (released) { + return false; + } + inputBuffer = queuedInputBuffers.removeFirst(); + outputBuffer = availableOutputBuffers[--availableOutputBufferCount]; + resetDecoder = flushed; + flushed = false; + } + + if (inputBuffer.isEndOfStream()) { + outputBuffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + } else { + if (inputBuffer.isDecodeOnly()) { + outputBuffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); + } + @Nullable E exception; + try { + exception = decode(inputBuffer, outputBuffer, resetDecoder); + } catch (RuntimeException e) { + // This can occur if a sample is malformed in a way that the decoder is not robust against. + // We don't want the process to die in this case, but we do want to propagate the error. + exception = createUnexpectedDecodeException(e); + } catch (OutOfMemoryError e) { + // This can occur if a sample is malformed in a way that causes the decoder to think it + // needs to allocate a large amount of memory. We don't want the process to die in this + // case, but we do want to propagate the error. + exception = createUnexpectedDecodeException(e); + } + if (exception != null) { + synchronized (lock) { + this.exception = exception; + } + return false; + } + } + + synchronized (lock) { + if (flushed) { + outputBuffer.release(); + } else if (outputBuffer.isDecodeOnly()) { + skippedOutputBufferCount++; + outputBuffer.release(); + } else { + outputBuffer.skippedOutputBufferCount = skippedOutputBufferCount; + skippedOutputBufferCount = 0; + queuedOutputBuffers.addLast(outputBuffer); + } + // Make the input buffer available again. + releaseInputBufferInternal(inputBuffer); + } + + return true; + } + + private boolean canDecodeBuffer() { + return !queuedInputBuffers.isEmpty() && availableOutputBufferCount > 0; + } + + private void releaseInputBufferInternal(I inputBuffer) { + inputBuffer.clear(); + availableInputBuffers[availableInputBufferCount++] = inputBuffer; + } + + private void releaseOutputBufferInternal(O outputBuffer) { + outputBuffer.clear(); + availableOutputBuffers[availableOutputBufferCount++] = outputBuffer; + } + + /** + * Creates a new input buffer. + */ + protected abstract I createInputBuffer(); + + /** + * Creates a new output buffer. + */ + protected abstract O createOutputBuffer(); + + /** + * Creates an exception to propagate for an unexpected decode error. + * + * @param error The unexpected decode error. + * @return The exception to propagate. + */ + protected abstract E createUnexpectedDecodeException(Throwable error); + + /** + * Decodes the {@code inputBuffer} and stores any decoded output in {@code outputBuffer}. + * + * @param inputBuffer The buffer to decode. + * @param outputBuffer The output buffer to store decoded data. The flag {@link + * C#BUFFER_FLAG_DECODE_ONLY} will be set if the same flag is set on {@code inputBuffer}, but + * may be set/unset as required. If the flag is set when the call returns then the output + * buffer will not be made available to dequeue. The output buffer may not have been populated + * in this case. + * @param reset Whether the decoder must be reset before decoding. + * @return A decoder exception if an error occurred, or null if decoding was successful. + */ + @Nullable + protected abstract E decode(I inputBuffer, O outputBuffer, boolean reset); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/SimpleOutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/SimpleOutputBuffer.java new file mode 100644 index 0000000000..4b80d38e54 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/SimpleOutputBuffer.java @@ -0,0 +1,65 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import androidx.annotation.Nullable; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Buffer for {@link SimpleDecoder} output. + */ +public class SimpleOutputBuffer extends OutputBuffer { + + private final SimpleDecoder<?, SimpleOutputBuffer, ?> owner; + + @Nullable public ByteBuffer data; + + public SimpleOutputBuffer(SimpleDecoder<?, SimpleOutputBuffer, ?> owner) { + this.owner = owner; + } + + /** + * Initializes the buffer. + * + * @param timeUs The presentation timestamp for the buffer, in microseconds. + * @param size An upper bound on the size of the data that will be written to the buffer. + * @return The {@link #data} buffer, for convenience. + */ + public ByteBuffer init(long timeUs, int size) { + this.timeUs = timeUs; + if (data == null || data.capacity() < size) { + data = ByteBuffer.allocateDirect(size).order(ByteOrder.nativeOrder()); + } + data.position(0); + data.limit(size); + return data; + } + + @Override + public void clear() { + super.clear(); + if (data != null) { + data.clear(); + } + } + + @Override + public void release() { + owner.releaseOutputBuffer(this); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/package-info.java new file mode 100644 index 0000000000..78a2c9f2e2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/decoder/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.decoder; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ClearKeyUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ClearKeyUtil.java new file mode 100644 index 0000000000..770b8511d9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ClearKeyUtil.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Utility methods for ClearKey. + */ +/* package */ final class ClearKeyUtil { + + private static final String TAG = "ClearKeyUtil"; + + private ClearKeyUtil() {} + + /** + * Adjusts ClearKey request data obtained from the Android ClearKey CDM to be spec compliant. + * + * @param request The request data. + * @return The adjusted request data. + */ + public static byte[] adjustRequestData(byte[] request) { + if (Util.SDK_INT >= 27) { + return request; + } + // Prior to O-MR1 the ClearKey CDM encoded the values in the "kids" array using Base64 encoding + // rather than Base64Url encoding. See [Internal: b/64388098]. We know the exact request format + // from the platform's InitDataParser.cpp. Since there aren't any "+" or "/" symbols elsewhere + // in the request, it's safe to fix the encoding by replacement through the whole request. + String requestString = Util.fromUtf8Bytes(request); + return Util.getUtf8Bytes(base64ToBase64Url(requestString)); + } + + /** + * Adjusts ClearKey response data to be suitable for providing to the Android ClearKey CDM. + * + * @param response The response data. + * @return The adjusted response data. + */ + public static byte[] adjustResponseData(byte[] response) { + if (Util.SDK_INT >= 27) { + return response; + } + // Prior to O-MR1 the ClearKey CDM expected Base64 encoding rather than Base64Url encoding for + // the "k" and "kid" strings. See [Internal: b/64388098]. We know that the ClearKey CDM only + // looks at the k, kid and kty parameters in each key, so can ignore the rest of the response. + try { + JSONObject responseJson = new JSONObject(Util.fromUtf8Bytes(response)); + StringBuilder adjustedResponseBuilder = new StringBuilder("{\"keys\":["); + JSONArray keysArray = responseJson.getJSONArray("keys"); + for (int i = 0; i < keysArray.length(); i++) { + if (i != 0) { + adjustedResponseBuilder.append(","); + } + JSONObject key = keysArray.getJSONObject(i); + adjustedResponseBuilder.append("{\"k\":\""); + adjustedResponseBuilder.append(base64UrlToBase64(key.getString("k"))); + adjustedResponseBuilder.append("\",\"kid\":\""); + adjustedResponseBuilder.append(base64UrlToBase64(key.getString("kid"))); + adjustedResponseBuilder.append("\",\"kty\":\""); + adjustedResponseBuilder.append(key.getString("kty")); + adjustedResponseBuilder.append("\"}"); + } + adjustedResponseBuilder.append("]}"); + return Util.getUtf8Bytes(adjustedResponseBuilder.toString()); + } catch (JSONException e) { + Log.e(TAG, "Failed to adjust response data: " + Util.fromUtf8Bytes(response), e); + return response; + } + } + + private static String base64ToBase64Url(String base64) { + return base64.replace('+', '-').replace('/', '_'); + } + + private static String base64UrlToBase64(String base64Url) { + return base64Url.replace('-', '+').replace('_', '/'); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DecryptionException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DecryptionException.java new file mode 100644 index 0000000000..989e68befd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DecryptionException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +/** + * Thrown when a non-platform component fails to decrypt data. + */ +public class DecryptionException extends Exception { + + /** + * A component specific error code. + */ + public final int errorCode; + + /** + * @param errorCode A component specific error code. + * @param message The detail message. + */ + public DecryptionException(int errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSession.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSession.java new file mode 100644 index 0000000000..ad7ed80580 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSession.java @@ -0,0 +1,607 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.NotProvisionedException; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** A {@link DrmSession} that supports playbacks using {@link ExoMediaDrm}. */ +@TargetApi(18) +/* package */ class DefaultDrmSession<T extends ExoMediaCrypto> implements DrmSession<T> { + + /** Thrown when an unexpected exception or error is thrown during provisioning or key requests. */ + public static final class UnexpectedDrmSessionException extends IOException { + + public UnexpectedDrmSessionException(Throwable cause) { + super("Unexpected " + cause.getClass().getSimpleName() + ": " + cause.getMessage(), cause); + } + } + + /** Manages provisioning requests. */ + public interface ProvisioningManager<T extends ExoMediaCrypto> { + + /** + * Called when a session requires provisioning. The manager <em>may</em> call {@link + * #provision()} to have this session perform the provisioning operation. The manager + * <em>will</em> call {@link DefaultDrmSession#onProvisionCompleted()} when provisioning has + * completed, or {@link DefaultDrmSession#onProvisionError} if provisioning fails. + * + * @param session The session. + */ + void provisionRequired(DefaultDrmSession<T> session); + + /** + * Called by a session when it fails to perform a provisioning operation. + * + * @param error The error that occurred. + */ + void onProvisionError(Exception error); + + /** Called by a session when it successfully completes a provisioning operation. */ + void onProvisionCompleted(); + } + + /** Callback to be notified when the session is released. */ + public interface ReleaseCallback<T extends ExoMediaCrypto> { + + /** + * Called immediately after releasing session resources. + * + * @param session The session. + */ + void onSessionReleased(DefaultDrmSession<T> session); + } + + private static final String TAG = "DefaultDrmSession"; + + private static final int MSG_PROVISION = 0; + private static final int MSG_KEYS = 1; + private static final int MAX_LICENSE_DURATION_TO_RENEW_SECONDS = 60; + + /** The DRM scheme datas, or null if this session uses offline keys. */ + @Nullable public final List<SchemeData> schemeDatas; + + private final ExoMediaDrm<T> mediaDrm; + private final ProvisioningManager<T> provisioningManager; + private final ReleaseCallback<T> releaseCallback; + private final @DefaultDrmSessionManager.Mode int mode; + private final boolean playClearSamplesWithoutKeys; + private final boolean isPlaceholderSession; + private final HashMap<String, String> keyRequestParameters; + private final EventDispatcher<DefaultDrmSessionEventListener> eventDispatcher; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + + /* package */ final MediaDrmCallback callback; + /* package */ final UUID uuid; + /* package */ final ResponseHandler responseHandler; + + private @DrmSession.State int state; + private int referenceCount; + @Nullable private HandlerThread requestHandlerThread; + @Nullable private RequestHandler requestHandler; + @Nullable private T mediaCrypto; + @Nullable private DrmSessionException lastException; + @Nullable private byte[] sessionId; + @MonotonicNonNull private byte[] offlineLicenseKeySetId; + + @Nullable private KeyRequest currentKeyRequest; + @Nullable private ProvisionRequest currentProvisionRequest; + + /** + * Instantiates a new DRM session. + * + * @param uuid The UUID of the drm scheme. + * @param mediaDrm The media DRM. + * @param provisioningManager The manager for provisioning. + * @param releaseCallback The {@link ReleaseCallback}. + * @param schemeDatas DRM scheme datas for this session, or null if an {@code + * offlineLicenseKeySetId} is provided or if {@code isPlaceholderSession} is true. + * @param mode The DRM mode. Ignored if {@code isPlaceholderSession} is true. + * @param isPlaceholderSession Whether this session is not expected to acquire any keys. + * @param offlineLicenseKeySetId The offline license key set identifier, or null when not using + * offline keys. + * @param keyRequestParameters Key request parameters. + * @param callback The media DRM callback. + * @param playbackLooper The playback looper. + * @param eventDispatcher The dispatcher for DRM session manager events. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy} for key and provisioning + * requests. + */ + // the constructor does not initialize fields: sessionId + @SuppressWarnings("nullness:initialization.fields.uninitialized") + public DefaultDrmSession( + UUID uuid, + ExoMediaDrm<T> mediaDrm, + ProvisioningManager<T> provisioningManager, + ReleaseCallback<T> releaseCallback, + @Nullable List<SchemeData> schemeDatas, + @DefaultDrmSessionManager.Mode int mode, + boolean playClearSamplesWithoutKeys, + boolean isPlaceholderSession, + @Nullable byte[] offlineLicenseKeySetId, + HashMap<String, String> keyRequestParameters, + MediaDrmCallback callback, + Looper playbackLooper, + EventDispatcher<DefaultDrmSessionEventListener> eventDispatcher, + LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + if (mode == DefaultDrmSessionManager.MODE_QUERY + || mode == DefaultDrmSessionManager.MODE_RELEASE) { + Assertions.checkNotNull(offlineLicenseKeySetId); + } + this.uuid = uuid; + this.provisioningManager = provisioningManager; + this.releaseCallback = releaseCallback; + this.mediaDrm = mediaDrm; + this.mode = mode; + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + this.isPlaceholderSession = isPlaceholderSession; + if (offlineLicenseKeySetId != null) { + this.offlineLicenseKeySetId = offlineLicenseKeySetId; + this.schemeDatas = null; + } else { + this.schemeDatas = Collections.unmodifiableList(Assertions.checkNotNull(schemeDatas)); + } + this.keyRequestParameters = keyRequestParameters; + this.callback = callback; + this.eventDispatcher = eventDispatcher; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + state = STATE_OPENING; + responseHandler = new ResponseHandler(playbackLooper); + } + + public boolean hasSessionId(byte[] sessionId) { + return Arrays.equals(this.sessionId, sessionId); + } + + public void onMediaDrmEvent(int what) { + switch (what) { + case ExoMediaDrm.EVENT_KEY_REQUIRED: + onKeysRequired(); + break; + default: + break; + } + } + + // Provisioning implementation. + + public void provision() { + currentProvisionRequest = mediaDrm.getProvisionRequest(); + Util.castNonNull(requestHandler) + .post( + MSG_PROVISION, + Assertions.checkNotNull(currentProvisionRequest), + /* allowRetry= */ true); + } + + public void onProvisionCompleted() { + if (openInternal(false)) { + doLicense(true); + } + } + + public void onProvisionError(Exception error) { + onError(error); + } + + // DrmSession implementation. + + @Override + @DrmSession.State + public final int getState() { + return state; + } + + @Override + public boolean playClearSamplesWithoutKeys() { + return playClearSamplesWithoutKeys; + } + + @Override + public final @Nullable DrmSessionException getError() { + return state == STATE_ERROR ? lastException : null; + } + + @Override + public final @Nullable T getMediaCrypto() { + return mediaCrypto; + } + + @Override + @Nullable + public Map<String, String> queryKeyStatus() { + return sessionId == null ? null : mediaDrm.queryKeyStatus(sessionId); + } + + @Override + @Nullable + public byte[] getOfflineLicenseKeySetId() { + return offlineLicenseKeySetId; + } + + @Override + public void acquire() { + Assertions.checkState(referenceCount >= 0); + if (++referenceCount == 1) { + Assertions.checkState(state == STATE_OPENING); + requestHandlerThread = new HandlerThread("DrmRequestHandler"); + requestHandlerThread.start(); + requestHandler = new RequestHandler(requestHandlerThread.getLooper()); + if (openInternal(true)) { + doLicense(true); + } + } + } + + @Override + public void release() { + if (--referenceCount == 0) { + // Assigning null to various non-null variables for clean-up. + state = STATE_RELEASED; + Util.castNonNull(responseHandler).removeCallbacksAndMessages(null); + Util.castNonNull(requestHandler).removeCallbacksAndMessages(null); + requestHandler = null; + Util.castNonNull(requestHandlerThread).quit(); + requestHandlerThread = null; + mediaCrypto = null; + lastException = null; + currentKeyRequest = null; + currentProvisionRequest = null; + if (sessionId != null) { + mediaDrm.closeSession(sessionId); + sessionId = null; + eventDispatcher.dispatch(DefaultDrmSessionEventListener::onDrmSessionReleased); + } + releaseCallback.onSessionReleased(this); + } + } + + // Internal methods. + + /** + * Try to open a session, do provisioning if necessary. + * + * @param allowProvisioning if provisioning is allowed, set this to false when calling from + * processing provision response. + * @return true on success, false otherwise. + */ + @EnsuresNonNullIf(result = true, expression = "sessionId") + private boolean openInternal(boolean allowProvisioning) { + if (isOpen()) { + // Already opened + return true; + } + + try { + sessionId = mediaDrm.openSession(); + mediaCrypto = mediaDrm.createMediaCrypto(sessionId); + eventDispatcher.dispatch(DefaultDrmSessionEventListener::onDrmSessionAcquired); + state = STATE_OPENED; + Assertions.checkNotNull(sessionId); + return true; + } catch (NotProvisionedException e) { + if (allowProvisioning) { + provisioningManager.provisionRequired(this); + } else { + onError(e); + } + } catch (Exception e) { + onError(e); + } + + return false; + } + + private void onProvisionResponse(Object request, Object response) { + if (request != currentProvisionRequest || (state != STATE_OPENING && !isOpen())) { + // This event is stale. + return; + } + currentProvisionRequest = null; + + if (response instanceof Exception) { + provisioningManager.onProvisionError((Exception) response); + return; + } + + try { + mediaDrm.provideProvisionResponse((byte[]) response); + } catch (Exception e) { + provisioningManager.onProvisionError(e); + return; + } + + provisioningManager.onProvisionCompleted(); + } + + @RequiresNonNull("sessionId") + private void doLicense(boolean allowRetry) { + if (isPlaceholderSession) { + return; + } + byte[] sessionId = Util.castNonNull(this.sessionId); + switch (mode) { + case DefaultDrmSessionManager.MODE_PLAYBACK: + case DefaultDrmSessionManager.MODE_QUERY: + if (offlineLicenseKeySetId == null) { + postKeyRequest(sessionId, ExoMediaDrm.KEY_TYPE_STREAMING, allowRetry); + } else if (state == STATE_OPENED_WITH_KEYS || restoreKeys()) { + long licenseDurationRemainingSec = getLicenseDurationRemainingSec(); + if (mode == DefaultDrmSessionManager.MODE_PLAYBACK + && licenseDurationRemainingSec <= MAX_LICENSE_DURATION_TO_RENEW_SECONDS) { + Log.d( + TAG, + "Offline license has expired or will expire soon. " + + "Remaining seconds: " + + licenseDurationRemainingSec); + postKeyRequest(sessionId, ExoMediaDrm.KEY_TYPE_OFFLINE, allowRetry); + } else if (licenseDurationRemainingSec <= 0) { + onError(new KeysExpiredException()); + } else { + state = STATE_OPENED_WITH_KEYS; + eventDispatcher.dispatch(DefaultDrmSessionEventListener::onDrmKeysRestored); + } + } + break; + case DefaultDrmSessionManager.MODE_DOWNLOAD: + if (offlineLicenseKeySetId == null || restoreKeys()) { + postKeyRequest(sessionId, ExoMediaDrm.KEY_TYPE_OFFLINE, allowRetry); + } + break; + case DefaultDrmSessionManager.MODE_RELEASE: + Assertions.checkNotNull(offlineLicenseKeySetId); + Assertions.checkNotNull(this.sessionId); + // It's not necessary to restore the key (and open a session to do that) before releasing it + // but this serves as a good sanity/fast-failure check. + if (restoreKeys()) { + postKeyRequest(offlineLicenseKeySetId, ExoMediaDrm.KEY_TYPE_RELEASE, allowRetry); + } + break; + default: + break; + } + } + + @RequiresNonNull({"sessionId", "offlineLicenseKeySetId"}) + private boolean restoreKeys() { + try { + mediaDrm.restoreKeys(sessionId, offlineLicenseKeySetId); + return true; + } catch (Exception e) { + Log.e(TAG, "Error trying to restore keys.", e); + onError(e); + } + return false; + } + + private long getLicenseDurationRemainingSec() { + if (!C.WIDEVINE_UUID.equals(uuid)) { + return Long.MAX_VALUE; + } + Pair<Long, Long> pair = + Assertions.checkNotNull(WidevineUtil.getLicenseDurationRemainingSec(this)); + return Math.min(pair.first, pair.second); + } + + private void postKeyRequest(byte[] scope, int type, boolean allowRetry) { + try { + currentKeyRequest = mediaDrm.getKeyRequest(scope, schemeDatas, type, keyRequestParameters); + Util.castNonNull(requestHandler) + .post(MSG_KEYS, Assertions.checkNotNull(currentKeyRequest), allowRetry); + } catch (Exception e) { + onKeysError(e); + } + } + + private void onKeyResponse(Object request, Object response) { + if (request != currentKeyRequest || !isOpen()) { + // This event is stale. + return; + } + currentKeyRequest = null; + + if (response instanceof Exception) { + onKeysError((Exception) response); + return; + } + + try { + byte[] responseData = (byte[]) response; + if (mode == DefaultDrmSessionManager.MODE_RELEASE) { + mediaDrm.provideKeyResponse(Util.castNonNull(offlineLicenseKeySetId), responseData); + eventDispatcher.dispatch(DefaultDrmSessionEventListener::onDrmKeysRestored); + } else { + byte[] keySetId = mediaDrm.provideKeyResponse(sessionId, responseData); + if ((mode == DefaultDrmSessionManager.MODE_DOWNLOAD + || (mode == DefaultDrmSessionManager.MODE_PLAYBACK + && offlineLicenseKeySetId != null)) + && keySetId != null + && keySetId.length != 0) { + offlineLicenseKeySetId = keySetId; + } + state = STATE_OPENED_WITH_KEYS; + eventDispatcher.dispatch(DefaultDrmSessionEventListener::onDrmKeysLoaded); + } + } catch (Exception e) { + onKeysError(e); + } + } + + private void onKeysRequired() { + if (mode == DefaultDrmSessionManager.MODE_PLAYBACK && state == STATE_OPENED_WITH_KEYS) { + Util.castNonNull(sessionId); + doLicense(/* allowRetry= */ false); + } + } + + private void onKeysError(Exception e) { + if (e instanceof NotProvisionedException) { + provisioningManager.provisionRequired(this); + } else { + onError(e); + } + } + + private void onError(final Exception e) { + lastException = new DrmSessionException(e); + eventDispatcher.dispatch(listener -> listener.onDrmSessionManagerError(e)); + if (state != STATE_OPENED_WITH_KEYS) { + state = STATE_ERROR; + } + } + + @EnsuresNonNullIf(result = true, expression = "sessionId") + @SuppressWarnings("contracts.conditional.postcondition.not.satisfied") + private boolean isOpen() { + return state == STATE_OPENED || state == STATE_OPENED_WITH_KEYS; + } + + // Internal classes. + + @SuppressLint("HandlerLeak") + private class ResponseHandler extends Handler { + + public ResponseHandler(Looper looper) { + super(looper); + } + + @Override + @SuppressWarnings("unchecked") + public void handleMessage(Message msg) { + Pair<Object, Object> requestAndResponse = (Pair<Object, Object>) msg.obj; + Object request = requestAndResponse.first; + Object response = requestAndResponse.second; + switch (msg.what) { + case MSG_PROVISION: + onProvisionResponse(request, response); + break; + case MSG_KEYS: + onKeyResponse(request, response); + break; + default: + break; + } + } + } + + @SuppressLint("HandlerLeak") + private class RequestHandler extends Handler { + + public RequestHandler(Looper backgroundLooper) { + super(backgroundLooper); + } + + void post(int what, Object request, boolean allowRetry) { + RequestTask requestTask = + new RequestTask(allowRetry, /* startTimeMs= */ SystemClock.elapsedRealtime(), request); + obtainMessage(what, requestTask).sendToTarget(); + } + + @Override + public void handleMessage(Message msg) { + RequestTask requestTask = (RequestTask) msg.obj; + Object response; + try { + switch (msg.what) { + case MSG_PROVISION: + response = + callback.executeProvisionRequest(uuid, (ProvisionRequest) requestTask.request); + break; + case MSG_KEYS: + response = callback.executeKeyRequest(uuid, (KeyRequest) requestTask.request); + break; + default: + throw new RuntimeException(); + } + } catch (Exception e) { + if (maybeRetryRequest(msg, e)) { + return; + } + response = e; + } + responseHandler + .obtainMessage(msg.what, Pair.create(requestTask.request, response)) + .sendToTarget(); + } + + private boolean maybeRetryRequest(Message originalMsg, Exception e) { + RequestTask requestTask = (RequestTask) originalMsg.obj; + if (!requestTask.allowRetry) { + return false; + } + requestTask.errorCount++; + if (requestTask.errorCount + > loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_DRM)) { + return false; + } + IOException ioException = + e instanceof IOException ? (IOException) e : new UnexpectedDrmSessionException(e); + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + C.DATA_TYPE_DRM, + /* loadDurationMs= */ SystemClock.elapsedRealtime() - requestTask.startTimeMs, + ioException, + requestTask.errorCount); + if (retryDelayMs == C.TIME_UNSET) { + // The error is fatal. + return false; + } + sendMessageDelayed(Message.obtain(originalMsg), retryDelayMs); + return true; + } + } + + private static final class RequestTask { + + public final boolean allowRetry; + public final long startTimeMs; + public final Object request; + public int errorCount; + + public RequestTask(boolean allowRetry, long startTimeMs, Object request) { + this.allowRetry = allowRetry; + this.startTimeMs = startTimeMs; + this.request = request; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSessionEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSessionEventListener.java new file mode 100644 index 0000000000..35bc7faf28 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSessionEventListener.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; + +/** Listener of {@link DefaultDrmSessionManager} events. */ +public interface DefaultDrmSessionEventListener { + + /** Called each time a drm session is acquired. */ + default void onDrmSessionAcquired() {} + + /** Called each time keys are loaded. */ + default void onDrmKeysLoaded() {} + + /** + * Called when a drm error occurs. + * + * <p>This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error and continue. Hence applications should + * <em>not</em> implement this method to display a user visible error or initiate an application + * level retry ({@link Player.EventListener#onPlayerError} is the appropriate place to implement + * such behavior). This method is called to provide the application with an opportunity to log the + * error if it wishes to do so. + * + * @param error The corresponding exception. + */ + default void onDrmSessionManagerError(Exception error) {} + + /** Called each time offline keys are restored. */ + default void onDrmKeysRestored() {} + + /** Called each time offline keys are removed. */ + default void onDrmKeysRemoved() {} + + /** Called each time a drm session is released. */ + default void onDrmSessionReleased() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java new file mode 100644 index 0000000000..683862b99a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DefaultDrmSessionManager.java @@ -0,0 +1,691 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.OnEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** A {@link DrmSessionManager} that supports playbacks using {@link ExoMediaDrm}. */ +@TargetApi(18) +public class DefaultDrmSessionManager<T extends ExoMediaCrypto> implements DrmSessionManager<T> { + + /** + * Builder for {@link DefaultDrmSessionManager} instances. + * + * <p>See {@link #Builder} for the list of default values. + */ + public static final class Builder { + + private final HashMap<String, String> keyRequestParameters; + private UUID uuid; + private ExoMediaDrm.Provider<ExoMediaCrypto> exoMediaDrmProvider; + private boolean multiSession; + private int[] useDrmSessionsForClearContentTrackTypes; + private boolean playClearSamplesWithoutKeys; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + + /** + * Creates a builder with default values. The default values are: + * + * <ul> + * <li>{@link #setKeyRequestParameters keyRequestParameters}: An empty map. + * <li>{@link #setUuidAndExoMediaDrmProvider UUID}: {@link C#WIDEVINE_UUID}. + * <li>{@link #setUuidAndExoMediaDrmProvider ExoMediaDrm.Provider}: {@link + * FrameworkMediaDrm#DEFAULT_PROVIDER}. + * <li>{@link #setMultiSession multiSession}: {@code false}. + * <li>{@link #setUseDrmSessionsForClearContent useDrmSessionsForClearContent}: No tracks. + * <li>{@link #setPlayClearSamplesWithoutKeys playClearSamplesWithoutKeys}: {@code false}. + * <li>{@link #setLoadErrorHandlingPolicy LoadErrorHandlingPolicy}: {@link + * DefaultLoadErrorHandlingPolicy}. + * </ul> + */ + @SuppressWarnings("unchecked") + public Builder() { + keyRequestParameters = new HashMap<>(); + uuid = C.WIDEVINE_UUID; + exoMediaDrmProvider = (ExoMediaDrm.Provider) FrameworkMediaDrm.DEFAULT_PROVIDER; + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + useDrmSessionsForClearContentTrackTypes = new int[0]; + } + + /** + * Sets the key request parameters to pass as the last argument to {@link + * ExoMediaDrm#getKeyRequest(byte[], List, int, HashMap)}. + * + * <p>Custom data for PlayReady should be set under {@link #PLAYREADY_CUSTOM_DATA_KEY}. + * + * @param keyRequestParameters A map with parameters. + * @return This builder. + */ + public Builder setKeyRequestParameters(Map<String, String> keyRequestParameters) { + this.keyRequestParameters.clear(); + this.keyRequestParameters.putAll(Assertions.checkNotNull(keyRequestParameters)); + return this; + } + + /** + * Sets the UUID of the DRM scheme and the {@link ExoMediaDrm.Provider} to use. + * + * @param uuid The UUID of the DRM scheme. + * @param exoMediaDrmProvider The {@link ExoMediaDrm.Provider}. + * @return This builder. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public Builder setUuidAndExoMediaDrmProvider( + UUID uuid, ExoMediaDrm.Provider exoMediaDrmProvider) { + this.uuid = Assertions.checkNotNull(uuid); + this.exoMediaDrmProvider = Assertions.checkNotNull(exoMediaDrmProvider); + return this; + } + + /** + * Sets whether this session manager is allowed to acquire multiple simultaneous sessions. + * + * <p>Users should pass false when a single key request will obtain all keys required to decrypt + * the associated content. {@code multiSession} is required when content uses key rotation. + * + * @param multiSession Whether this session manager is allowed to acquire multiple simultaneous + * sessions. + * @return This builder. + */ + public Builder setMultiSession(boolean multiSession) { + this.multiSession = multiSession; + return this; + } + + /** + * Sets whether this session manager should attach {@link DrmSession DrmSessions} to the clear + * sections of the media content. + * + * <p>Using {@link DrmSession DrmSessions} for clear content avoids the recreation of decoders + * when transitioning between clear and encrypted sections of content. + * + * @param useDrmSessionsForClearContentTrackTypes The track types ({@link C#TRACK_TYPE_AUDIO} + * and/or {@link C#TRACK_TYPE_VIDEO}) for which to use a {@link DrmSession} regardless of + * whether the content is clear or encrypted. + * @return This builder. + * @throws IllegalArgumentException If {@code useDrmSessionsForClearContentTrackTypes} contains + * track types other than {@link C#TRACK_TYPE_AUDIO} and {@link C#TRACK_TYPE_VIDEO}. + */ + public Builder setUseDrmSessionsForClearContent( + int... useDrmSessionsForClearContentTrackTypes) { + for (int trackType : useDrmSessionsForClearContentTrackTypes) { + Assertions.checkArgument( + trackType == C.TRACK_TYPE_VIDEO || trackType == C.TRACK_TYPE_AUDIO); + } + this.useDrmSessionsForClearContentTrackTypes = + useDrmSessionsForClearContentTrackTypes.clone(); + return this; + } + + /** + * Sets whether clear samples within protected content should be played when keys for the + * encrypted part of the content have yet to be loaded. + * + * @param playClearSamplesWithoutKeys Whether clear samples within protected content should be + * played when keys for the encrypted part of the content have yet to be loaded. + * @return This builder. + */ + public Builder setPlayClearSamplesWithoutKeys(boolean playClearSamplesWithoutKeys) { + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + return this; + } + + /** + * Sets the {@link LoadErrorHandlingPolicy} for key and provisioning requests. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This builder. + */ + public Builder setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + this.loadErrorHandlingPolicy = Assertions.checkNotNull(loadErrorHandlingPolicy); + return this; + } + + /** Builds a {@link DefaultDrmSessionManager} instance. */ + public DefaultDrmSessionManager<ExoMediaCrypto> build(MediaDrmCallback mediaDrmCallback) { + return new DefaultDrmSessionManager<>( + uuid, + exoMediaDrmProvider, + mediaDrmCallback, + keyRequestParameters, + multiSession, + useDrmSessionsForClearContentTrackTypes, + playClearSamplesWithoutKeys, + loadErrorHandlingPolicy); + } + } + + /** + * Signals that the {@link DrmInitData} passed to {@link #acquireSession} does not contain does + * not contain scheme data for the required UUID. + */ + public static final class MissingSchemeDataException extends Exception { + + private MissingSchemeDataException(UUID uuid) { + super("Media does not support uuid: " + uuid); + } + } + + /** + * A key for specifying PlayReady custom data in the key request parameters passed to {@link + * Builder#setKeyRequestParameters(Map)}. + */ + public static final String PLAYREADY_CUSTOM_DATA_KEY = "PRCustomData"; + + /** + * Determines the action to be done after a session acquired. One of {@link #MODE_PLAYBACK}, + * {@link #MODE_QUERY}, {@link #MODE_DOWNLOAD} or {@link #MODE_RELEASE}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({MODE_PLAYBACK, MODE_QUERY, MODE_DOWNLOAD, MODE_RELEASE}) + public @interface Mode {} + /** + * Loads and refreshes (if necessary) a license for playback. Supports streaming and offline + * licenses. + */ + public static final int MODE_PLAYBACK = 0; + /** Restores an offline license to allow its status to be queried. */ + public static final int MODE_QUERY = 1; + /** Downloads an offline license or renews an existing one. */ + public static final int MODE_DOWNLOAD = 2; + /** Releases an existing offline license. */ + public static final int MODE_RELEASE = 3; + /** Number of times to retry for initial provisioning and key request for reporting error. */ + public static final int INITIAL_DRM_REQUEST_RETRY_COUNT = 3; + + private static final String TAG = "DefaultDrmSessionMgr"; + + private final UUID uuid; + private final ExoMediaDrm.Provider<T> exoMediaDrmProvider; + private final MediaDrmCallback callback; + private final HashMap<String, String> keyRequestParameters; + private final EventDispatcher<DefaultDrmSessionEventListener> eventDispatcher; + private final boolean multiSession; + private final int[] useDrmSessionsForClearContentTrackTypes; + private final boolean playClearSamplesWithoutKeys; + private final ProvisioningManagerImpl provisioningManagerImpl; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + + private final List<DefaultDrmSession<T>> sessions; + private final List<DefaultDrmSession<T>> provisioningSessions; + + private int prepareCallsCount; + @Nullable private ExoMediaDrm<T> exoMediaDrm; + @Nullable private DefaultDrmSession<T> placeholderDrmSession; + @Nullable private DefaultDrmSession<T> noMultiSessionDrmSession; + @Nullable private Looper playbackLooper; + private int mode; + @Nullable private byte[] offlineLicenseKeySetId; + + /* package */ volatile @Nullable MediaDrmHandler mediaDrmHandler; + + /** + * @param uuid The UUID of the drm scheme. + * @param exoMediaDrm An underlying {@link ExoMediaDrm} for use by the manager. + * @param callback Performs key and provisioning requests. + * @param keyRequestParameters An optional map of parameters to pass as the last argument to + * {@link ExoMediaDrm#getKeyRequest(byte[], List, int, HashMap)}. May be null. + * @deprecated Use {@link Builder} instead. + */ + @SuppressWarnings("deprecation") + @Deprecated + public DefaultDrmSessionManager( + UUID uuid, + ExoMediaDrm<T> exoMediaDrm, + MediaDrmCallback callback, + @Nullable HashMap<String, String> keyRequestParameters) { + this( + uuid, + exoMediaDrm, + callback, + keyRequestParameters == null ? new HashMap<>() : keyRequestParameters, + /* multiSession= */ false, + INITIAL_DRM_REQUEST_RETRY_COUNT); + } + + /** + * @param uuid The UUID of the drm scheme. + * @param exoMediaDrm An underlying {@link ExoMediaDrm} for use by the manager. + * @param callback Performs key and provisioning requests. + * @param keyRequestParameters An optional map of parameters to pass as the last argument to + * {@link ExoMediaDrm#getKeyRequest(byte[], List, int, HashMap)}. May be null. + * @param multiSession A boolean that specify whether multiple key session support is enabled. + * Default is false. + * @deprecated Use {@link Builder} instead. + */ + @Deprecated + public DefaultDrmSessionManager( + UUID uuid, + ExoMediaDrm<T> exoMediaDrm, + MediaDrmCallback callback, + @Nullable HashMap<String, String> keyRequestParameters, + boolean multiSession) { + this( + uuid, + exoMediaDrm, + callback, + keyRequestParameters == null ? new HashMap<>() : keyRequestParameters, + multiSession, + INITIAL_DRM_REQUEST_RETRY_COUNT); + } + + /** + * @param uuid The UUID of the drm scheme. + * @param exoMediaDrm An underlying {@link ExoMediaDrm} for use by the manager. + * @param callback Performs key and provisioning requests. + * @param keyRequestParameters An optional map of parameters to pass as the last argument to + * {@link ExoMediaDrm#getKeyRequest(byte[], List, int, HashMap)}. May be null. + * @param multiSession A boolean that specify whether multiple key session support is enabled. + * Default is false. + * @param initialDrmRequestRetryCount The number of times to retry for initial provisioning and + * key request before reporting error. + * @deprecated Use {@link Builder} instead. + */ + @Deprecated + public DefaultDrmSessionManager( + UUID uuid, + ExoMediaDrm<T> exoMediaDrm, + MediaDrmCallback callback, + @Nullable HashMap<String, String> keyRequestParameters, + boolean multiSession, + int initialDrmRequestRetryCount) { + this( + uuid, + new ExoMediaDrm.AppManagedProvider<>(exoMediaDrm), + callback, + keyRequestParameters == null ? new HashMap<>() : keyRequestParameters, + multiSession, + /* useDrmSessionsForClearContentTrackTypes= */ new int[0], + /* playClearSamplesWithoutKeys= */ false, + new DefaultLoadErrorHandlingPolicy(initialDrmRequestRetryCount)); + } + + // the constructor does not initialize fields: offlineLicenseKeySetId + @SuppressWarnings("nullness:initialization.fields.uninitialized") + private DefaultDrmSessionManager( + UUID uuid, + ExoMediaDrm.Provider<T> exoMediaDrmProvider, + MediaDrmCallback callback, + HashMap<String, String> keyRequestParameters, + boolean multiSession, + int[] useDrmSessionsForClearContentTrackTypes, + boolean playClearSamplesWithoutKeys, + LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkNotNull(uuid); + Assertions.checkArgument(!C.COMMON_PSSH_UUID.equals(uuid), "Use C.CLEARKEY_UUID instead"); + this.uuid = uuid; + this.exoMediaDrmProvider = exoMediaDrmProvider; + this.callback = callback; + this.keyRequestParameters = keyRequestParameters; + this.eventDispatcher = new EventDispatcher<>(); + this.multiSession = multiSession; + this.useDrmSessionsForClearContentTrackTypes = useDrmSessionsForClearContentTrackTypes; + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + provisioningManagerImpl = new ProvisioningManagerImpl(); + mode = MODE_PLAYBACK; + sessions = new ArrayList<>(); + provisioningSessions = new ArrayList<>(); + } + + /** + * Adds a {@link DefaultDrmSessionEventListener} to listen to drm session events. + * + * @param handler A handler to use when delivering events to {@code eventListener}. + * @param eventListener A listener of events. + */ + public final void addListener(Handler handler, DefaultDrmSessionEventListener eventListener) { + eventDispatcher.addListener(handler, eventListener); + } + + /** + * Removes a {@link DefaultDrmSessionEventListener} from the list of drm session event listeners. + * + * @param eventListener The listener to remove. + */ + public final void removeListener(DefaultDrmSessionEventListener eventListener) { + eventDispatcher.removeListener(eventListener); + } + + /** + * Sets the mode, which determines the role of sessions acquired from the instance. This must be + * called before {@link #acquireSession(Looper, DrmInitData)} or {@link + * #acquirePlaceholderSession} is called. + * + * <p>By default, the mode is {@link #MODE_PLAYBACK} and a streaming license is requested when + * required. + * + * <p>{@code mode} must be one of these: + * + * <ul> + * <li>{@link #MODE_PLAYBACK}: If {@code offlineLicenseKeySetId} is null, a streaming license is + * requested otherwise the offline license is restored. + * <li>{@link #MODE_QUERY}: {@code offlineLicenseKeySetId} can not be null. The offline license + * is restored. + * <li>{@link #MODE_DOWNLOAD}: If {@code offlineLicenseKeySetId} is null, an offline license is + * requested otherwise the offline license is renewed. + * <li>{@link #MODE_RELEASE}: {@code offlineLicenseKeySetId} can not be null. The offline + * license is released. + * </ul> + * + * @param mode The mode to be set. + * @param offlineLicenseKeySetId The key set id of the license to be used with the given mode. + */ + public void setMode(@Mode int mode, @Nullable byte[] offlineLicenseKeySetId) { + Assertions.checkState(sessions.isEmpty()); + if (mode == MODE_QUERY || mode == MODE_RELEASE) { + Assertions.checkNotNull(offlineLicenseKeySetId); + } + this.mode = mode; + this.offlineLicenseKeySetId = offlineLicenseKeySetId; + } + + // DrmSessionManager implementation. + + @Override + public final void prepare() { + if (prepareCallsCount++ == 0) { + Assertions.checkState(exoMediaDrm == null); + exoMediaDrm = exoMediaDrmProvider.acquireExoMediaDrm(uuid); + exoMediaDrm.setOnEventListener(new MediaDrmEventListener()); + } + } + + @Override + public final void release() { + if (--prepareCallsCount == 0) { + Assertions.checkNotNull(exoMediaDrm).release(); + exoMediaDrm = null; + } + } + + @Override + public boolean canAcquireSession(DrmInitData drmInitData) { + if (offlineLicenseKeySetId != null) { + // An offline license can be restored so a session can always be acquired. + return true; + } + List<SchemeData> schemeDatas = getSchemeDatas(drmInitData, uuid, true); + if (schemeDatas.isEmpty()) { + if (drmInitData.schemeDataCount == 1 && drmInitData.get(0).matches(C.COMMON_PSSH_UUID)) { + // Assume scheme specific data will be added before the session is opened. + Log.w( + TAG, "DrmInitData only contains common PSSH SchemeData. Assuming support for: " + uuid); + } else { + // No data for this manager's scheme. + return false; + } + } + String schemeType = drmInitData.schemeType; + if (schemeType == null || C.CENC_TYPE_cenc.equals(schemeType)) { + // If there is no scheme information, assume patternless AES-CTR. + return true; + } else if (C.CENC_TYPE_cbc1.equals(schemeType) + || C.CENC_TYPE_cbcs.equals(schemeType) + || C.CENC_TYPE_cens.equals(schemeType)) { + // API support for AES-CBC and pattern encryption was added in API 24. However, the + // implementation was not stable until API 25. + return Util.SDK_INT >= 25; + } + // Unknown schemes, assume one of them is supported. + return true; + } + + @Override + @Nullable + public DrmSession<T> acquirePlaceholderSession(Looper playbackLooper, int trackType) { + assertExpectedPlaybackLooper(playbackLooper); + ExoMediaDrm<T> exoMediaDrm = Assertions.checkNotNull(this.exoMediaDrm); + boolean avoidPlaceholderDrmSessions = + FrameworkMediaCrypto.class.equals(exoMediaDrm.getExoMediaCryptoType()) + && FrameworkMediaCrypto.WORKAROUND_DEVICE_NEEDS_KEYS_TO_CONFIGURE_CODEC; + // Avoid attaching a session to sparse formats. + if (avoidPlaceholderDrmSessions + || Util.linearSearch(useDrmSessionsForClearContentTrackTypes, trackType) == C.INDEX_UNSET + || exoMediaDrm.getExoMediaCryptoType() == null) { + return null; + } + maybeCreateMediaDrmHandler(playbackLooper); + if (placeholderDrmSession == null) { + DefaultDrmSession<T> placeholderDrmSession = + createNewDefaultSession( + /* schemeDatas= */ Collections.emptyList(), /* isPlaceholderSession= */ true); + sessions.add(placeholderDrmSession); + this.placeholderDrmSession = placeholderDrmSession; + } + placeholderDrmSession.acquire(); + return placeholderDrmSession; + } + + @Override + public DrmSession<T> acquireSession(Looper playbackLooper, DrmInitData drmInitData) { + assertExpectedPlaybackLooper(playbackLooper); + maybeCreateMediaDrmHandler(playbackLooper); + + @Nullable List<SchemeData> schemeDatas = null; + if (offlineLicenseKeySetId == null) { + schemeDatas = getSchemeDatas(drmInitData, uuid, false); + if (schemeDatas.isEmpty()) { + final MissingSchemeDataException error = new MissingSchemeDataException(uuid); + eventDispatcher.dispatch(listener -> listener.onDrmSessionManagerError(error)); + return new ErrorStateDrmSession<>(new DrmSessionException(error)); + } + } + + @Nullable DefaultDrmSession<T> session; + if (!multiSession) { + session = noMultiSessionDrmSession; + } else { + // Only use an existing session if it has matching init data. + session = null; + for (DefaultDrmSession<T> existingSession : sessions) { + if (Util.areEqual(existingSession.schemeDatas, schemeDatas)) { + session = existingSession; + break; + } + } + } + + if (session == null) { + // Create a new session. + session = createNewDefaultSession(schemeDatas, /* isPlaceholderSession= */ false); + if (!multiSession) { + noMultiSessionDrmSession = session; + } + sessions.add(session); + } + session.acquire(); + return session; + } + + @Override + @Nullable + public Class<T> getExoMediaCryptoType(DrmInitData drmInitData) { + return canAcquireSession(drmInitData) + ? Assertions.checkNotNull(exoMediaDrm).getExoMediaCryptoType() + : null; + } + + // Internal methods. + + private void assertExpectedPlaybackLooper(Looper playbackLooper) { + Assertions.checkState(this.playbackLooper == null || this.playbackLooper == playbackLooper); + this.playbackLooper = playbackLooper; + } + + private void maybeCreateMediaDrmHandler(Looper playbackLooper) { + if (mediaDrmHandler == null) { + mediaDrmHandler = new MediaDrmHandler(playbackLooper); + } + } + + private DefaultDrmSession<T> createNewDefaultSession( + @Nullable List<SchemeData> schemeDatas, boolean isPlaceholderSession) { + Assertions.checkNotNull(exoMediaDrm); + // Placeholder sessions should always play clear samples without keys. + boolean playClearSamplesWithoutKeys = this.playClearSamplesWithoutKeys | isPlaceholderSession; + return new DefaultDrmSession<>( + uuid, + exoMediaDrm, + /* provisioningManager= */ provisioningManagerImpl, + /* releaseCallback= */ this::onSessionReleased, + schemeDatas, + mode, + playClearSamplesWithoutKeys, + isPlaceholderSession, + offlineLicenseKeySetId, + keyRequestParameters, + callback, + Assertions.checkNotNull(playbackLooper), + eventDispatcher, + loadErrorHandlingPolicy); + } + + private void onSessionReleased(DefaultDrmSession<T> drmSession) { + sessions.remove(drmSession); + if (placeholderDrmSession == drmSession) { + placeholderDrmSession = null; + } + if (noMultiSessionDrmSession == drmSession) { + noMultiSessionDrmSession = null; + } + if (provisioningSessions.size() > 1 && provisioningSessions.get(0) == drmSession) { + // Other sessions were waiting for the released session to complete a provision operation. + // We need to have one of those sessions perform the provision operation instead. + provisioningSessions.get(1).provision(); + } + provisioningSessions.remove(drmSession); + } + + /** + * Extracts {@link SchemeData} instances suitable for the given DRM scheme {@link UUID}. + * + * @param drmInitData The {@link DrmInitData} from which to extract the {@link SchemeData}. + * @param uuid The UUID. + * @param allowMissingData Whether a {@link SchemeData} with null {@link SchemeData#data} may be + * returned. + * @return The extracted {@link SchemeData} instances, or an empty list if no suitable data is + * present. + */ + private static List<SchemeData> getSchemeDatas( + DrmInitData drmInitData, UUID uuid, boolean allowMissingData) { + // Look for matching scheme data (matching the Common PSSH box for ClearKey). + List<SchemeData> matchingSchemeDatas = new ArrayList<>(drmInitData.schemeDataCount); + for (int i = 0; i < drmInitData.schemeDataCount; i++) { + SchemeData schemeData = drmInitData.get(i); + boolean uuidMatches = + schemeData.matches(uuid) + || (C.CLEARKEY_UUID.equals(uuid) && schemeData.matches(C.COMMON_PSSH_UUID)); + if (uuidMatches && (schemeData.data != null || allowMissingData)) { + matchingSchemeDatas.add(schemeData); + } + } + return matchingSchemeDatas; + } + + @SuppressLint("HandlerLeak") + private class MediaDrmHandler extends Handler { + + public MediaDrmHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + byte[] sessionId = (byte[]) msg.obj; + if (sessionId == null) { + // The event is not associated with any particular session. + return; + } + for (DefaultDrmSession<T> session : sessions) { + if (session.hasSessionId(sessionId)) { + session.onMediaDrmEvent(msg.what); + return; + } + } + } + } + + private class ProvisioningManagerImpl implements DefaultDrmSession.ProvisioningManager<T> { + @Override + public void provisionRequired(DefaultDrmSession<T> session) { + if (provisioningSessions.contains(session)) { + // The session has already requested provisioning. + return; + } + provisioningSessions.add(session); + if (provisioningSessions.size() == 1) { + // This is the first session requesting provisioning, so have it perform the operation. + session.provision(); + } + } + + @Override + public void onProvisionCompleted() { + for (DefaultDrmSession<T> session : provisioningSessions) { + session.onProvisionCompleted(); + } + provisioningSessions.clear(); + } + + @Override + public void onProvisionError(Exception error) { + for (DefaultDrmSession<T> session : provisioningSessions) { + session.onProvisionError(error); + } + provisioningSessions.clear(); + } + } + + private class MediaDrmEventListener implements OnEventListener<T> { + + @Override + public void onEvent( + ExoMediaDrm<? extends T> md, + @Nullable byte[] sessionId, + int event, + int extra, + @Nullable byte[] data) { + Assertions.checkNotNull(mediaDrmHandler).obtainMessage(event, sessionId).sendToTarget(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmInitData.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmInitData.java new file mode 100644 index 0000000000..2a25d1deb4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmInitData.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +/** + * Initialization data for one or more DRM schemes. + */ +public final class DrmInitData implements Comparator<SchemeData>, Parcelable { + + /** + * Merges {@link DrmInitData} obtained from a media manifest and a media stream. + * + * <p>The result is generated as follows. + * + * <ol> + * <li>Include all {@link SchemeData}s from {@code manifestData} where {@link + * SchemeData#hasData()} is true. + * <li>Include all {@link SchemeData}s in {@code mediaData} where {@link SchemeData#hasData()} + * is true and for which we did not include an entry from the manifest targeting the same + * UUID. + * <li>If available, the scheme type from the manifest is used. If not, the scheme type from the + * media is used. + * </ol> + * + * @param manifestData DRM session acquisition data obtained from the manifest. + * @param mediaData DRM session acquisition data obtained from the media. + * @return A {@link DrmInitData} obtained from merging a media manifest and a media stream. + */ + public static @Nullable DrmInitData createSessionCreationData( + @Nullable DrmInitData manifestData, @Nullable DrmInitData mediaData) { + ArrayList<SchemeData> result = new ArrayList<>(); + String schemeType = null; + if (manifestData != null) { + schemeType = manifestData.schemeType; + for (SchemeData data : manifestData.schemeDatas) { + if (data.hasData()) { + result.add(data); + } + } + } + + if (mediaData != null) { + if (schemeType == null) { + schemeType = mediaData.schemeType; + } + int manifestDatasCount = result.size(); + for (SchemeData data : mediaData.schemeDatas) { + if (data.hasData() && !containsSchemeDataWithUuid(result, manifestDatasCount, data.uuid)) { + result.add(data); + } + } + } + + return result.isEmpty() ? null : new DrmInitData(schemeType, result); + } + + private final SchemeData[] schemeDatas; + + // Lazily initialized hashcode. + private int hashCode; + + /** The protection scheme type, or null if not applicable or unknown. */ + @Nullable public final String schemeType; + + /** + * Number of {@link SchemeData}s. + */ + public final int schemeDataCount; + + /** + * @param schemeDatas Scheme initialization data for possibly multiple DRM schemes. + */ + public DrmInitData(List<SchemeData> schemeDatas) { + this(null, false, schemeDatas.toArray(new SchemeData[0])); + } + + /** + * @param schemeType See {@link #schemeType}. + * @param schemeDatas Scheme initialization data for possibly multiple DRM schemes. + */ + public DrmInitData(@Nullable String schemeType, List<SchemeData> schemeDatas) { + this(schemeType, false, schemeDatas.toArray(new SchemeData[0])); + } + + /** + * @param schemeDatas Scheme initialization data for possibly multiple DRM schemes. + */ + public DrmInitData(SchemeData... schemeDatas) { + this(null, schemeDatas); + } + + /** + * @param schemeType See {@link #schemeType}. + * @param schemeDatas Scheme initialization data for possibly multiple DRM schemes. + */ + public DrmInitData(@Nullable String schemeType, SchemeData... schemeDatas) { + this(schemeType, true, schemeDatas); + } + + private DrmInitData(@Nullable String schemeType, boolean cloneSchemeDatas, + SchemeData... schemeDatas) { + this.schemeType = schemeType; + if (cloneSchemeDatas) { + schemeDatas = schemeDatas.clone(); + } + this.schemeDatas = schemeDatas; + schemeDataCount = schemeDatas.length; + // Sorting ensures that universal scheme data (i.e. data that applies to all schemes) is matched + // last. It's also required by the equals and hashcode implementations. + Arrays.sort(this.schemeDatas, this); + } + + /* package */ + DrmInitData(Parcel in) { + schemeType = in.readString(); + schemeDatas = Util.castNonNull(in.createTypedArray(SchemeData.CREATOR)); + schemeDataCount = schemeDatas.length; + } + + /** + * Retrieves data for a given DRM scheme, specified by its UUID. + * + * @deprecated Use {@link #get(int)} and {@link SchemeData#matches(UUID)} instead. + * @param uuid The DRM scheme's UUID. + * @return The initialization data for the scheme, or null if the scheme is not supported. + */ + @Deprecated + @Nullable + public SchemeData get(UUID uuid) { + for (SchemeData schemeData : schemeDatas) { + if (schemeData.matches(uuid)) { + return schemeData; + } + } + return null; + } + + /** + * Retrieves the {@link SchemeData} at a given index. + * + * @param index The index of the scheme to return. Must not exceed {@link #schemeDataCount}. + * @return The {@link SchemeData} at the specified index. + */ + public SchemeData get(int index) { + return schemeDatas[index]; + } + + /** + * Returns a copy with the specified protection scheme type. + * + * @param schemeType A protection scheme type. May be null. + * @return A copy with the specified protection scheme type. + */ + public DrmInitData copyWithSchemeType(@Nullable String schemeType) { + if (Util.areEqual(this.schemeType, schemeType)) { + return this; + } + return new DrmInitData(schemeType, false, schemeDatas); + } + + /** + * Returns an instance containing the {@link #schemeDatas} from both this and {@code other}. The + * {@link #schemeType} of the instances being merged must either match, or at least one scheme + * type must be {@code null}. + * + * @param drmInitData The instance to merge. + * @return The merged result. + */ + public DrmInitData merge(DrmInitData drmInitData) { + Assertions.checkState( + schemeType == null + || drmInitData.schemeType == null + || TextUtils.equals(schemeType, drmInitData.schemeType)); + String mergedSchemeType = schemeType != null ? this.schemeType : drmInitData.schemeType; + SchemeData[] mergedSchemeDatas = + Util.nullSafeArrayConcatenation(schemeDatas, drmInitData.schemeDatas); + return new DrmInitData(mergedSchemeType, mergedSchemeDatas); + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = (schemeType == null ? 0 : schemeType.hashCode()); + result = 31 * result + Arrays.hashCode(schemeDatas); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + DrmInitData other = (DrmInitData) obj; + return Util.areEqual(schemeType, other.schemeType) + && Arrays.equals(schemeDatas, other.schemeDatas); + } + + @Override + public int compare(SchemeData first, SchemeData second) { + return C.UUID_NIL.equals(first.uuid) ? (C.UUID_NIL.equals(second.uuid) ? 0 : 1) + : first.uuid.compareTo(second.uuid); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(schemeType); + dest.writeTypedArray(schemeDatas, 0); + } + + public static final Parcelable.Creator<DrmInitData> CREATOR = + new Parcelable.Creator<DrmInitData>() { + + @Override + public DrmInitData createFromParcel(Parcel in) { + return new DrmInitData(in); + } + + @Override + public DrmInitData[] newArray(int size) { + return new DrmInitData[size]; + } + + }; + + // Internal methods. + + private static boolean containsSchemeDataWithUuid( + ArrayList<SchemeData> datas, int limit, UUID uuid) { + for (int i = 0; i < limit; i++) { + if (datas.get(i).uuid.equals(uuid)) { + return true; + } + } + return false; + } + + /** + * Scheme initialization data. + */ + public static final class SchemeData implements Parcelable { + + // Lazily initialized hashcode. + private int hashCode; + + /** + * The {@link UUID} of the DRM scheme, or {@link C#UUID_NIL} if the data is universal (i.e. + * applies to all schemes). + */ + private final UUID uuid; + /** The URL of the server to which license requests should be made. May be null if unknown. */ + @Nullable public final String licenseServerUrl; + /** The mimeType of {@link #data}. */ + public final String mimeType; + /** The initialization data. May be null for scheme support checks only. */ + @Nullable public final byte[] data; + + /** + * @param uuid The {@link UUID} of the DRM scheme, or {@link C#UUID_NIL} if the data is + * universal (i.e. applies to all schemes). + * @param mimeType See {@link #mimeType}. + * @param data See {@link #data}. + */ + public SchemeData(UUID uuid, String mimeType, @Nullable byte[] data) { + this(uuid, /* licenseServerUrl= */ null, mimeType, data); + } + + /** + * @param uuid The {@link UUID} of the DRM scheme, or {@link C#UUID_NIL} if the data is + * universal (i.e. applies to all schemes). + * @param licenseServerUrl See {@link #licenseServerUrl}. + * @param mimeType See {@link #mimeType}. + * @param data See {@link #data}. + */ + public SchemeData( + UUID uuid, @Nullable String licenseServerUrl, String mimeType, @Nullable byte[] data) { + this.uuid = Assertions.checkNotNull(uuid); + this.licenseServerUrl = licenseServerUrl; + this.mimeType = Assertions.checkNotNull(mimeType); + this.data = data; + } + + /* package */ SchemeData(Parcel in) { + uuid = new UUID(in.readLong(), in.readLong()); + licenseServerUrl = in.readString(); + mimeType = Util.castNonNull(in.readString()); + data = in.createByteArray(); + } + + /** + * Returns whether this initialization data applies to the specified scheme. + * + * @param schemeUuid The scheme {@link UUID}. + * @return Whether this initialization data applies to the specified scheme. + */ + public boolean matches(UUID schemeUuid) { + return C.UUID_NIL.equals(uuid) || schemeUuid.equals(uuid); + } + + /** + * Returns whether this {@link SchemeData} can be used to replace {@code other}. + * + * @param other A {@link SchemeData}. + * @return Whether this {@link SchemeData} can be used to replace {@code other}. + */ + public boolean canReplace(SchemeData other) { + return hasData() && !other.hasData() && matches(other.uuid); + } + + /** + * Returns whether {@link #data} is non-null. + */ + public boolean hasData() { + return data != null; + } + + /** + * Returns a copy of this instance with the specified data. + * + * @param data The data to include in the copy. + * @return The new instance. + */ + public SchemeData copyWithData(@Nullable byte[] data) { + return new SchemeData(uuid, licenseServerUrl, mimeType, data); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (!(obj instanceof SchemeData)) { + return false; + } + if (obj == this) { + return true; + } + SchemeData other = (SchemeData) obj; + return Util.areEqual(licenseServerUrl, other.licenseServerUrl) + && Util.areEqual(mimeType, other.mimeType) + && Util.areEqual(uuid, other.uuid) + && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = uuid.hashCode(); + result = 31 * result + (licenseServerUrl == null ? 0 : licenseServerUrl.hashCode()); + result = 31 * result + mimeType.hashCode(); + result = 31 * result + Arrays.hashCode(data); + hashCode = result; + } + return hashCode; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(uuid.getMostSignificantBits()); + dest.writeLong(uuid.getLeastSignificantBits()); + dest.writeString(licenseServerUrl); + dest.writeString(mimeType); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator<SchemeData> CREATOR = + new Parcelable.Creator<SchemeData>() { + + @Override + public SchemeData createFromParcel(Parcel in) { + return new SchemeData(in); + } + + @Override + public SchemeData[] newArray(int size) { + return new SchemeData[size]; + } + + }; + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmSession.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmSession.java new file mode 100644 index 0000000000..7a9af2684f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmSession.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.media.MediaDrm; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Map; + +/** + * A DRM session. + */ +public interface DrmSession<T extends ExoMediaCrypto> { + + /** + * Invokes {@code newSession's} {@link #acquire()} and {@code previousSession's} {@link + * #release()} in that order. Null arguments are ignored. Does nothing if {@code previousSession} + * and {@code newSession} are the same session. + */ + static <T extends ExoMediaCrypto> void replaceSession( + @Nullable DrmSession<T> previousSession, @Nullable DrmSession<T> newSession) { + if (previousSession == newSession) { + // Do nothing. + return; + } + if (newSession != null) { + newSession.acquire(); + } + if (previousSession != null) { + previousSession.release(); + } + } + + /** Wraps the throwable which is the cause of the error state. */ + class DrmSessionException extends IOException { + + public DrmSessionException(Throwable cause) { + super(cause); + } + + } + + /** + * The state of the DRM session. One of {@link #STATE_RELEASED}, {@link #STATE_ERROR}, {@link + * #STATE_OPENING}, {@link #STATE_OPENED} or {@link #STATE_OPENED_WITH_KEYS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_RELEASED, STATE_ERROR, STATE_OPENING, STATE_OPENED, STATE_OPENED_WITH_KEYS}) + @interface State {} + /** + * The session has been released. + */ + int STATE_RELEASED = 0; + /** + * The session has encountered an error. {@link #getError()} can be used to retrieve the cause. + */ + int STATE_ERROR = 1; + /** + * The session is being opened. + */ + int STATE_OPENING = 2; + /** The session is open, but does not have keys required for decryption. */ + int STATE_OPENED = 3; + /** The session is open and has keys required for decryption. */ + int STATE_OPENED_WITH_KEYS = 4; + + /** + * Returns the current state of the session, which is one of {@link #STATE_ERROR}, + * {@link #STATE_RELEASED}, {@link #STATE_OPENING}, {@link #STATE_OPENED} and + * {@link #STATE_OPENED_WITH_KEYS}. + */ + @State int getState(); + + /** Returns whether this session allows playback of clear samples prior to keys being loaded. */ + default boolean playClearSamplesWithoutKeys() { + return false; + } + + /** + * Returns the cause of the error state, or null if {@link #getState()} is not {@link + * #STATE_ERROR}. + */ + @Nullable + DrmSessionException getError(); + + /** + * Returns a {@link ExoMediaCrypto} for the open session, or null if called before the session has + * been opened or after it's been released. + */ + @Nullable + T getMediaCrypto(); + + /** + * Returns a map describing the key status for the session, or null if called before the session + * has been opened or after it's been released. + * + * <p>Since DRM license policies vary by vendor, the specific status field names are determined by + * each DRM vendor. Refer to your DRM provider documentation for definitions of the field names + * for a particular DRM engine plugin. + * + * @return A map describing the key status for the session, or null if called before the session + * has been opened or after it's been released. + * @see MediaDrm#queryKeyStatus(byte[]) + */ + @Nullable + Map<String, String> queryKeyStatus(); + + /** + * Returns the key set id of the offline license loaded into this session, or null if there isn't + * one. + */ + @Nullable + byte[] getOfflineLicenseKeySetId(); + + /** + * Increments the reference count. When the caller no longer needs to use the instance, it must + * call {@link #release()} to decrement the reference count. + */ + void acquire(); + + /** + * Decrements the reference count. If the reference count drops to 0 underlying resources are + * released, and the instance cannot be re-used. + */ + void release(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmSessionManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmSessionManager.java new file mode 100644 index 0000000000..bf98a0a658 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DrmSessionManager.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.os.Looper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; + +/** + * Manages a DRM session. + */ +public interface DrmSessionManager<T extends ExoMediaCrypto> { + + /** Returns {@link #DUMMY}. */ + @SuppressWarnings("unchecked") + static <T extends ExoMediaCrypto> DrmSessionManager<T> getDummyDrmSessionManager() { + return (DrmSessionManager<T>) DUMMY; + } + + /** {@link DrmSessionManager} that supports no DRM schemes. */ + DrmSessionManager<ExoMediaCrypto> DUMMY = + new DrmSessionManager<ExoMediaCrypto>() { + + @Override + public boolean canAcquireSession(DrmInitData drmInitData) { + return false; + } + + @Override + public DrmSession<ExoMediaCrypto> acquireSession( + Looper playbackLooper, DrmInitData drmInitData) { + return new ErrorStateDrmSession<>( + new DrmSession.DrmSessionException( + new UnsupportedDrmException(UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME))); + } + + @Override + @Nullable + public Class<ExoMediaCrypto> getExoMediaCryptoType(DrmInitData drmInitData) { + return null; + } + }; + + /** + * Acquires any required resources. + * + * <p>{@link #release()} must be called to ensure the acquired resources are released. After + * releasing, an instance may be re-prepared. + */ + default void prepare() { + // Do nothing. + } + + /** Releases any acquired resources. */ + default void release() { + // Do nothing. + } + + /** + * Returns whether the manager is capable of acquiring a session for the given + * {@link DrmInitData}. + * + * @param drmInitData DRM initialization data. + * @return Whether the manager is capable of acquiring a session for the given + * {@link DrmInitData}. + */ + boolean canAcquireSession(DrmInitData drmInitData); + + /** + * Returns a {@link DrmSession} that does not execute key requests, with an incremented reference + * count. When the caller no longer needs to use the instance, it must call {@link + * DrmSession#release()} to decrement the reference count. + * + * <p>Placeholder {@link DrmSession DrmSessions} may be used to configure secure decoders for + * playback of clear content periods. This can reduce the cost of transitioning between clear and + * encrypted content periods. + * + * @param playbackLooper The looper associated with the media playback thread. + * @param trackType The type of the track to acquire a placeholder session for. Must be one of the + * {@link C}{@code .TRACK_TYPE_*} constants. + * @return The placeholder DRM session, or null if this DRM session manager does not support + * placeholder sessions. + */ + @Nullable + default DrmSession<T> acquirePlaceholderSession(Looper playbackLooper, int trackType) { + return null; + } + + /** + * Returns a {@link DrmSession} for the specified {@link DrmInitData}, with an incremented + * reference count. When the caller no longer needs to use the instance, it must call {@link + * DrmSession#release()} to decrement the reference count. + * + * @param playbackLooper The looper associated with the media playback thread. + * @param drmInitData DRM initialization data. All contained {@link SchemeData}s must contain + * non-null {@link SchemeData#data}. + * @return The DRM session. + */ + DrmSession<T> acquireSession(Looper playbackLooper, DrmInitData drmInitData); + + /** + * Returns the {@link ExoMediaCrypto} type returned by sessions acquired using the given {@link + * DrmInitData}, or null if a session cannot be acquired with the given {@link DrmInitData}. + */ + @Nullable + Class<? extends ExoMediaCrypto> getExoMediaCryptoType(DrmInitData drmInitData); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java new file mode 100644 index 0000000000..b6a66ceac0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/DummyExoMediaDrm.java @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.media.MediaDrmException; +import android.os.PersistableBundle; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** An {@link ExoMediaDrm} that does not support any protection schemes. */ +@RequiresApi(18) +public final class DummyExoMediaDrm<T extends ExoMediaCrypto> implements ExoMediaDrm<T> { + + /** Returns a new instance. */ + @SuppressWarnings("unchecked") + public static <T extends ExoMediaCrypto> DummyExoMediaDrm<T> getInstance() { + return (DummyExoMediaDrm<T>) new DummyExoMediaDrm<>(); + } + + @Override + public void setOnEventListener(OnEventListener<? super T> listener) { + // Do nothing. + } + + @Override + public void setOnKeyStatusChangeListener(OnKeyStatusChangeListener<? super T> listener) { + // Do nothing. + } + + @Override + public byte[] openSession() throws MediaDrmException { + throw new MediaDrmException("Attempting to open a session using a dummy ExoMediaDrm."); + } + + @Override + public void closeSession(byte[] sessionId) { + // Do nothing. + } + + @Override + public KeyRequest getKeyRequest( + byte[] scope, + @Nullable List<DrmInitData.SchemeData> schemeDatas, + int keyType, + @Nullable HashMap<String, String> optionalParameters) { + // Should not be invoked. No session should exist. + throw new IllegalStateException(); + } + + @Nullable + @Override + public byte[] provideKeyResponse(byte[] scope, byte[] response) { + // Should not be invoked. No session should exist. + throw new IllegalStateException(); + } + + @Override + public ProvisionRequest getProvisionRequest() { + // Should not be invoked. No provision should be required. + throw new IllegalStateException(); + } + + @Override + public void provideProvisionResponse(byte[] response) { + // Should not be invoked. No provision should be required. + throw new IllegalStateException(); + } + + @Override + public Map<String, String> queryKeyStatus(byte[] sessionId) { + // Should not be invoked. No session should exist. + throw new IllegalStateException(); + } + + @Override + public void acquire() { + // Do nothing. + } + + @Override + public void release() { + // Do nothing. + } + + @Override + public void restoreKeys(byte[] sessionId, byte[] keySetId) { + // Should not be invoked. No session should exist. + throw new IllegalStateException(); + } + + @Override + @Nullable + public PersistableBundle getMetrics() { + return null; + } + + @Override + public String getPropertyString(String propertyName) { + return ""; + } + + @Override + public byte[] getPropertyByteArray(String propertyName) { + return Util.EMPTY_BYTE_ARRAY; + } + + @Override + public void setPropertyString(String propertyName, String value) { + // Do nothing. + } + + @Override + public void setPropertyByteArray(String propertyName, byte[] value) { + // Do nothing. + } + + @Override + public T createMediaCrypto(byte[] sessionId) { + // Should not be invoked. No session should exist. + throw new IllegalStateException(); + } + + @Override + @Nullable + public Class<T> getExoMediaCryptoType() { + // No ExoMediaCrypto type is supported. + return null; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java new file mode 100644 index 0000000000..97d0ecaaa4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ErrorStateDrmSession.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Map; + +/** A {@link DrmSession} that's in a terminal error state. */ +public final class ErrorStateDrmSession<T extends ExoMediaCrypto> implements DrmSession<T> { + + private final DrmSessionException error; + + public ErrorStateDrmSession(DrmSessionException error) { + this.error = Assertions.checkNotNull(error); + } + + @Override + public int getState() { + return STATE_ERROR; + } + + @Override + public boolean playClearSamplesWithoutKeys() { + return false; + } + + @Override + @Nullable + public DrmSessionException getError() { + return error; + } + + @Override + @Nullable + public T getMediaCrypto() { + return null; + } + + @Override + @Nullable + public Map<String, String> queryKeyStatus() { + return null; + } + + @Override + @Nullable + public byte[] getOfflineLicenseKeySetId() { + return null; + } + + @Override + public void acquire() { + // Do nothing. + } + + @Override + public void release() { + // Do nothing. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ExoMediaCrypto.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ExoMediaCrypto.java new file mode 100644 index 0000000000..a12b212799 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ExoMediaCrypto.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +/** An opaque {@link android.media.MediaCrypto} equivalent. */ +public interface ExoMediaCrypto {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ExoMediaDrm.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ExoMediaDrm.java new file mode 100644 index 0000000000..1e851a7c0b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/ExoMediaDrm.java @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.media.DeniedByServerException; +import android.media.MediaCryptoException; +import android.media.MediaDrm; +import android.media.MediaDrmException; +import android.media.NotProvisionedException; +import android.os.Handler; +import android.os.PersistableBundle; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Used to obtain keys for decrypting protected media streams. See {@link android.media.MediaDrm}. + * + * <h3>Reference counting</h3> + * + * <p>Access to an instance is managed by reference counting, where {@link #acquire()} increments + * the reference count and {@link #release()} decrements it. When the reference count drops to 0 + * underlying resources are released, and the instance cannot be re-used. + * + * <p>Each new instance has an initial reference count of 1. Hence application code that creates a + * new instance does not normally need to call {@link #acquire()}, and must call {@link #release()} + * when the instance is no longer required. + */ +public interface ExoMediaDrm<T extends ExoMediaCrypto> { + + /** {@link ExoMediaDrm} instances provider. */ + interface Provider<T extends ExoMediaCrypto> { + + /** + * Returns an {@link ExoMediaDrm} instance with an incremented reference count. When the caller + * no longer needs to use the instance, it must call {@link ExoMediaDrm#release()} to decrement + * the reference count. + */ + ExoMediaDrm<T> acquireExoMediaDrm(UUID uuid); + } + + /** + * Provides an {@link ExoMediaDrm} instance owned by the app. + * + * <p>Note that when using this provider the app will have instantiated the {@link ExoMediaDrm} + * instance, and remains responsible for calling {@link ExoMediaDrm#release()} on the instance + * when it's no longer being used. + */ + final class AppManagedProvider<T extends ExoMediaCrypto> implements Provider<T> { + + private final ExoMediaDrm<T> exoMediaDrm; + + /** Creates an instance that provides the given {@link ExoMediaDrm}. */ + public AppManagedProvider(ExoMediaDrm<T> exoMediaDrm) { + this.exoMediaDrm = exoMediaDrm; + } + + @Override + public ExoMediaDrm<T> acquireExoMediaDrm(UUID uuid) { + exoMediaDrm.acquire(); + return exoMediaDrm; + } + } + + /** @see MediaDrm#EVENT_KEY_REQUIRED */ + @SuppressWarnings("InlinedApi") + int EVENT_KEY_REQUIRED = MediaDrm.EVENT_KEY_REQUIRED; + /** + * @see MediaDrm#EVENT_KEY_EXPIRED + */ + @SuppressWarnings("InlinedApi") + int EVENT_KEY_EXPIRED = MediaDrm.EVENT_KEY_EXPIRED; + /** + * @see MediaDrm#EVENT_PROVISION_REQUIRED + */ + @SuppressWarnings("InlinedApi") + int EVENT_PROVISION_REQUIRED = MediaDrm.EVENT_PROVISION_REQUIRED; + + /** + * @see MediaDrm#KEY_TYPE_STREAMING + */ + @SuppressWarnings("InlinedApi") + int KEY_TYPE_STREAMING = MediaDrm.KEY_TYPE_STREAMING; + /** + * @see MediaDrm#KEY_TYPE_OFFLINE + */ + @SuppressWarnings("InlinedApi") + int KEY_TYPE_OFFLINE = MediaDrm.KEY_TYPE_OFFLINE; + /** + * @see MediaDrm#KEY_TYPE_RELEASE + */ + @SuppressWarnings("InlinedApi") + int KEY_TYPE_RELEASE = MediaDrm.KEY_TYPE_RELEASE; + + /** + * @see android.media.MediaDrm.OnEventListener + */ + interface OnEventListener<T extends ExoMediaCrypto> { + /** + * Called when an event occurs that requires the app to be notified + * + * @param mediaDrm The {@link ExoMediaDrm} object on which the event occurred. + * @param sessionId The DRM session ID on which the event occurred. + * @param event Indicates the event type. + * @param extra A secondary error code. + * @param data Optional byte array of data that may be associated with the event. + */ + void onEvent( + ExoMediaDrm<? extends T> mediaDrm, + @Nullable byte[] sessionId, + int event, + int extra, + @Nullable byte[] data); + } + + /** + * @see android.media.MediaDrm.OnKeyStatusChangeListener + */ + interface OnKeyStatusChangeListener<T extends ExoMediaCrypto> { + /** + * Called when the keys in a session change status, such as when the license is renewed or + * expires. + * + * @param mediaDrm The {@link ExoMediaDrm} object on which the event occurred. + * @param sessionId The DRM session ID on which the event occurred. + * @param exoKeyInformation A list of {@link KeyStatus} that contains key ID and status. + * @param hasNewUsableKey Whether a new key became usable. + */ + void onKeyStatusChange( + ExoMediaDrm<? extends T> mediaDrm, + byte[] sessionId, + List<KeyStatus> exoKeyInformation, + boolean hasNewUsableKey); + } + + /** @see android.media.MediaDrm.KeyStatus */ + final class KeyStatus { + + private final int statusCode; + private final byte[] keyId; + + public KeyStatus(int statusCode, byte[] keyId) { + this.statusCode = statusCode; + this.keyId = keyId; + } + + public int getStatusCode() { + return statusCode; + } + + public byte[] getKeyId() { + return keyId; + } + + } + + /** @see android.media.MediaDrm.KeyRequest */ + final class KeyRequest { + + private final byte[] data; + private final String licenseServerUrl; + + public KeyRequest(byte[] data, String licenseServerUrl) { + this.data = data; + this.licenseServerUrl = licenseServerUrl; + } + + public byte[] getData() { + return data; + } + + public String getLicenseServerUrl() { + return licenseServerUrl; + } + + } + + /** @see android.media.MediaDrm.ProvisionRequest */ + final class ProvisionRequest { + + private final byte[] data; + private final String defaultUrl; + + public ProvisionRequest(byte[] data, String defaultUrl) { + this.data = data; + this.defaultUrl = defaultUrl; + } + + public byte[] getData() { + return data; + } + + public String getDefaultUrl() { + return defaultUrl; + } + + } + + /** + * @see MediaDrm#setOnEventListener(MediaDrm.OnEventListener) + */ + void setOnEventListener(OnEventListener<? super T> listener); + + /** + * @see MediaDrm#setOnKeyStatusChangeListener(MediaDrm.OnKeyStatusChangeListener, Handler) + */ + void setOnKeyStatusChangeListener(OnKeyStatusChangeListener<? super T> listener); + + /** + * @see MediaDrm#openSession() + */ + byte[] openSession() throws MediaDrmException; + + /** + * @see MediaDrm#closeSession(byte[]) + */ + void closeSession(byte[] sessionId); + + /** + * Generates a key request. + * + * @param scope If {@code keyType} is {@link #KEY_TYPE_STREAMING} or {@link #KEY_TYPE_OFFLINE}, + * the session id that the keys will be provided to. If {@code keyType} is {@link + * #KEY_TYPE_RELEASE}, the keySetId of the keys to release. + * @param schemeDatas If key type is {@link #KEY_TYPE_STREAMING} or {@link #KEY_TYPE_OFFLINE}, a + * list of {@link SchemeData} instances extracted from the media. Null otherwise. + * @param keyType The type of the request. Either {@link #KEY_TYPE_STREAMING} to acquire keys for + * streaming, {@link #KEY_TYPE_OFFLINE} to acquire keys for offline usage, or {@link + * #KEY_TYPE_RELEASE} to release acquired keys. Releasing keys invalidates them for all + * sessions. + * @param optionalParameters Are included in the key request message to allow a client application + * to provide additional message parameters to the server. This may be {@code null} if no + * additional parameters are to be sent. + * @return The generated key request. + * @see MediaDrm#getKeyRequest(byte[], byte[], String, int, HashMap) + */ + KeyRequest getKeyRequest( + byte[] scope, + @Nullable List<SchemeData> schemeDatas, + int keyType, + @Nullable HashMap<String, String> optionalParameters) + throws NotProvisionedException; + + /** @see MediaDrm#provideKeyResponse(byte[], byte[]) */ + @Nullable + byte[] provideKeyResponse(byte[] scope, byte[] response) + throws NotProvisionedException, DeniedByServerException; + + /** + * @see MediaDrm#getProvisionRequest() + */ + ProvisionRequest getProvisionRequest(); + + /** + * @see MediaDrm#provideProvisionResponse(byte[]) + */ + void provideProvisionResponse(byte[] response) throws DeniedByServerException; + + /** + * @see MediaDrm#queryKeyStatus(byte[]) + */ + Map<String, String> queryKeyStatus(byte[] sessionId); + + /** + * Increments the reference count. When the caller no longer needs to use the instance, it must + * call {@link #release()} to decrement the reference count. + * + * <p>A new instance will have an initial reference count of 1, and therefore it is not normally + * necessary for application code to call this method. + */ + void acquire(); + + /** + * Decrements the reference count. If the reference count drops to 0 underlying resources are + * released, and the instance cannot be re-used. + */ + void release(); + + /** + * @see MediaDrm#restoreKeys(byte[], byte[]) + */ + void restoreKeys(byte[] sessionId, byte[] keySetId); + + /** + * Returns drm metrics. May be null if unavailable. + * + * @see MediaDrm#getMetrics() + */ + @Nullable + PersistableBundle getMetrics(); + + /** + * @see MediaDrm#getPropertyString(String) + */ + String getPropertyString(String propertyName); + + /** + * @see MediaDrm#getPropertyByteArray(String) + */ + byte[] getPropertyByteArray(String propertyName); + + /** + * @see MediaDrm#setPropertyString(String, String) + */ + void setPropertyString(String propertyName, String value); + + /** + * @see MediaDrm#setPropertyByteArray(String, byte[]) + */ + void setPropertyByteArray(String propertyName, byte[] value); + + /** + * @see android.media.MediaCrypto#MediaCrypto(UUID, byte[]) + * @param sessionId The DRM session ID. + * @return An object extends {@link ExoMediaCrypto}, using opaque crypto scheme specific data. + * @throws MediaCryptoException If the instance can't be created. + */ + T createMediaCrypto(byte[] sessionId) throws MediaCryptoException; + + /** + * Returns the {@link ExoMediaCrypto} type created by {@link #createMediaCrypto(byte[])}, or null + * if this instance cannot create any {@link ExoMediaCrypto} instances. + */ + @Nullable + Class<T> getExoMediaCryptoType(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java new file mode 100644 index 0000000000..bb3a9b272b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/FrameworkMediaCrypto.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.media.MediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.UUID; + +/** + * An {@link ExoMediaCrypto} implementation that contains the necessary information to build or + * update a framework {@link MediaCrypto}. + */ +public final class FrameworkMediaCrypto implements ExoMediaCrypto { + + /** + * Whether the device needs keys to have been loaded into the {@link DrmSession} before codec + * configuration. + */ + public static final boolean WORKAROUND_DEVICE_NEEDS_KEYS_TO_CONFIGURE_CODEC = + "Amazon".equals(Util.MANUFACTURER) + && ("AFTM".equals(Util.MODEL) // Fire TV Stick Gen 1 + || "AFTB".equals(Util.MODEL)); // Fire TV Gen 1 + + /** The DRM scheme UUID. */ + public final UUID uuid; + /** The DRM session id. */ + public final byte[] sessionId; + /** + * Whether to allow use of insecure decoder components even if the underlying platform says + * otherwise. + */ + public final boolean forceAllowInsecureDecoderComponents; + + /** + * @param uuid The DRM scheme UUID. + * @param sessionId The DRM session id. + * @param forceAllowInsecureDecoderComponents Whether to allow use of insecure decoder components + * even if the underlying platform says otherwise. + */ + public FrameworkMediaCrypto( + UUID uuid, byte[] sessionId, boolean forceAllowInsecureDecoderComponents) { + this.uuid = uuid; + this.sessionId = sessionId; + this.forceAllowInsecureDecoderComponents = forceAllowInsecureDecoderComponents; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java new file mode 100644 index 0000000000..10ca857448 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/FrameworkMediaDrm.java @@ -0,0 +1,440 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.DeniedByServerException; +import android.media.MediaCryptoException; +import android.media.MediaDrm; +import android.media.MediaDrmException; +import android.media.NotProvisionedException; +import android.media.UnsupportedSchemeException; +import android.os.PersistableBundle; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** An {@link ExoMediaDrm} implementation that wraps the framework {@link MediaDrm}. */ +@TargetApi(23) +@RequiresApi(18) +public final class FrameworkMediaDrm implements ExoMediaDrm<FrameworkMediaCrypto> { + + private static final String TAG = "FrameworkMediaDrm"; + + /** + * {@link ExoMediaDrm.Provider} that returns a new {@link FrameworkMediaDrm} for the requested + * UUID. Returns a {@link DummyExoMediaDrm} if the protection scheme identified by the given UUID + * is not supported by the device. + */ + public static final Provider<FrameworkMediaCrypto> DEFAULT_PROVIDER = + uuid -> { + try { + return newInstance(uuid); + } catch (UnsupportedDrmException e) { + Log.e(TAG, "Failed to instantiate a FrameworkMediaDrm for uuid: " + uuid + "."); + return new DummyExoMediaDrm<>(); + } + }; + + private static final String CENC_SCHEME_MIME_TYPE = "cenc"; + private static final String MOCK_LA_URL_VALUE = "https://x"; + private static final String MOCK_LA_URL = "<LA_URL>" + MOCK_LA_URL_VALUE + "</LA_URL>"; + private static final int UTF_16_BYTES_PER_CHARACTER = 2; + + private final UUID uuid; + private final MediaDrm mediaDrm; + private int referenceCount; + + /** + * Creates an instance with an initial reference count of 1. {@link #release()} must be called on + * the instance when it's no longer required. + * + * @param uuid The scheme uuid. + * @return The created instance. + * @throws UnsupportedDrmException If the DRM scheme is unsupported or cannot be instantiated. + */ + public static FrameworkMediaDrm newInstance(UUID uuid) throws UnsupportedDrmException { + try { + return new FrameworkMediaDrm(uuid); + } catch (UnsupportedSchemeException e) { + throw new UnsupportedDrmException(UnsupportedDrmException.REASON_UNSUPPORTED_SCHEME, e); + } catch (Exception e) { + throw new UnsupportedDrmException(UnsupportedDrmException.REASON_INSTANTIATION_ERROR, e); + } + } + + private FrameworkMediaDrm(UUID uuid) throws UnsupportedSchemeException { + Assertions.checkNotNull(uuid); + Assertions.checkArgument(!C.COMMON_PSSH_UUID.equals(uuid), "Use C.CLEARKEY_UUID instead"); + this.uuid = uuid; + this.mediaDrm = new MediaDrm(adjustUuid(uuid)); + // Creators of an instance automatically acquire ownership of the created instance. + referenceCount = 1; + if (C.WIDEVINE_UUID.equals(uuid) && needsForceWidevineL3Workaround()) { + forceWidevineL3(mediaDrm); + } + } + + @Override + public void setOnEventListener( + final ExoMediaDrm.OnEventListener<? super FrameworkMediaCrypto> listener) { + mediaDrm.setOnEventListener( + listener == null + ? null + : (mediaDrm, sessionId, event, extra, data) -> + listener.onEvent(FrameworkMediaDrm.this, sessionId, event, extra, data)); + } + + @Override + public void setOnKeyStatusChangeListener( + final ExoMediaDrm.OnKeyStatusChangeListener<? super FrameworkMediaCrypto> listener) { + if (Util.SDK_INT < 23) { + throw new UnsupportedOperationException(); + } + + mediaDrm.setOnKeyStatusChangeListener( + listener == null + ? null + : (mediaDrm, sessionId, keyInfo, hasNewUsableKey) -> { + List<KeyStatus> exoKeyInfo = new ArrayList<>(); + for (MediaDrm.KeyStatus keyStatus : keyInfo) { + exoKeyInfo.add(new KeyStatus(keyStatus.getStatusCode(), keyStatus.getKeyId())); + } + listener.onKeyStatusChange( + FrameworkMediaDrm.this, sessionId, exoKeyInfo, hasNewUsableKey); + }, + null); + } + + @Override + public byte[] openSession() throws MediaDrmException { + return mediaDrm.openSession(); + } + + @Override + public void closeSession(byte[] sessionId) { + mediaDrm.closeSession(sessionId); + } + + @Override + public KeyRequest getKeyRequest( + byte[] scope, + @Nullable List<DrmInitData.SchemeData> schemeDatas, + int keyType, + @Nullable HashMap<String, String> optionalParameters) + throws NotProvisionedException { + SchemeData schemeData = null; + byte[] initData = null; + String mimeType = null; + if (schemeDatas != null) { + schemeData = getSchemeData(uuid, schemeDatas); + initData = adjustRequestInitData(uuid, Assertions.checkNotNull(schemeData.data)); + mimeType = adjustRequestMimeType(uuid, schemeData.mimeType); + } + MediaDrm.KeyRequest request = + mediaDrm.getKeyRequest(scope, initData, mimeType, keyType, optionalParameters); + + byte[] requestData = adjustRequestData(uuid, request.getData()); + + String licenseServerUrl = request.getDefaultUrl(); + if (MOCK_LA_URL_VALUE.equals(licenseServerUrl)) { + licenseServerUrl = ""; + } + if (TextUtils.isEmpty(licenseServerUrl) + && schemeData != null + && !TextUtils.isEmpty(schemeData.licenseServerUrl)) { + licenseServerUrl = schemeData.licenseServerUrl; + } + + return new KeyRequest(requestData, licenseServerUrl); + } + + @Nullable + @Override + public byte[] provideKeyResponse(byte[] scope, byte[] response) + throws NotProvisionedException, DeniedByServerException { + if (C.CLEARKEY_UUID.equals(uuid)) { + response = ClearKeyUtil.adjustResponseData(response); + } + + return mediaDrm.provideKeyResponse(scope, response); + } + + @Override + public ProvisionRequest getProvisionRequest() { + final MediaDrm.ProvisionRequest request = mediaDrm.getProvisionRequest(); + return new ProvisionRequest(request.getData(), request.getDefaultUrl()); + } + + @Override + public void provideProvisionResponse(byte[] response) throws DeniedByServerException { + mediaDrm.provideProvisionResponse(response); + } + + @Override + public Map<String, String> queryKeyStatus(byte[] sessionId) { + return mediaDrm.queryKeyStatus(sessionId); + } + + @Override + public synchronized void acquire() { + Assertions.checkState(referenceCount > 0); + referenceCount++; + } + + @Override + public synchronized void release() { + if (--referenceCount == 0) { + mediaDrm.release(); + } + } + + @Override + public void restoreKeys(byte[] sessionId, byte[] keySetId) { + mediaDrm.restoreKeys(sessionId, keySetId); + } + + @Override + @Nullable + @TargetApi(28) + public PersistableBundle getMetrics() { + if (Util.SDK_INT < 28) { + return null; + } + return mediaDrm.getMetrics(); + } + + @Override + public String getPropertyString(String propertyName) { + return mediaDrm.getPropertyString(propertyName); + } + + @Override + public byte[] getPropertyByteArray(String propertyName) { + return mediaDrm.getPropertyByteArray(propertyName); + } + + @Override + public void setPropertyString(String propertyName, String value) { + mediaDrm.setPropertyString(propertyName, value); + } + + @Override + public void setPropertyByteArray(String propertyName, byte[] value) { + mediaDrm.setPropertyByteArray(propertyName, value); + } + + @Override + public FrameworkMediaCrypto createMediaCrypto(byte[] initData) throws MediaCryptoException { + // Work around a bug prior to Lollipop where L1 Widevine forced into L3 mode would still + // indicate that it required secure video decoders [Internal ref: b/11428937]. + boolean forceAllowInsecureDecoderComponents = Util.SDK_INT < 21 + && C.WIDEVINE_UUID.equals(uuid) && "L3".equals(getPropertyString("securityLevel")); + return new FrameworkMediaCrypto( + adjustUuid(uuid), initData, forceAllowInsecureDecoderComponents); + } + + @Override + public Class<FrameworkMediaCrypto> getExoMediaCryptoType() { + return FrameworkMediaCrypto.class; + } + + private static SchemeData getSchemeData(UUID uuid, List<SchemeData> schemeDatas) { + if (!C.WIDEVINE_UUID.equals(uuid)) { + // For non-Widevine CDMs always use the first scheme data. + return schemeDatas.get(0); + } + + if (Util.SDK_INT >= 28 && schemeDatas.size() > 1) { + // For API level 28 and above, concatenate multiple PSSH scheme datas if possible. + SchemeData firstSchemeData = schemeDatas.get(0); + int concatenatedDataLength = 0; + boolean canConcatenateData = true; + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + byte[] schemeDataData = Util.castNonNull(schemeData.data); + if (Util.areEqual(schemeData.mimeType, firstSchemeData.mimeType) + && Util.areEqual(schemeData.licenseServerUrl, firstSchemeData.licenseServerUrl) + && PsshAtomUtil.isPsshAtom(schemeDataData)) { + concatenatedDataLength += schemeDataData.length; + } else { + canConcatenateData = false; + break; + } + } + if (canConcatenateData) { + byte[] concatenatedData = new byte[concatenatedDataLength]; + int concatenatedDataPosition = 0; + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + byte[] schemeDataData = Util.castNonNull(schemeData.data); + int schemeDataLength = schemeDataData.length; + System.arraycopy( + schemeDataData, 0, concatenatedData, concatenatedDataPosition, schemeDataLength); + concatenatedDataPosition += schemeDataLength; + } + return firstSchemeData.copyWithData(concatenatedData); + } + } + + // For API levels 23 - 27, prefer the first V1 PSSH box. For API levels 22 and earlier, prefer + // the first V0 box. + for (int i = 0; i < schemeDatas.size(); i++) { + SchemeData schemeData = schemeDatas.get(i); + int version = PsshAtomUtil.parseVersion(Util.castNonNull(schemeData.data)); + if (Util.SDK_INT < 23 && version == 0) { + return schemeData; + } else if (Util.SDK_INT >= 23 && version == 1) { + return schemeData; + } + } + + // If all else fails, use the first scheme data. + return schemeDatas.get(0); + } + + private static UUID adjustUuid(UUID uuid) { + // ClearKey had to be accessed using the Common PSSH UUID prior to API level 27. + return Util.SDK_INT < 27 && C.CLEARKEY_UUID.equals(uuid) ? C.COMMON_PSSH_UUID : uuid; + } + + private static byte[] adjustRequestInitData(UUID uuid, byte[] initData) { + // TODO: Add API level check once [Internal ref: b/112142048] is fixed. + if (C.PLAYREADY_UUID.equals(uuid)) { + byte[] schemeSpecificData = PsshAtomUtil.parseSchemeSpecificData(initData, uuid); + if (schemeSpecificData == null) { + // The init data is not contained in a pssh box. + schemeSpecificData = initData; + } + initData = + PsshAtomUtil.buildPsshAtom( + C.PLAYREADY_UUID, addLaUrlAttributeIfMissing(schemeSpecificData)); + } + + // Prior to API level 21, the Widevine CDM required scheme specific data to be extracted from + // the PSSH atom. We also extract the data on API levels 21 and 22 because these API levels + // don't handle V1 PSSH atoms, but do handle scheme specific data regardless of whether it's + // extracted from a V0 or a V1 PSSH atom. Hence extracting the data allows us to support content + // that only provides V1 PSSH atoms. API levels 23 and above understand V0 and V1 PSSH atoms, + // and so we do not extract the data. + // Some Amazon devices also require data to be extracted from the PSSH atom for PlayReady. + if ((Util.SDK_INT < 23 && C.WIDEVINE_UUID.equals(uuid)) + || (C.PLAYREADY_UUID.equals(uuid) + && "Amazon".equals(Util.MANUFACTURER) + && ("AFTB".equals(Util.MODEL) // Fire TV Gen 1 + || "AFTS".equals(Util.MODEL) // Fire TV Gen 2 + || "AFTM".equals(Util.MODEL) // Fire TV Stick Gen 1 + || "AFTT".equals(Util.MODEL)))) { // Fire TV Stick Gen 2 + byte[] psshData = PsshAtomUtil.parseSchemeSpecificData(initData, uuid); + if (psshData != null) { + // Extraction succeeded, so return the extracted data. + return psshData; + } + } + return initData; + } + + private static String adjustRequestMimeType(UUID uuid, String mimeType) { + // Prior to API level 26 the ClearKey CDM only accepted "cenc" as the scheme for MP4. + if (Util.SDK_INT < 26 + && C.CLEARKEY_UUID.equals(uuid) + && (MimeTypes.VIDEO_MP4.equals(mimeType) || MimeTypes.AUDIO_MP4.equals(mimeType))) { + return CENC_SCHEME_MIME_TYPE; + } + return mimeType; + } + + private static byte[] adjustRequestData(UUID uuid, byte[] requestData) { + if (C.CLEARKEY_UUID.equals(uuid)) { + return ClearKeyUtil.adjustRequestData(requestData); + } + return requestData; + } + + @SuppressLint("WrongConstant") // Suppress spurious lint error [Internal ref: b/32137960] + private static void forceWidevineL3(MediaDrm mediaDrm) { + mediaDrm.setPropertyString("securityLevel", "L3"); + } + + /** + * Returns whether the device codec is known to fail if security level L1 is used. + * + * <p>See <a href="https://github.com/google/ExoPlayer/issues/4413">GitHub issue #4413</a>. + */ + private static boolean needsForceWidevineL3Workaround() { + return "ASUS_Z00AD".equals(Util.MODEL); + } + + /** + * If the LA_URL tag is missing, injects a mock LA_URL value to avoid causing the CDM to throw + * when creating the key request. The LA_URL attribute is optional but some Android PlayReady + * implementations are known to require it. Does nothing it the provided {@code data} already + * contains an LA_URL value. + */ + private static byte[] addLaUrlAttributeIfMissing(byte[] data) { + ParsableByteArray byteArray = new ParsableByteArray(data); + // See https://docs.microsoft.com/en-us/playready/specifications/specifications for more + // information about the init data format. + int length = byteArray.readLittleEndianInt(); + int objectRecordCount = byteArray.readLittleEndianShort(); + int recordType = byteArray.readLittleEndianShort(); + if (objectRecordCount != 1 || recordType != 1) { + Log.i(TAG, "Unexpected record count or type. Skipping LA_URL workaround."); + return data; + } + int recordLength = byteArray.readLittleEndianShort(); + String xml = byteArray.readString(recordLength, Charset.forName(C.UTF16LE_NAME)); + if (xml.contains("<LA_URL>")) { + // LA_URL already present. Do nothing. + return data; + } + // This PlayReady object record does not include an LA_URL. We add a mock value for it. + int endOfDataTagIndex = xml.indexOf("</DATA>"); + if (endOfDataTagIndex == -1) { + Log.w(TAG, "Could not find the </DATA> tag. Skipping LA_URL workaround."); + } + String xmlWithMockLaUrl = + xml.substring(/* beginIndex= */ 0, /* endIndex= */ endOfDataTagIndex) + + MOCK_LA_URL + + xml.substring(/* beginIndex= */ endOfDataTagIndex); + int extraBytes = MOCK_LA_URL.length() * UTF_16_BYTES_PER_CHARACTER; + ByteBuffer newData = ByteBuffer.allocate(length + extraBytes); + newData.order(ByteOrder.LITTLE_ENDIAN); + newData.putInt(length + extraBytes); + newData.putShort((short) objectRecordCount); + newData.putShort((short) recordType); + newData.putShort((short) (xmlWithMockLaUrl.length() * UTF_16_BYTES_PER_CHARACTER)); + newData.put(xmlWithMockLaUrl.getBytes(Charset.forName(C.UTF16LE_NAME))); + return newData.array(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java new file mode 100644 index 0000000000..baa5bf0916 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/HttpMediaDrmCallback.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.annotation.TargetApi; +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceInputStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * A {@link MediaDrmCallback} that makes requests using {@link HttpDataSource} instances. + */ +@TargetApi(18) +public final class HttpMediaDrmCallback implements MediaDrmCallback { + + private static final int MAX_MANUAL_REDIRECTS = 5; + + private final HttpDataSource.Factory dataSourceFactory; + private final String defaultLicenseUrl; + private final boolean forceDefaultLicenseUrl; + private final Map<String, String> keyRequestProperties; + + /** + * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify + * their own license URL. + * @param dataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. + */ + public HttpMediaDrmCallback(String defaultLicenseUrl, HttpDataSource.Factory dataSourceFactory) { + this(defaultLicenseUrl, false, dataSourceFactory); + } + + /** + * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify + * their own license URL, or for all key requests if {@code forceDefaultLicenseUrl} is + * set to true. + * @param forceDefaultLicenseUrl Whether to use {@code defaultLicenseUrl} for key requests that + * include their own license URL. + * @param dataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. + */ + public HttpMediaDrmCallback(String defaultLicenseUrl, boolean forceDefaultLicenseUrl, + HttpDataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; + this.defaultLicenseUrl = defaultLicenseUrl; + this.forceDefaultLicenseUrl = forceDefaultLicenseUrl; + this.keyRequestProperties = new HashMap<>(); + } + + /** + * Sets a header for key requests made by the callback. + * + * @param name The name of the header field. + * @param value The value of the field. + */ + public void setKeyRequestProperty(String name, String value) { + Assertions.checkNotNull(name); + Assertions.checkNotNull(value); + synchronized (keyRequestProperties) { + keyRequestProperties.put(name, value); + } + } + + /** + * Clears a header for key requests made by the callback. + * + * @param name The name of the header field. + */ + public void clearKeyRequestProperty(String name) { + Assertions.checkNotNull(name); + synchronized (keyRequestProperties) { + keyRequestProperties.remove(name); + } + } + + /** + * Clears all headers for key requests made by the callback. + */ + public void clearAllKeyRequestProperties() { + synchronized (keyRequestProperties) { + keyRequestProperties.clear(); + } + } + + @Override + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws IOException { + String url = + request.getDefaultUrl() + "&signedRequest=" + Util.fromUtf8Bytes(request.getData()); + return executePost(dataSourceFactory, url, /* httpBody= */ null, /* requestProperties= */ null); + } + + @Override + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception { + String url = request.getLicenseServerUrl(); + if (forceDefaultLicenseUrl || TextUtils.isEmpty(url)) { + url = defaultLicenseUrl; + } + Map<String, String> requestProperties = new HashMap<>(); + // Add standard request properties for supported schemes. + String contentType = C.PLAYREADY_UUID.equals(uuid) ? "text/xml" + : (C.CLEARKEY_UUID.equals(uuid) ? "application/json" : "application/octet-stream"); + requestProperties.put("Content-Type", contentType); + if (C.PLAYREADY_UUID.equals(uuid)) { + requestProperties.put("SOAPAction", + "http://schemas.microsoft.com/DRM/2007/03/protocols/AcquireLicense"); + } + // Add additional request properties. + synchronized (keyRequestProperties) { + requestProperties.putAll(keyRequestProperties); + } + return executePost(dataSourceFactory, url, request.getData(), requestProperties); + } + + private static byte[] executePost( + HttpDataSource.Factory dataSourceFactory, + String url, + @Nullable byte[] httpBody, + @Nullable Map<String, String> requestProperties) + throws IOException { + HttpDataSource dataSource = dataSourceFactory.createDataSource(); + if (requestProperties != null) { + for (Map.Entry<String, String> requestProperty : requestProperties.entrySet()) { + dataSource.setRequestProperty(requestProperty.getKey(), requestProperty.getValue()); + } + } + + int manualRedirectCount = 0; + while (true) { + DataSpec dataSpec = + new DataSpec( + Uri.parse(url), + DataSpec.HTTP_METHOD_POST, + httpBody, + /* absoluteStreamPosition= */ 0, + /* position= */ 0, + /* length= */ C.LENGTH_UNSET, + /* key= */ null, + DataSpec.FLAG_ALLOW_GZIP); + DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + try { + return Util.toByteArray(inputStream); + } catch (InvalidResponseCodeException e) { + // For POST requests, the underlying network stack will not normally follow 307 or 308 + // redirects automatically. Do so manually here. + boolean manuallyRedirect = + (e.responseCode == 307 || e.responseCode == 308) + && manualRedirectCount++ < MAX_MANUAL_REDIRECTS; + String redirectUrl = manuallyRedirect ? getRedirectUrl(e) : null; + if (redirectUrl == null) { + throw e; + } + url = redirectUrl; + } finally { + Util.closeQuietly(inputStream); + } + } + } + + private static @Nullable String getRedirectUrl(InvalidResponseCodeException exception) { + Map<String, List<String>> headerFields = exception.headerFields; + if (headerFields != null) { + List<String> locationHeaders = headerFields.get("Location"); + if (locationHeaders != null && !locationHeaders.isEmpty()) { + return locationHeaders.get(0); + } + } + return null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/KeysExpiredException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/KeysExpiredException.java new file mode 100644 index 0000000000..79208489c4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/KeysExpiredException.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +/** + * Thrown when the drm keys loaded into an open session expire. + */ +public final class KeysExpiredException extends Exception { +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java new file mode 100644 index 0000000000..23e1859ca8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/LocalMediaDrmCallback.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.UUID; + +/** + * A {@link MediaDrmCallback} that provides a fixed response to key requests. Provisioning is not + * supported. This implementation is primarily useful for providing locally stored keys to decrypt + * ClearKey protected content. It is not suitable for use with Widevine or PlayReady protected + * content. + */ +public final class LocalMediaDrmCallback implements MediaDrmCallback { + + private final byte[] keyResponse; + + /** + * @param keyResponse The fixed response for all key requests. + */ + public LocalMediaDrmCallback(byte[] keyResponse) { + this.keyResponse = Assertions.checkNotNull(keyResponse); + } + + @Override + public byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception { + return keyResponse; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/MediaDrmCallback.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/MediaDrmCallback.java new file mode 100644 index 0000000000..2bc41f6bec --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/MediaDrmCallback.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.KeyRequest; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaDrm.ProvisionRequest; +import java.util.UUID; + +/** + * Performs {@link ExoMediaDrm} key and provisioning requests. + */ +public interface MediaDrmCallback { + + /** + * Executes a provisioning request. + * + * @param uuid The UUID of the content protection scheme. + * @param request The request. + * @return The response data. + * @throws Exception If an error occurred executing the request. + */ + byte[] executeProvisionRequest(UUID uuid, ProvisionRequest request) throws Exception; + + /** + * Executes a key request. + * + * @param uuid The UUID of the content protection scheme. + * @param request The request. + * @return The response data. + * @throws Exception If an error occurred executing the request. + */ + byte[] executeKeyRequest(UUID uuid, KeyRequest request) throws Exception; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java new file mode 100644 index 0000000000..3ce3879a76 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/OfflineLicenseHelper.java @@ -0,0 +1,266 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.annotation.TargetApi; +import android.media.MediaDrm; +import android.os.ConditionVariable; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DefaultDrmSessionManager.Mode; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource.Factory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +/** Helper class to download, renew and release offline licenses. */ +@TargetApi(18) +@RequiresApi(18) +public final class OfflineLicenseHelper<T extends ExoMediaCrypto> { + + private static final DrmInitData DUMMY_DRM_INIT_DATA = new DrmInitData(); + + private final ConditionVariable conditionVariable; + private final DefaultDrmSessionManager<T> drmSessionManager; + private final HandlerThread handlerThread; + + /** + * Instantiates a new instance which uses Widevine CDM. Call {@link #release()} when the instance + * is no longer required. + * + * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify + * their own license URL. + * @param httpDataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. + * @return A new instance which uses Widevine CDM. + * @throws UnsupportedDrmException If the Widevine DRM scheme is unsupported or cannot be + * instantiated. + */ + public static OfflineLicenseHelper<FrameworkMediaCrypto> newWidevineInstance( + String defaultLicenseUrl, Factory httpDataSourceFactory) + throws UnsupportedDrmException { + return newWidevineInstance(defaultLicenseUrl, false, httpDataSourceFactory, null); + } + + /** + * Instantiates a new instance which uses Widevine CDM. Call {@link #release()} when the instance + * is no longer required. + * + * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify + * their own license URL. + * @param forceDefaultLicenseUrl Whether to use {@code defaultLicenseUrl} for key requests that + * include their own license URL. + * @param httpDataSourceFactory A factory from which to obtain {@link HttpDataSource} instances. + * @return A new instance which uses Widevine CDM. + * @throws UnsupportedDrmException If the Widevine DRM scheme is unsupported or cannot be + * instantiated. + */ + public static OfflineLicenseHelper<FrameworkMediaCrypto> newWidevineInstance( + String defaultLicenseUrl, boolean forceDefaultLicenseUrl, Factory httpDataSourceFactory) + throws UnsupportedDrmException { + return newWidevineInstance(defaultLicenseUrl, forceDefaultLicenseUrl, httpDataSourceFactory, + null); + } + + /** + * Instantiates a new instance which uses Widevine CDM. Call {@link #release()} when the instance + * is no longer required. + * + * @param defaultLicenseUrl The default license URL. Used for key requests that do not specify + * their own license URL. + * @param forceDefaultLicenseUrl Whether to use {@code defaultLicenseUrl} for key requests that + * include their own license URL. + * @param optionalKeyRequestParameters An optional map of parameters to pass as the last argument + * to {@link MediaDrm#getKeyRequest}. May be null. + * @return A new instance which uses Widevine CDM. + * @throws UnsupportedDrmException If the Widevine DRM scheme is unsupported or cannot be + * instantiated. + * @see DefaultDrmSessionManager.Builder + */ + public static OfflineLicenseHelper<FrameworkMediaCrypto> newWidevineInstance( + String defaultLicenseUrl, + boolean forceDefaultLicenseUrl, + Factory httpDataSourceFactory, + @Nullable Map<String, String> optionalKeyRequestParameters) + throws UnsupportedDrmException { + return new OfflineLicenseHelper<>( + C.WIDEVINE_UUID, + FrameworkMediaDrm.DEFAULT_PROVIDER, + new HttpMediaDrmCallback(defaultLicenseUrl, forceDefaultLicenseUrl, httpDataSourceFactory), + optionalKeyRequestParameters); + } + + /** + * Constructs an instance. Call {@link #release()} when the instance is no longer required. + * + * @param uuid The UUID of the drm scheme. + * @param mediaDrmProvider A {@link ExoMediaDrm.Provider}. + * @param callback Performs key and provisioning requests. + * @param optionalKeyRequestParameters An optional map of parameters to pass as the last argument + * to {@link MediaDrm#getKeyRequest}. May be null. + * @see DefaultDrmSessionManager.Builder + */ + @SuppressWarnings("unchecked") + public OfflineLicenseHelper( + UUID uuid, + ExoMediaDrm.Provider<T> mediaDrmProvider, + MediaDrmCallback callback, + @Nullable Map<String, String> optionalKeyRequestParameters) { + handlerThread = new HandlerThread("OfflineLicenseHelper"); + handlerThread.start(); + conditionVariable = new ConditionVariable(); + DefaultDrmSessionEventListener eventListener = + new DefaultDrmSessionEventListener() { + @Override + public void onDrmKeysLoaded() { + conditionVariable.open(); + } + + @Override + public void onDrmSessionManagerError(Exception e) { + conditionVariable.open(); + } + + @Override + public void onDrmKeysRestored() { + conditionVariable.open(); + } + + @Override + public void onDrmKeysRemoved() { + conditionVariable.open(); + } + }; + if (optionalKeyRequestParameters == null) { + optionalKeyRequestParameters = Collections.emptyMap(); + } + drmSessionManager = + (DefaultDrmSessionManager<T>) + new DefaultDrmSessionManager.Builder() + .setUuidAndExoMediaDrmProvider(uuid, mediaDrmProvider) + .setKeyRequestParameters(optionalKeyRequestParameters) + .build(callback); + drmSessionManager.addListener(new Handler(handlerThread.getLooper()), eventListener); + } + + /** + * Downloads an offline license. + * + * @param drmInitData The {@link DrmInitData} for the content whose license is to be downloaded. + * @return The key set id for the downloaded license. + * @throws DrmSessionException Thrown when a DRM session error occurs. + */ + public synchronized byte[] downloadLicense(DrmInitData drmInitData) throws DrmSessionException { + Assertions.checkArgument(drmInitData != null); + return blockingKeyRequest(DefaultDrmSessionManager.MODE_DOWNLOAD, null, drmInitData); + } + + /** + * Renews an offline license. + * + * @param offlineLicenseKeySetId The key set id of the license to be renewed. + * @return The renewed offline license key set id. + * @throws DrmSessionException Thrown when a DRM session error occurs. + */ + public synchronized byte[] renewLicense(byte[] offlineLicenseKeySetId) + throws DrmSessionException { + Assertions.checkNotNull(offlineLicenseKeySetId); + return blockingKeyRequest( + DefaultDrmSessionManager.MODE_DOWNLOAD, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + } + + /** + * Releases an offline license. + * + * @param offlineLicenseKeySetId The key set id of the license to be released. + * @throws DrmSessionException Thrown when a DRM session error occurs. + */ + public synchronized void releaseLicense(byte[] offlineLicenseKeySetId) + throws DrmSessionException { + Assertions.checkNotNull(offlineLicenseKeySetId); + blockingKeyRequest( + DefaultDrmSessionManager.MODE_RELEASE, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + } + + /** + * Returns the remaining license and playback durations in seconds, for an offline license. + * + * @param offlineLicenseKeySetId The key set id of the license. + * @return The remaining license and playback durations, in seconds. + * @throws DrmSessionException Thrown when a DRM session error occurs. + */ + public synchronized Pair<Long, Long> getLicenseDurationRemainingSec(byte[] offlineLicenseKeySetId) + throws DrmSessionException { + Assertions.checkNotNull(offlineLicenseKeySetId); + drmSessionManager.prepare(); + DrmSession<T> drmSession = + openBlockingKeyRequest( + DefaultDrmSessionManager.MODE_QUERY, offlineLicenseKeySetId, DUMMY_DRM_INIT_DATA); + DrmSessionException error = drmSession.getError(); + Pair<Long, Long> licenseDurationRemainingSec = + WidevineUtil.getLicenseDurationRemainingSec(drmSession); + drmSession.release(); + drmSessionManager.release(); + if (error != null) { + if (error.getCause() instanceof KeysExpiredException) { + return Pair.create(0L, 0L); + } + throw error; + } + return Assertions.checkNotNull(licenseDurationRemainingSec); + } + + /** + * Releases the helper. Should be called when the helper is no longer required. + */ + public void release() { + handlerThread.quit(); + } + + private byte[] blockingKeyRequest( + @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, DrmInitData drmInitData) + throws DrmSessionException { + drmSessionManager.prepare(); + DrmSession<T> drmSession = openBlockingKeyRequest(licenseMode, offlineLicenseKeySetId, + drmInitData); + DrmSessionException error = drmSession.getError(); + byte[] keySetId = drmSession.getOfflineLicenseKeySetId(); + drmSession.release(); + drmSessionManager.release(); + if (error != null) { + throw error; + } + return Assertions.checkNotNull(keySetId); + } + + private DrmSession<T> openBlockingKeyRequest( + @Mode int licenseMode, @Nullable byte[] offlineLicenseKeySetId, DrmInitData drmInitData) { + drmSessionManager.setMode(licenseMode, offlineLicenseKeySetId); + conditionVariable.close(); + DrmSession<T> drmSession = drmSessionManager.acquireSession(handlerThread.getLooper(), + drmInitData); + // Block current thread until key loading is finished + conditionVariable.block(); + return drmSession; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/UnsupportedDrmException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/UnsupportedDrmException.java new file mode 100644 index 0000000000..4dc9f2b0b2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/UnsupportedDrmException.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import androidx.annotation.IntDef; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Thrown when the requested DRM scheme is not supported. + */ +public final class UnsupportedDrmException extends Exception { + + /** + * The reason for the exception. One of {@link #REASON_UNSUPPORTED_SCHEME} or {@link + * #REASON_INSTANTIATION_ERROR}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({REASON_UNSUPPORTED_SCHEME, REASON_INSTANTIATION_ERROR}) + public @interface Reason {} + /** + * The requested DRM scheme is unsupported by the device. + */ + public static final int REASON_UNSUPPORTED_SCHEME = 1; + /** + * There device advertises support for the requested DRM scheme, but there was an error + * instantiating it. The cause can be retrieved using {@link #getCause()}. + */ + public static final int REASON_INSTANTIATION_ERROR = 2; + + /** + * Either {@link #REASON_UNSUPPORTED_SCHEME} or {@link #REASON_INSTANTIATION_ERROR}. + */ + @Reason public final int reason; + + /** + * @param reason {@link #REASON_UNSUPPORTED_SCHEME} or {@link #REASON_INSTANTIATION_ERROR}. + */ + public UnsupportedDrmException(@Reason int reason) { + this.reason = reason; + } + + /** + * @param reason {@link #REASON_UNSUPPORTED_SCHEME} or {@link #REASON_INSTANTIATION_ERROR}. + * @param cause The cause of this exception. + */ + public UnsupportedDrmException(@Reason int reason, Exception cause) { + super(cause); + this.reason = reason; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/WidevineUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/WidevineUtil.java new file mode 100644 index 0000000000..67539bef39 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/WidevineUtil.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.Map; + +/** + * Utility methods for Widevine. + */ +public final class WidevineUtil { + + /** Widevine specific key status field name for the remaining license duration, in seconds. */ + public static final String PROPERTY_LICENSE_DURATION_REMAINING = "LicenseDurationRemaining"; + /** Widevine specific key status field name for the remaining playback duration, in seconds. */ + public static final String PROPERTY_PLAYBACK_DURATION_REMAINING = "PlaybackDurationRemaining"; + + private WidevineUtil() {} + + /** + * Returns license and playback durations remaining in seconds. + * + * @param drmSession The drm session to query. + * @return A {@link Pair} consisting of the remaining license and playback durations in seconds, + * or null if called before the session has been opened or after it's been released. + */ + public static @Nullable Pair<Long, Long> getLicenseDurationRemainingSec( + DrmSession<?> drmSession) { + Map<String, String> keyStatus = drmSession.queryKeyStatus(); + if (keyStatus == null) { + return null; + } + return new Pair<>(getDurationRemainingSec(keyStatus, PROPERTY_LICENSE_DURATION_REMAINING), + getDurationRemainingSec(keyStatus, PROPERTY_PLAYBACK_DURATION_REMAINING)); + } + + private static long getDurationRemainingSec(Map<String, String> keyStatus, String property) { + if (keyStatus != null) { + try { + String value = keyStatus.get(property); + if (value != null) { + return Long.parseLong(value); + } + } catch (NumberFormatException e) { + // do nothing. + } + } + return C.TIME_UNSET; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/package-info.java new file mode 100644 index 0000000000..ec885e2ad7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/drm/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.drm; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java new file mode 100644 index 0000000000..b0b7c7da13 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/BinarySearchSeeker.java @@ -0,0 +1,538 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A seeker that supports seeking within a stream by searching for the target frame using binary + * search. + * + * <p>This seeker operates on a stream that contains multiple frames (or samples). Each frame is + * associated with some kind of timestamps, such as stream time, or frame indices. Given a target + * seek time, the seeker will find the corresponding target timestamp, and perform a search + * operation within the stream to identify the target frame and return the byte position in the + * stream of the target frame. + */ +public abstract class BinarySearchSeeker { + + /** A seeker that looks for a given timestamp from an input. */ + protected interface TimestampSeeker { + + /** + * Searches a limited window of the provided input for a target timestamp. The size of the + * window is implementation specific, but should be small enough such that it's reasonable for + * multiple such reads to occur during a seek operation. + * + * @param input The {@link ExtractorInput} from which data should be peeked. + * @param targetTimestamp The target timestamp. + * @return A {@link TimestampSearchResult} that describes the result of the search. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) + throws IOException, InterruptedException; + + /** Called when a seek operation finishes. */ + default void onSeekFinished() {} + } + + /** + * A {@link SeekTimestampConverter} implementation that returns the seek time itself as the + * timestamp for a seek time position. + */ + public static final class DefaultSeekTimestampConverter implements SeekTimestampConverter { + + @Override + public long timeUsToTargetTime(long timeUs) { + return timeUs; + } + } + + /** + * A converter that converts seek time in stream time into target timestamp for the {@link + * BinarySearchSeeker}. + */ + protected interface SeekTimestampConverter { + /** + * Converts a seek time in microseconds into target timestamp for the {@link + * BinarySearchSeeker}. + */ + long timeUsToTargetTime(long timeUs); + } + + /** + * When seeking within the source, if the offset is smaller than or equal to this value, the seek + * operation will be performed using a skip operation. Otherwise, the source will be reloaded at + * the new seek position. + */ + private static final long MAX_SKIP_BYTES = 256 * 1024; + + protected final BinarySearchSeekMap seekMap; + protected final TimestampSeeker timestampSeeker; + protected @Nullable SeekOperationParams seekOperationParams; + + private final int minimumSearchRange; + + /** + * Constructs an instance. + * + * @param seekTimestampConverter The {@link SeekTimestampConverter} that converts seek time in + * stream time into target timestamp. + * @param timestampSeeker A {@link TimestampSeeker} that will be used to search for timestamps + * within the stream. + * @param durationUs The duration of the stream in microseconds. + * @param floorTimePosition The minimum timestamp value (inclusive) in the stream. + * @param ceilingTimePosition The minimum timestamp value (exclusive) in the stream. + * @param floorBytePosition The starting position of the frame with minimum timestamp value + * (inclusive) in the stream. + * @param ceilingBytePosition The position after the frame with maximum timestamp value in the + * stream. + * @param approxBytesPerFrame Approximated bytes per frame. + * @param minimumSearchRange The minimum byte range that this binary seeker will operate on. If + * the remaining search range is smaller than this value, the search will stop, and the seeker + * will return the position at the floor of the range as the result. + */ + @SuppressWarnings("initialization") + protected BinarySearchSeeker( + SeekTimestampConverter seekTimestampConverter, + TimestampSeeker timestampSeeker, + long durationUs, + long floorTimePosition, + long ceilingTimePosition, + long floorBytePosition, + long ceilingBytePosition, + long approxBytesPerFrame, + int minimumSearchRange) { + this.timestampSeeker = timestampSeeker; + this.minimumSearchRange = minimumSearchRange; + this.seekMap = + new BinarySearchSeekMap( + seekTimestampConverter, + durationUs, + floorTimePosition, + ceilingTimePosition, + floorBytePosition, + ceilingBytePosition, + approxBytesPerFrame); + } + + /** Returns the seek map for the stream. */ + public final SeekMap getSeekMap() { + return seekMap; + } + + /** + * Sets the target time in microseconds within the stream to seek to. + * + * @param timeUs The target time in microseconds within the stream. + */ + public final void setSeekTargetUs(long timeUs) { + if (seekOperationParams != null && seekOperationParams.getSeekTimeUs() == timeUs) { + return; + } + seekOperationParams = createSeekParamsForTargetTimeUs(timeUs); + } + + /** Returns whether the last operation set by {@link #setSeekTargetUs(long)} is still pending. */ + public final boolean isSeeking() { + return seekOperationParams != null; + } + + /** + * Continues to handle the pending seek operation. Returns one of the {@code RESULT_} values from + * {@link Extractor}. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated + * to hold the position of the required seek. + * @return One of the {@code RESULT_} values defined in {@link Extractor}. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + public int handlePendingSeek(ExtractorInput input, PositionHolder seekPositionHolder) + throws InterruptedException, IOException { + TimestampSeeker timestampSeeker = Assertions.checkNotNull(this.timestampSeeker); + while (true) { + SeekOperationParams seekOperationParams = Assertions.checkNotNull(this.seekOperationParams); + long floorPosition = seekOperationParams.getFloorBytePosition(); + long ceilingPosition = seekOperationParams.getCeilingBytePosition(); + long searchPosition = seekOperationParams.getNextSearchBytePosition(); + + if (ceilingPosition - floorPosition <= minimumSearchRange) { + // The seeking range is too small, so we can just continue from the floor position. + markSeekOperationFinished(/* foundTargetFrame= */ false, floorPosition); + return seekToPosition(input, floorPosition, seekPositionHolder); + } + if (!skipInputUntilPosition(input, searchPosition)) { + return seekToPosition(input, searchPosition, seekPositionHolder); + } + + input.resetPeekPosition(); + TimestampSearchResult timestampSearchResult = + timestampSeeker.searchForTimestamp(input, seekOperationParams.getTargetTimePosition()); + + switch (timestampSearchResult.type) { + case TimestampSearchResult.TYPE_POSITION_OVERESTIMATED: + seekOperationParams.updateSeekCeiling( + timestampSearchResult.timestampToUpdate, timestampSearchResult.bytePositionToUpdate); + break; + case TimestampSearchResult.TYPE_POSITION_UNDERESTIMATED: + seekOperationParams.updateSeekFloor( + timestampSearchResult.timestampToUpdate, timestampSearchResult.bytePositionToUpdate); + break; + case TimestampSearchResult.TYPE_TARGET_TIMESTAMP_FOUND: + markSeekOperationFinished( + /* foundTargetFrame= */ true, timestampSearchResult.bytePositionToUpdate); + skipInputUntilPosition(input, timestampSearchResult.bytePositionToUpdate); + return seekToPosition( + input, timestampSearchResult.bytePositionToUpdate, seekPositionHolder); + case TimestampSearchResult.TYPE_NO_TIMESTAMP: + // We can't find any timestamp in the search range from the search position. + // Give up, and just continue reading from the last search position in this case. + markSeekOperationFinished(/* foundTargetFrame= */ false, searchPosition); + return seekToPosition(input, searchPosition, seekPositionHolder); + default: + throw new IllegalStateException("Invalid case"); + } + } + } + + protected SeekOperationParams createSeekParamsForTargetTimeUs(long timeUs) { + return new SeekOperationParams( + timeUs, + seekMap.timeUsToTargetTime(timeUs), + seekMap.floorTimePosition, + seekMap.ceilingTimePosition, + seekMap.floorBytePosition, + seekMap.ceilingBytePosition, + seekMap.approxBytesPerFrame); + } + + protected final void markSeekOperationFinished(boolean foundTargetFrame, long resultPosition) { + seekOperationParams = null; + timestampSeeker.onSeekFinished(); + onSeekOperationFinished(foundTargetFrame, resultPosition); + } + + protected void onSeekOperationFinished(boolean foundTargetFrame, long resultPosition) { + // Do nothing. + } + + protected final boolean skipInputUntilPosition(ExtractorInput input, long position) + throws IOException, InterruptedException { + long bytesToSkip = position - input.getPosition(); + if (bytesToSkip >= 0 && bytesToSkip <= MAX_SKIP_BYTES) { + input.skipFully((int) bytesToSkip); + return true; + } + return false; + } + + protected final int seekToPosition( + ExtractorInput input, long position, PositionHolder seekPositionHolder) { + if (position == input.getPosition()) { + return Extractor.RESULT_CONTINUE; + } else { + seekPositionHolder.position = position; + return Extractor.RESULT_SEEK; + } + } + + /** + * Contains parameters for a pending seek operation by {@link BinarySearchSeeker}. + * + * <p>This class holds parameters for a binary-search for the {@code targetTimePosition} in the + * range [floorPosition, ceilingPosition). + */ + protected static class SeekOperationParams { + private final long seekTimeUs; + private final long targetTimePosition; + private final long approxBytesPerFrame; + + private long floorTimePosition; + private long ceilingTimePosition; + private long floorBytePosition; + private long ceilingBytePosition; + private long nextSearchBytePosition; + + /** + * Returns the next position in the stream to search for target frame, given [floorBytePosition, + * ceilingBytePosition), with corresponding [floorTimePosition, ceilingTimePosition). + */ + protected static long calculateNextSearchBytePosition( + long targetTimePosition, + long floorTimePosition, + long ceilingTimePosition, + long floorBytePosition, + long ceilingBytePosition, + long approxBytesPerFrame) { + if (floorBytePosition + 1 >= ceilingBytePosition + || floorTimePosition + 1 >= ceilingTimePosition) { + return floorBytePosition; + } + long seekTimeDuration = targetTimePosition - floorTimePosition; + float estimatedBytesPerTimeUnit = + (float) (ceilingBytePosition - floorBytePosition) + / (ceilingTimePosition - floorTimePosition); + // It's better to under-estimate rather than over-estimate, because the extractor + // input can skip forward easily, but cannot rewind easily (it may require a new connection + // to be made). + // Therefore, we should reduce the estimated position by some amount, so it will converge to + // the correct frame earlier. + long bytesToSkip = (long) (seekTimeDuration * estimatedBytesPerTimeUnit); + long confidenceInterval = bytesToSkip / 20; + long estimatedFramePosition = floorBytePosition + bytesToSkip - approxBytesPerFrame; + long estimatedPosition = estimatedFramePosition - confidenceInterval; + return Util.constrainValue(estimatedPosition, floorBytePosition, ceilingBytePosition - 1); + } + + protected SeekOperationParams( + long seekTimeUs, + long targetTimePosition, + long floorTimePosition, + long ceilingTimePosition, + long floorBytePosition, + long ceilingBytePosition, + long approxBytesPerFrame) { + this.seekTimeUs = seekTimeUs; + this.targetTimePosition = targetTimePosition; + this.floorTimePosition = floorTimePosition; + this.ceilingTimePosition = ceilingTimePosition; + this.floorBytePosition = floorBytePosition; + this.ceilingBytePosition = ceilingBytePosition; + this.approxBytesPerFrame = approxBytesPerFrame; + this.nextSearchBytePosition = + calculateNextSearchBytePosition( + targetTimePosition, + floorTimePosition, + ceilingTimePosition, + floorBytePosition, + ceilingBytePosition, + approxBytesPerFrame); + } + + /** + * Returns the floor byte position of the range [floorPosition, ceilingPosition) for this seek + * operation. + */ + private long getFloorBytePosition() { + return floorBytePosition; + } + + /** + * Returns the ceiling byte position of the range [floorPosition, ceilingPosition) for this seek + * operation. + */ + private long getCeilingBytePosition() { + return ceilingBytePosition; + } + + /** Returns the target timestamp as translated from the seek time. */ + private long getTargetTimePosition() { + return targetTimePosition; + } + + /** Returns the target seek time in microseconds. */ + private long getSeekTimeUs() { + return seekTimeUs; + } + + /** Updates the floor constraints (inclusive) of the seek operation. */ + private void updateSeekFloor(long floorTimePosition, long floorBytePosition) { + this.floorTimePosition = floorTimePosition; + this.floorBytePosition = floorBytePosition; + updateNextSearchBytePosition(); + } + + /** Updates the ceiling constraints (exclusive) of the seek operation. */ + private void updateSeekCeiling(long ceilingTimePosition, long ceilingBytePosition) { + this.ceilingTimePosition = ceilingTimePosition; + this.ceilingBytePosition = ceilingBytePosition; + updateNextSearchBytePosition(); + } + + /** Returns the next position in the stream to search. */ + private long getNextSearchBytePosition() { + return nextSearchBytePosition; + } + + private void updateNextSearchBytePosition() { + this.nextSearchBytePosition = + calculateNextSearchBytePosition( + targetTimePosition, + floorTimePosition, + ceilingTimePosition, + floorBytePosition, + ceilingBytePosition, + approxBytesPerFrame); + } + } + + /** + * Represents possible search results for {@link + * TimestampSeeker#searchForTimestamp(ExtractorInput, long)}. + */ + public static final class TimestampSearchResult { + + /** The search found a timestamp that it deems close enough to the given target. */ + public static final int TYPE_TARGET_TIMESTAMP_FOUND = 0; + /** The search found only timestamps larger than the target timestamp. */ + public static final int TYPE_POSITION_OVERESTIMATED = -1; + /** The search found only timestamps smaller than the target timestamp. */ + public static final int TYPE_POSITION_UNDERESTIMATED = -2; + /** The search didn't find any timestamps. */ + public static final int TYPE_NO_TIMESTAMP = -3; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TYPE_TARGET_TIMESTAMP_FOUND, + TYPE_POSITION_OVERESTIMATED, + TYPE_POSITION_UNDERESTIMATED, + TYPE_NO_TIMESTAMP + }) + @interface Type {} + + public static final TimestampSearchResult NO_TIMESTAMP_IN_RANGE_RESULT = + new TimestampSearchResult(TYPE_NO_TIMESTAMP, C.TIME_UNSET, C.POSITION_UNSET); + + /** The type of the result. */ + @Type private final int type; + + /** + * When {@link #type} is {@link #TYPE_POSITION_OVERESTIMATED}, the {@link + * SeekOperationParams#ceilingTimePosition} should be updated with this value. When {@link + * #type} is {@link #TYPE_POSITION_UNDERESTIMATED}, the {@link + * SeekOperationParams#floorTimePosition} should be updated with this value. + */ + private final long timestampToUpdate; + /** + * When {@link #type} is {@link #TYPE_POSITION_OVERESTIMATED}, the {@link + * SeekOperationParams#ceilingBytePosition} should be updated with this value. When {@link + * #type} is {@link #TYPE_POSITION_UNDERESTIMATED}, the {@link + * SeekOperationParams#floorBytePosition} should be updated with this value. + */ + private final long bytePositionToUpdate; + + private TimestampSearchResult( + @Type int type, long timestampToUpdate, long bytePositionToUpdate) { + this.type = type; + this.timestampToUpdate = timestampToUpdate; + this.bytePositionToUpdate = bytePositionToUpdate; + } + + /** + * Returns a result to signal that the current position in the input stream overestimates the + * true position of the target frame, and the {@link BinarySearchSeeker} should modify its + * {@link SeekOperationParams}'s ceiling timestamp and byte position using the given values. + */ + public static TimestampSearchResult overestimatedResult( + long newCeilingTimestamp, long newCeilingBytePosition) { + return new TimestampSearchResult( + TYPE_POSITION_OVERESTIMATED, newCeilingTimestamp, newCeilingBytePosition); + } + + /** + * Returns a result to signal that the current position in the input stream underestimates the + * true position of the target frame, and the {@link BinarySearchSeeker} should modify its + * {@link SeekOperationParams}'s floor timestamp and byte position using the given values. + */ + public static TimestampSearchResult underestimatedResult( + long newFloorTimestamp, long newCeilingBytePosition) { + return new TimestampSearchResult( + TYPE_POSITION_UNDERESTIMATED, newFloorTimestamp, newCeilingBytePosition); + } + + /** + * Returns a result to signal that the target timestamp has been found at {@code + * resultBytePosition}, and the seek operation can stop. + */ + public static TimestampSearchResult targetFoundResult(long resultBytePosition) { + return new TimestampSearchResult( + TYPE_TARGET_TIMESTAMP_FOUND, C.TIME_UNSET, resultBytePosition); + } + } + + /** + * A {@link SeekMap} implementation that returns the estimated byte location from {@link + * SeekOperationParams#calculateNextSearchBytePosition(long, long, long, long, long, long)} for + * each {@link #getSeekPoints(long)} query. + */ + public static class BinarySearchSeekMap implements SeekMap { + private final SeekTimestampConverter seekTimestampConverter; + private final long durationUs; + private final long floorTimePosition; + private final long ceilingTimePosition; + private final long floorBytePosition; + private final long ceilingBytePosition; + private final long approxBytesPerFrame; + + /** Constructs a new instance of this seek map. */ + public BinarySearchSeekMap( + SeekTimestampConverter seekTimestampConverter, + long durationUs, + long floorTimePosition, + long ceilingTimePosition, + long floorBytePosition, + long ceilingBytePosition, + long approxBytesPerFrame) { + this.seekTimestampConverter = seekTimestampConverter; + this.durationUs = durationUs; + this.floorTimePosition = floorTimePosition; + this.ceilingTimePosition = ceilingTimePosition; + this.floorBytePosition = floorBytePosition; + this.ceilingBytePosition = ceilingBytePosition; + this.approxBytesPerFrame = approxBytesPerFrame; + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + long nextSearchPosition = + SeekOperationParams.calculateNextSearchBytePosition( + /* targetTimePosition= */ seekTimestampConverter.timeUsToTargetTime(timeUs), + /* floorTimePosition= */ floorTimePosition, + /* ceilingTimePosition= */ ceilingTimePosition, + /* floorBytePosition= */ floorBytePosition, + /* ceilingBytePosition= */ ceilingBytePosition, + /* approxBytesPerFrame= */ approxBytesPerFrame); + return new SeekPoints(new SeekPoint(timeUs, nextSearchPosition)); + } + + @Override + public long getDurationUs() { + return durationUs; + } + + /** @see SeekTimestampConverter#timeUsToTargetTime(long) */ + public long timeUsToTargetTime(long timeUs) { + return seekTimestampConverter.timeUsToTargetTime(timeUs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ChunkIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ChunkIndex.java new file mode 100644 index 0000000000..4fdf9f3c55 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ChunkIndex.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Defines chunks of samples within a media stream. + */ +public final class ChunkIndex implements SeekMap { + + /** + * The number of chunks. + */ + public final int length; + + /** + * The chunk sizes, in bytes. + */ + public final int[] sizes; + + /** + * The chunk byte offsets. + */ + public final long[] offsets; + + /** + * The chunk durations, in microseconds. + */ + public final long[] durationsUs; + + /** + * The start time of each chunk, in microseconds. + */ + public final long[] timesUs; + + private final long durationUs; + + /** + * @param sizes The chunk sizes, in bytes. + * @param offsets The chunk byte offsets. + * @param durationsUs The chunk durations, in microseconds. + * @param timesUs The start time of each chunk, in microseconds. + */ + public ChunkIndex(int[] sizes, long[] offsets, long[] durationsUs, long[] timesUs) { + this.sizes = sizes; + this.offsets = offsets; + this.durationsUs = durationsUs; + this.timesUs = timesUs; + length = sizes.length; + if (length > 0) { + durationUs = durationsUs[length - 1] + timesUs[length - 1]; + } else { + durationUs = 0; + } + } + + /** + * Obtains the index of the chunk corresponding to a given time. + * + * @param timeUs The time, in microseconds. + * @return The index of the corresponding chunk. + */ + public int getChunkIndex(long timeUs) { + return Util.binarySearchFloor(timesUs, timeUs, true, true); + } + + // SeekMap implementation. + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + int chunkIndex = getChunkIndex(timeUs); + SeekPoint seekPoint = new SeekPoint(timesUs[chunkIndex], offsets[chunkIndex]); + if (seekPoint.timeUs >= timeUs || chunkIndex == length - 1) { + return new SeekPoints(seekPoint); + } else { + SeekPoint nextSeekPoint = new SeekPoint(timesUs[chunkIndex + 1], offsets[chunkIndex + 1]); + return new SeekPoints(seekPoint, nextSeekPoint); + } + } + + @Override + public String toString() { + return "ChunkIndex(" + + "length=" + + length + + ", sizes=" + + Arrays.toString(sizes) + + ", offsets=" + + Arrays.toString(offsets) + + ", timeUs=" + + Arrays.toString(timesUs) + + ", durationsUs=" + + Arrays.toString(durationsUs) + + ")"; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java new file mode 100644 index 0000000000..215aac0e6d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ConstantBitrateSeekMap.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * A {@link SeekMap} implementation that assumes the stream has a constant bitrate and consists of + * multiple independent frames of the same size. Seek points are calculated to be at frame + * boundaries. + */ +public class ConstantBitrateSeekMap implements SeekMap { + + private final long inputLength; + private final long firstFrameBytePosition; + private final int frameSize; + private final long dataSize; + private final int bitrate; + private final long durationUs; + + /** + * Constructs a new instance from a stream. + * + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param firstFrameBytePosition The byte-position of the first frame in the stream. + * @param bitrate The bitrate (which is assumed to be constant in the stream). + * @param frameSize The size of each frame in the stream in bytes. May be {@link C#LENGTH_UNSET} + * if unknown. + */ + public ConstantBitrateSeekMap( + long inputLength, long firstFrameBytePosition, int bitrate, int frameSize) { + this.inputLength = inputLength; + this.firstFrameBytePosition = firstFrameBytePosition; + this.frameSize = frameSize == C.LENGTH_UNSET ? 1 : frameSize; + this.bitrate = bitrate; + + if (inputLength == C.LENGTH_UNSET) { + dataSize = C.LENGTH_UNSET; + durationUs = C.TIME_UNSET; + } else { + dataSize = inputLength - firstFrameBytePosition; + durationUs = getTimeUsAtPosition(inputLength, firstFrameBytePosition, bitrate); + } + } + + @Override + public boolean isSeekable() { + return dataSize != C.LENGTH_UNSET; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + if (dataSize == C.LENGTH_UNSET) { + return new SeekPoints(new SeekPoint(0, firstFrameBytePosition)); + } + long seekFramePosition = getFramePositionForTimeUs(timeUs); + long seekTimeUs = getTimeUsAtPosition(seekFramePosition); + SeekPoint seekPoint = new SeekPoint(seekTimeUs, seekFramePosition); + if (seekTimeUs >= timeUs || seekFramePosition + frameSize >= inputLength) { + return new SeekPoints(seekPoint); + } else { + long secondSeekPosition = seekFramePosition + frameSize; + long secondSeekTimeUs = getTimeUsAtPosition(secondSeekPosition); + SeekPoint secondSeekPoint = new SeekPoint(secondSeekTimeUs, secondSeekPosition); + return new SeekPoints(seekPoint, secondSeekPoint); + } + } + + @Override + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the stream time in microseconds for a given position. + * + * @param position The stream byte-position. + * @return The stream time in microseconds for the given position. + */ + public long getTimeUsAtPosition(long position) { + return getTimeUsAtPosition(position, firstFrameBytePosition, bitrate); + } + + // Internal methods + + /** + * Returns the stream time in microseconds for a given stream position. + * + * @param position The stream byte-position. + * @param firstFrameBytePosition The position of the first frame in the stream. + * @param bitrate The bitrate (which is assumed to be constant in the stream). + * @return The stream time in microseconds for the given stream position. + */ + private static long getTimeUsAtPosition(long position, long firstFrameBytePosition, int bitrate) { + return Math.max(0, position - firstFrameBytePosition) + * C.BITS_PER_BYTE + * C.MICROS_PER_SECOND + / bitrate; + } + + private long getFramePositionForTimeUs(long timeUs) { + long positionOffset = (timeUs * bitrate) / (C.MICROS_PER_SECOND * C.BITS_PER_BYTE); + // Constrain to nearest preceding frame offset. + positionOffset = (positionOffset / frameSize) * frameSize; + positionOffset = + Util.constrainValue(positionOffset, /* min= */ 0, /* max= */ dataSize - frameSize); + return firstFrameBytePosition + positionOffset; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java new file mode 100644 index 0000000000..93009f2d5c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DefaultExtractorInput.java @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.util.Arrays; + +/** + * An {@link ExtractorInput} that wraps a {@link DataSource}. + */ +public final class DefaultExtractorInput implements ExtractorInput { + + private static final int PEEK_MIN_FREE_SPACE_AFTER_RESIZE = 64 * 1024; + private static final int PEEK_MAX_FREE_SPACE = 512 * 1024; + private static final int SCRATCH_SPACE_SIZE = 4096; + + private final byte[] scratchSpace; + private final DataSource dataSource; + private final long streamLength; + + private long position; + private byte[] peekBuffer; + private int peekBufferPosition; + private int peekBufferLength; + + /** + * @param dataSource The wrapped {@link DataSource}. + * @param position The initial position in the stream. + * @param length The length of the stream, or {@link C#LENGTH_UNSET} if it is unknown. + */ + public DefaultExtractorInput(DataSource dataSource, long position, long length) { + this.dataSource = dataSource; + this.position = position; + this.streamLength = length; + peekBuffer = new byte[PEEK_MIN_FREE_SPACE_AFTER_RESIZE]; + scratchSpace = new byte[SCRATCH_SPACE_SIZE]; + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException, InterruptedException { + int bytesRead = readFromPeekBuffer(target, offset, length); + if (bytesRead == 0) { + bytesRead = + readFromDataSource( + target, offset, length, /* bytesAlreadyRead= */ 0, /* allowEndOfInput= */ true); + } + commitBytesRead(bytesRead); + return bytesRead; + } + + @Override + public boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + int bytesRead = readFromPeekBuffer(target, offset, length); + while (bytesRead < length && bytesRead != C.RESULT_END_OF_INPUT) { + bytesRead = readFromDataSource(target, offset, length, bytesRead, allowEndOfInput); + } + commitBytesRead(bytesRead); + return bytesRead != C.RESULT_END_OF_INPUT; + } + + @Override + public void readFully(byte[] target, int offset, int length) + throws IOException, InterruptedException { + readFully(target, offset, length, false); + } + + @Override + public int skip(int length) throws IOException, InterruptedException { + int bytesSkipped = skipFromPeekBuffer(length); + if (bytesSkipped == 0) { + bytesSkipped = + readFromDataSource(scratchSpace, 0, Math.min(length, scratchSpace.length), 0, true); + } + commitBytesRead(bytesSkipped); + return bytesSkipped; + } + + @Override + public boolean skipFully(int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + int bytesSkipped = skipFromPeekBuffer(length); + while (bytesSkipped < length && bytesSkipped != C.RESULT_END_OF_INPUT) { + int minLength = Math.min(length, bytesSkipped + scratchSpace.length); + bytesSkipped = + readFromDataSource(scratchSpace, -bytesSkipped, minLength, bytesSkipped, allowEndOfInput); + } + commitBytesRead(bytesSkipped); + return bytesSkipped != C.RESULT_END_OF_INPUT; + } + + @Override + public void skipFully(int length) throws IOException, InterruptedException { + skipFully(length, false); + } + + @Override + public int peek(byte[] target, int offset, int length) throws IOException, InterruptedException { + ensureSpaceForPeek(length); + int peekBufferRemainingBytes = peekBufferLength - peekBufferPosition; + int bytesPeeked; + if (peekBufferRemainingBytes == 0) { + bytesPeeked = + readFromDataSource( + peekBuffer, + peekBufferPosition, + length, + /* bytesAlreadyRead= */ 0, + /* allowEndOfInput= */ true); + if (bytesPeeked == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + peekBufferLength += bytesPeeked; + } else { + bytesPeeked = Math.min(length, peekBufferRemainingBytes); + } + System.arraycopy(peekBuffer, peekBufferPosition, target, offset, bytesPeeked); + peekBufferPosition += bytesPeeked; + return bytesPeeked; + } + + @Override + public boolean peekFully(byte[] target, int offset, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + if (!advancePeekPosition(length, allowEndOfInput)) { + return false; + } + System.arraycopy(peekBuffer, peekBufferPosition - length, target, offset, length); + return true; + } + + @Override + public void peekFully(byte[] target, int offset, int length) + throws IOException, InterruptedException { + peekFully(target, offset, length, false); + } + + @Override + public boolean advancePeekPosition(int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + ensureSpaceForPeek(length); + int bytesPeeked = peekBufferLength - peekBufferPosition; + while (bytesPeeked < length) { + bytesPeeked = readFromDataSource(peekBuffer, peekBufferPosition, length, bytesPeeked, + allowEndOfInput); + if (bytesPeeked == C.RESULT_END_OF_INPUT) { + return false; + } + peekBufferLength = peekBufferPosition + bytesPeeked; + } + peekBufferPosition += length; + return true; + } + + @Override + public void advancePeekPosition(int length) throws IOException, InterruptedException { + advancePeekPosition(length, false); + } + + @Override + public void resetPeekPosition() { + peekBufferPosition = 0; + } + + @Override + public long getPeekPosition() { + return position + peekBufferPosition; + } + + @Override + public long getPosition() { + return position; + } + + @Override + public long getLength() { + return streamLength; + } + + @Override + public <E extends Throwable> void setRetryPosition(long position, E e) throws E { + Assertions.checkArgument(position >= 0); + this.position = position; + throw e; + } + + /** + * Ensures {@code peekBuffer} is large enough to store at least {@code length} bytes from the + * current peek position. + */ + private void ensureSpaceForPeek(int length) { + int requiredLength = peekBufferPosition + length; + if (requiredLength > peekBuffer.length) { + int newPeekCapacity = Util.constrainValue(peekBuffer.length * 2, + requiredLength + PEEK_MIN_FREE_SPACE_AFTER_RESIZE, requiredLength + PEEK_MAX_FREE_SPACE); + peekBuffer = Arrays.copyOf(peekBuffer, newPeekCapacity); + } + } + + /** + * Skips from the peek buffer. + * + * @param length The maximum number of bytes to skip from the peek buffer. + * @return The number of bytes skipped. + */ + private int skipFromPeekBuffer(int length) { + int bytesSkipped = Math.min(peekBufferLength, length); + updatePeekBuffer(bytesSkipped); + return bytesSkipped; + } + + /** + * Reads from the peek buffer. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to read from the peek buffer. + * @return The number of bytes read. + */ + private int readFromPeekBuffer(byte[] target, int offset, int length) { + if (peekBufferLength == 0) { + return 0; + } + int peekBytes = Math.min(peekBufferLength, length); + System.arraycopy(peekBuffer, 0, target, offset, peekBytes); + updatePeekBuffer(peekBytes); + return peekBytes; + } + + /** + * Updates the peek buffer's length, position and contents after consuming data. + * + * @param bytesConsumed The number of bytes consumed from the peek buffer. + */ + private void updatePeekBuffer(int bytesConsumed) { + peekBufferLength -= bytesConsumed; + peekBufferPosition = 0; + byte[] newPeekBuffer = peekBuffer; + if (peekBufferLength < peekBuffer.length - PEEK_MAX_FREE_SPACE) { + newPeekBuffer = new byte[peekBufferLength + PEEK_MIN_FREE_SPACE_AFTER_RESIZE]; + } + System.arraycopy(peekBuffer, bytesConsumed, newPeekBuffer, 0, peekBufferLength); + peekBuffer = newPeekBuffer; + } + + /** + * Starts or continues a read from the data source. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to read from the input. + * @param bytesAlreadyRead The number of bytes already read from the input. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it + * should be considered an error, causing an {@link EOFException} to be thrown. + * @return The total number of bytes read so far, or {@link C#RESULT_END_OF_INPUT} if + * {@code allowEndOfInput} is true and the input has ended having read no bytes. + * @throws EOFException If the end of input was encountered having partially satisfied the read + * (i.e. having read at least one byte, but fewer than {@code length}), or if no bytes were + * read and {@code allowEndOfInput} is false. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private int readFromDataSource(byte[] target, int offset, int length, int bytesAlreadyRead, + boolean allowEndOfInput) throws InterruptedException, IOException { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + int bytesRead = dataSource.read(target, offset + bytesAlreadyRead, length - bytesAlreadyRead); + if (bytesRead == C.RESULT_END_OF_INPUT) { + if (bytesAlreadyRead == 0 && allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } + throw new EOFException(); + } + return bytesAlreadyRead + bytesRead; + } + + /** + * Advances the position by the specified number of bytes read. + * + * @param bytesRead The number of bytes read. + */ + private void commitBytesRead(int bytesRead) { + if (bytesRead != C.RESULT_END_OF_INPUT) { + position += bytesRead; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java new file mode 100644 index 0000000000..8425f89860 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DefaultExtractorsFactory.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.amr.AmrExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flac.FlacExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flv.FlvExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.Mp4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg.OggExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.PsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.wav.WavExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.lang.reflect.Constructor; + +/** + * An {@link ExtractorsFactory} that provides an array of extractors for the following formats: + * + * <ul> + * <li>MP4, including M4A ({@link Mp4Extractor}) + * <li>fMP4 ({@link FragmentedMp4Extractor}) + * <li>Matroska and WebM ({@link MatroskaExtractor}) + * <li>Ogg Vorbis/FLAC ({@link OggExtractor} + * <li>MP3 ({@link Mp3Extractor}) + * <li>AAC ({@link AdtsExtractor}) + * <li>MPEG TS ({@link TsExtractor}) + * <li>MPEG PS ({@link PsExtractor}) + * <li>FLV ({@link FlvExtractor}) + * <li>WAV ({@link WavExtractor}) + * <li>AC3 ({@link Ac3Extractor}) + * <li>AC4 ({@link Ac4Extractor}) + * <li>AMR ({@link AmrExtractor}) + * <li>FLAC + * <ul> + * <li>If available, the FLAC extension extractor is used. + * <li>Otherwise, the core {@link FlacExtractor} is used. Note that Android devices do not + * generally include a FLAC decoder before API 27. This can be worked around by using + * the FLAC extension or the FFmpeg extension. + * </ul> + * </ul> + */ +public final class DefaultExtractorsFactory implements ExtractorsFactory { + + private static final Constructor<? extends Extractor> FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR; + + static { + Constructor<? extends Extractor> flacExtensionExtractorConstructor = null; + try { + // LINT.IfChange + @SuppressWarnings("nullness:argument.type.incompatible") + boolean isFlacNativeLibraryAvailable = + Boolean.TRUE.equals( + Class.forName("com.google.android.exoplayer2.ext.flac.FlacLibrary") + .getMethod("isAvailable") + .invoke(/* obj= */ null)); + if (isFlacNativeLibraryAvailable) { + flacExtensionExtractorConstructor = + Class.forName("com.google.android.exoplayer2.ext.flac.FlacExtractor") + .asSubclass(Extractor.class) + .getConstructor(); + } + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the app was built without the FLAC extension. + } catch (Exception e) { + // The FLAC extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating FLAC extension", e); + } + FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR = flacExtensionExtractorConstructor; + } + + private boolean constantBitrateSeekingEnabled; + private @AdtsExtractor.Flags int adtsFlags; + private @AmrExtractor.Flags int amrFlags; + private @MatroskaExtractor.Flags int matroskaFlags; + private @Mp4Extractor.Flags int mp4Flags; + private @FragmentedMp4Extractor.Flags int fragmentedMp4Flags; + private @Mp3Extractor.Flags int mp3Flags; + private @TsExtractor.Mode int tsMode; + private @DefaultTsPayloadReaderFactory.Flags int tsFlags; + + public DefaultExtractorsFactory() { + tsMode = TsExtractor.MODE_SINGLE_PMT; + } + + /** + * Convenience method to set whether approximate seeking using constant bitrate assumptions should + * be enabled for all extractors that support it. If set to true, the flags required to enable + * this functionality will be OR'd with those passed to the setters when creating extractor + * instances. If set to false then the flags passed to the setters will be used without + * modification. + * + * @param constantBitrateSeekingEnabled Whether approximate seeking using a constant bitrate + * assumption should be enabled for all extractors that support it. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setConstantBitrateSeekingEnabled( + boolean constantBitrateSeekingEnabled) { + this.constantBitrateSeekingEnabled = constantBitrateSeekingEnabled; + return this; + } + + /** + * Sets flags for {@link AdtsExtractor} instances created by the factory. + * + * @see AdtsExtractor#AdtsExtractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setAdtsExtractorFlags( + @AdtsExtractor.Flags int flags) { + this.adtsFlags = flags; + return this; + } + + /** + * Sets flags for {@link AmrExtractor} instances created by the factory. + * + * @see AmrExtractor#AmrExtractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setAmrExtractorFlags(@AmrExtractor.Flags int flags) { + this.amrFlags = flags; + return this; + } + + /** + * Sets flags for {@link MatroskaExtractor} instances created by the factory. + * + * @see MatroskaExtractor#MatroskaExtractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setMatroskaExtractorFlags( + @MatroskaExtractor.Flags int flags) { + this.matroskaFlags = flags; + return this; + } + + /** + * Sets flags for {@link Mp4Extractor} instances created by the factory. + * + * @see Mp4Extractor#Mp4Extractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setMp4ExtractorFlags(@Mp4Extractor.Flags int flags) { + this.mp4Flags = flags; + return this; + } + + /** + * Sets flags for {@link FragmentedMp4Extractor} instances created by the factory. + * + * @see FragmentedMp4Extractor#FragmentedMp4Extractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setFragmentedMp4ExtractorFlags( + @FragmentedMp4Extractor.Flags int flags) { + this.fragmentedMp4Flags = flags; + return this; + } + + /** + * Sets flags for {@link Mp3Extractor} instances created by the factory. + * + * @see Mp3Extractor#Mp3Extractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setMp3ExtractorFlags(@Mp3Extractor.Flags int flags) { + mp3Flags = flags; + return this; + } + + /** + * Sets the mode for {@link TsExtractor} instances created by the factory. + * + * @see TsExtractor#TsExtractor(int, TimestampAdjuster, TsPayloadReader.Factory) + * @param mode The mode to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setTsExtractorMode(@TsExtractor.Mode int mode) { + tsMode = mode; + return this; + } + + /** + * Sets flags for {@link DefaultTsPayloadReaderFactory}s used by {@link TsExtractor} instances + * created by the factory. + * + * @see TsExtractor#TsExtractor(int) + * @param flags The flags to use. + * @return The factory, for convenience. + */ + public synchronized DefaultExtractorsFactory setTsExtractorFlags( + @DefaultTsPayloadReaderFactory.Flags int flags) { + tsFlags = flags; + return this; + } + + @Override + public synchronized Extractor[] createExtractors() { + Extractor[] extractors = new Extractor[14]; + extractors[0] = new MatroskaExtractor(matroskaFlags); + extractors[1] = new FragmentedMp4Extractor(fragmentedMp4Flags); + extractors[2] = new Mp4Extractor(mp4Flags); + extractors[3] = + new Mp3Extractor( + mp3Flags + | (constantBitrateSeekingEnabled + ? Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0)); + extractors[4] = + new AdtsExtractor( + adtsFlags + | (constantBitrateSeekingEnabled + ? AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0)); + extractors[5] = new Ac3Extractor(); + extractors[6] = new TsExtractor(tsMode, tsFlags); + extractors[7] = new FlvExtractor(); + extractors[8] = new OggExtractor(); + extractors[9] = new PsExtractor(); + extractors[10] = new WavExtractor(); + extractors[11] = + new AmrExtractor( + amrFlags + | (constantBitrateSeekingEnabled + ? AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING + : 0)); + extractors[12] = new Ac4Extractor(); + if (FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR != null) { + try { + extractors[13] = FLAC_EXTENSION_EXTRACTOR_CONSTRUCTOR.newInstance(); + } catch (Exception e) { + // Should never happen. + throw new IllegalStateException("Unexpected error creating FLAC extractor", e); + } + } else { + extractors[13] = new FlacExtractor(); + } + return extractors; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java new file mode 100644 index 0000000000..06c90ae874 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DummyExtractorOutput.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +/** A dummy {@link ExtractorOutput} implementation. */ +public final class DummyExtractorOutput implements ExtractorOutput { + + @Override + public TrackOutput track(int id, int type) { + return new DummyTrackOutput(); + } + + @Override + public void endTracks() { + // Do nothing. + } + + @Override + public void seekMap(SeekMap seekMap) { + // Do nothing. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DummyTrackOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DummyTrackOutput.java new file mode 100644 index 0000000000..6df947731d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/DummyTrackOutput.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; + +/** + * A dummy {@link TrackOutput} implementation. + */ +public final class DummyTrackOutput implements TrackOutput { + + @Override + public void format(Format format) { + // Do nothing. + } + + @Override + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + int bytesSkipped = input.skip(length); + if (bytesSkipped == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } + throw new EOFException(); + } + return bytesSkipped; + } + + @Override + public void sampleData(ParsableByteArray data, int length) { + data.skipBytes(length); + } + + @Override + public void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData cryptoData) { + // Do nothing. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/Extractor.java new file mode 100644 index 0000000000..aeb7028c3f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/Extractor.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Extracts media data from a container format. + */ +public interface Extractor { + + /** + * Returned by {@link #read(ExtractorInput, PositionHolder)} if the {@link ExtractorInput} passed + * to the next {@link #read(ExtractorInput, PositionHolder)} is required to provide data + * continuing from the position in the stream reached by the returning call. + */ + int RESULT_CONTINUE = 0; + /** + * Returned by {@link #read(ExtractorInput, PositionHolder)} if the {@link ExtractorInput} passed + * to the next {@link #read(ExtractorInput, PositionHolder)} is required to provide data starting + * from a specified position in the stream. + */ + int RESULT_SEEK = 1; + /** + * Returned by {@link #read(ExtractorInput, PositionHolder)} if the end of the + * {@link ExtractorInput} was reached. Equal to {@link C#RESULT_END_OF_INPUT}. + */ + int RESULT_END_OF_INPUT = C.RESULT_END_OF_INPUT; + + /** + * Result values that can be returned by {@link #read(ExtractorInput, PositionHolder)}. One of + * {@link #RESULT_CONTINUE}, {@link #RESULT_SEEK} or {@link #RESULT_END_OF_INPUT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {RESULT_CONTINUE, RESULT_SEEK, RESULT_END_OF_INPUT}) + @interface ReadResult {} + + /** + * Returns whether this extractor can extract samples from the {@link ExtractorInput}, which must + * provide data from the start of the stream. + * <p> + * If {@code true} is returned, the {@code input}'s reading position may have been modified. + * Otherwise, only its peek position may have been modified. + * + * @param input The {@link ExtractorInput} from which data should be peeked/read. + * @return Whether this extractor can read the provided input. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + boolean sniff(ExtractorInput input) throws IOException, InterruptedException; + + /** + * Initializes the extractor with an {@link ExtractorOutput}. Called at most once. + * + * @param output An {@link ExtractorOutput} to receive extracted data. + */ + void init(ExtractorOutput output); + + /** + * Extracts data read from a provided {@link ExtractorInput}. Must not be called before {@link + * #init(ExtractorOutput)}. + * + * <p>A single call to this method will block until some progress has been made, but will not + * block for longer than this. Hence each call will consume only a small amount of input data. + * + * <p>In the common case, {@link #RESULT_CONTINUE} is returned to indicate that the {@link + * ExtractorInput} passed to the next read is required to provide data continuing from the + * position in the stream reached by the returning call. If the extractor requires data to be + * provided from a different position, then that position is set in {@code seekPosition} and + * {@link #RESULT_SEEK} is returned. If the extractor reached the end of the data provided by the + * {@link ExtractorInput}, then {@link #RESULT_END_OF_INPUT} is returned. + * + * <p>When this method throws an {@link IOException} or an {@link InterruptedException}, + * extraction may continue by providing an {@link ExtractorInput} with an unchanged {@link + * ExtractorInput#getPosition() read position} to a subsequent call to this method. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param seekPosition If {@link #RESULT_SEEK} is returned, this holder is updated to hold the + * position of the required data. + * @return One of the {@code RESULT_} values defined in this interface. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + @ReadResult + int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException; + + /** + * Notifies the extractor that a seek has occurred. + * <p> + * Following a call to this method, the {@link ExtractorInput} passed to the next invocation of + * {@link #read(ExtractorInput, PositionHolder)} is required to provide data starting from {@code + * position} in the stream. Valid random access positions are the start of the stream and + * positions that can be obtained from any {@link SeekMap} passed to the {@link ExtractorOutput}. + * + * @param position The byte offset in the stream from which data will be provided. + * @param timeUs The seek time in microseconds. + */ + void seek(long position, long timeUs); + + /** + * Releases all kept resources. + */ + void release(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorInput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorInput.java new file mode 100644 index 0000000000..351df1e79e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorInput.java @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Provides data to be consumed by an {@link Extractor}. + * + * <p>This interface provides two modes of accessing the underlying input. See the subheadings below + * for more info about each mode. + * + * <ul> + * <li>The {@code read()/peek()} and {@code skip()} methods provide {@link InputStream}-like + * byte-level access operations. + * <li>The {@code read/skip/peekFully()} and {@code advancePeekPosition()} methods assume the user + * wants to read an entire block/frame/header of known length. + * </ul> + * + * <h3>{@link InputStream}-like methods</h3> + * + * <p>The {@code read()/peek()} and {@code skip()} methods provide {@link InputStream}-like + * byte-level access operations. The {@code length} parameter is a maximum, and each method returns + * the number of bytes actually processed. This may be less than {@code length} because the end of + * the input was reached, or the method was interrupted, or the operation was aborted early for + * another reason. + * + * <h3>Block-based methods</h3> + * + * <p>The {@code read/skip/peekFully()} and {@code advancePeekPosition()} methods assume the user + * wants to read an entire block/frame/header of known length. + * + * <p>These methods all have a variant that takes a boolean {@code allowEndOfInput} parameter. This + * parameter is intended to be set to true when the caller believes the input might be fully + * exhausted before the call is made (i.e. they've previously read/skipped/peeked the final + * block/frame/header). It's <b>not</b> intended to allow a partial read (i.e. greater than 0 bytes, + * but less than {@code length}) to succeed - this will always throw an {@link EOFException} from + * these methods (a partial read is assumed to indicate a malformed block/frame/header - and + * therefore a malformed file). + * + * <p>The expected behaviour of the block-based methods is therefore: + * + * <ul> + * <li>Already at end-of-input and {@code allowEndOfInput=false}: Throw {@link EOFException}. + * <li>Already at end-of-input and {@code allowEndOfInput=true}: Return {@code false}. + * <li>Encounter end-of-input during read/skip/peek/advance: Throw {@link EOFException} + * (regardless of {@code allowEndOfInput}). + * </ul> + */ +public interface ExtractorInput { + + /** + * Reads up to {@code length} bytes from the input and resets the peek position. + * <p> + * This method blocks until at least one byte of data can be read, the end of the input is + * detected, or an exception is thrown. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to read from the input. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the input has ended. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + int read(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Like {@link #read(byte[], int, int)}, but reads the requested {@code length} in full. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The number of bytes to read from the input. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@code false} being returned. False if it should be + * considered an error, causing an {@link EOFException} to be thrown. See note in class + * Javadoc. + * @return True if the read was successful. False if {@code allowEndOfInput=true} and the end of + * the input was encountered having read no data. + * @throws EOFException If the end of input was encountered having partially satisfied the read + * (i.e. having read at least one byte, but fewer than {@code length}), or if no bytes were + * read and {@code allowEndOfInput} is false. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + boolean readFully(byte[] target, int offset, int length, boolean allowEndOfInput) + throws IOException, InterruptedException; + + /** + * Equivalent to {@link #readFully(byte[], int, int, boolean) readFully(target, offset, length, + * false)}. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The number of bytes to read from the input. + * @throws EOFException If the end of input was encountered. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void readFully(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Like {@link #read(byte[], int, int)}, except the data is skipped instead of read. + * + * @param length The maximum number of bytes to skip from the input. + * @return The number of bytes skipped, or {@link C#RESULT_END_OF_INPUT} if the input has ended. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + int skip(int length) throws IOException, InterruptedException; + + /** + * Like {@link #readFully(byte[], int, int, boolean)}, except the data is skipped instead of read. + * + * @param length The number of bytes to skip from the input. + * @param allowEndOfInput True if encountering the end of the input having skipped no data is + * allowed, and should result in {@code false} being returned. False if it should be + * considered an error, causing an {@link EOFException} to be thrown. See note in class + * Javadoc. + * @return True if the skip was successful. False if {@code allowEndOfInput=true} and the end of + * the input was encountered having skipped no data. + * @throws EOFException If the end of input was encountered having partially satisfied the skip + * (i.e. having skipped at least one byte, but fewer than {@code length}), or if no bytes were + * skipped and {@code allowEndOfInput} is false. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + boolean skipFully(int length, boolean allowEndOfInput) throws IOException, InterruptedException; + + /** + * Like {@link #readFully(byte[], int, int)}, except the data is skipped instead of read. + * <p> + * Encountering the end of input is always considered an error, and will result in an + * {@link EOFException} being thrown. + * + * @param length The number of bytes to skip from the input. + * @throws EOFException If the end of input was encountered. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void skipFully(int length) throws IOException, InterruptedException; + + /** + * Peeks up to {@code length} bytes from the peek position. The current read position is left + * unchanged. + * + * <p>This method blocks until at least one byte of data can be peeked, the end of the input is + * detected, or an exception is thrown. + * + * <p>Calling {@link #resetPeekPosition()} resets the peek position to equal the current read + * position, so the caller can peek the same data again. Reading or skipping also resets the peek + * position. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to peek from the input. + * @return The number of bytes peeked, or {@link C#RESULT_END_OF_INPUT} if the input has ended. + * @throws IOException If an error occurs peeking from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + int peek(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Like {@link #peek(byte[], int, int)}, but peeks the requested {@code length} in full. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The number of bytes to peek from the input. + * @param allowEndOfInput True if encountering the end of the input having peeked no data is + * allowed, and should result in {@code false} being returned. False if it should be + * considered an error, causing an {@link EOFException} to be thrown. See note in class + * Javadoc. + * @return True if the peek was successful. False if {@code allowEndOfInput=true} and the end of + * the input was encountered having peeked no data. + * @throws EOFException If the end of input was encountered having partially satisfied the peek + * (i.e. having peeked at least one byte, but fewer than {@code length}), or if no bytes were + * peeked and {@code allowEndOfInput} is false. + * @throws IOException If an error occurs peeking from the input. + * @throws InterruptedException If the thread is interrupted. + */ + boolean peekFully(byte[] target, int offset, int length, boolean allowEndOfInput) + throws IOException, InterruptedException; + + /** + * Equivalent to {@link #peekFully(byte[], int, int, boolean) peekFully(target, offset, length, + * false)}. + * + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The number of bytes to peek from the input. + * @throws EOFException If the end of input was encountered. + * @throws IOException If an error occurs peeking from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void peekFully(byte[] target, int offset, int length) throws IOException, InterruptedException; + + /** + * Advances the peek position by {@code length} bytes. Like {@link #peekFully(byte[], int, int, + * boolean)} except the data is skipped instead of read. + * + * @param length The number of bytes by which to advance the peek position. + * @param allowEndOfInput True if encountering the end of the input before advancing is allowed, + * and should result in {@code false} being returned. False if it should be considered an + * error, causing an {@link EOFException} to be thrown. See note in class Javadoc. + * @return True if advancing the peek position was successful. False if {@code + * allowEndOfInput=true} and the end of the input was encountered before advancing over any + * data. + * @throws EOFException If the end of input was encountered having partially advanced (i.e. having + * advanced by at least one byte, but fewer than {@code length}), or if the end of input was + * encountered before advancing and {@code allowEndOfInput} is false. + * @throws IOException If an error occurs advancing the peek position. + * @throws InterruptedException If the thread is interrupted. + */ + boolean advancePeekPosition(int length, boolean allowEndOfInput) + throws IOException, InterruptedException; + + /** + * Advances the peek position by {@code length} bytes. Like {@link #peekFully(byte[], int, int)} + * except the data is skipped instead of read. + * + * @param length The number of bytes to peek from the input. + * @throws EOFException If the end of input was encountered. + * @throws IOException If an error occurs peeking from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void advancePeekPosition(int length) throws IOException, InterruptedException; + + /** + * Resets the peek position to equal the current read position. + */ + void resetPeekPosition(); + + /** + * Returns the current peek position (byte offset) in the stream. + * + * @return The peek position (byte offset) in the stream. + */ + long getPeekPosition(); + + /** + * Returns the current read position (byte offset) in the stream. + * + * @return The read position (byte offset) in the stream. + */ + long getPosition(); + + /** + * Returns the length of the source stream, or {@link C#LENGTH_UNSET} if it is unknown. + * + * @return The length of the source stream, or {@link C#LENGTH_UNSET}. + */ + long getLength(); + + /** + * Called when reading fails and the required retry position is different from the last position. + * After setting the retry position it throws the given {@link Throwable}. + * + * @param <E> Type of {@link Throwable} to be thrown. + * @param position The required retry position. + * @param e {@link Throwable} to be thrown. + * @throws E The given {@link Throwable} object. + */ + <E extends Throwable> void setRetryPosition(long position, E e) throws E; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorOutput.java new file mode 100644 index 0000000000..8708758265 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorOutput.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +/** + * Receives stream level data extracted by an {@link Extractor}. + */ +public interface ExtractorOutput { + + /** + * Called by the {@link Extractor} to get the {@link TrackOutput} for a specific track. + * <p> + * The same {@link TrackOutput} is returned if multiple calls are made with the same {@code id}. + * + * @param id A track identifier. + * @param type The type of the track. Typically one of the {@link org.mozilla.thirdparty.com.google.android.exoplayer2C} + * {@code TRACK_TYPE_*} constants. + * @return The {@link TrackOutput} for the given track identifier. + */ + TrackOutput track(int id, int type); + + /** + * Called when all tracks have been identified, meaning no new {@code trackId} values will be + * passed to {@link #track(int, int)}. + */ + void endTracks(); + + /** + * Called when a {@link SeekMap} has been extracted from the stream. + * + * @param seekMap The extracted {@link SeekMap}. + */ + void seekMap(SeekMap seekMap); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorUtil.java new file mode 100644 index 0000000000..6951f7e311 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorUtil.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.IOException; + +/** Extractor related utility methods. */ +/* package */ final class ExtractorUtil { + + /** + * Peeks {@code length} bytes from the input peek position, or all the bytes to the end of the + * input if there was less than {@code length} bytes left. + * + * <p>If an exception is thrown, there is no guarantee on the peek position. + * + * @param input The stream input to peek the data from. + * @param target A target array into which data should be written. + * @param offset The offset into the target array at which to write. + * @param length The maximum number of bytes to peek from the input. + * @return The number of bytes peeked. + * @throws IOException If an error occurs peeking from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + public static int peekToLength(ExtractorInput input, byte[] target, int offset, int length) + throws IOException, InterruptedException { + int totalBytesPeeked = 0; + while (totalBytesPeeked < length) { + int bytesPeeked = input.peek(target, offset + totalBytesPeeked, length - totalBytesPeeked); + if (bytesPeeked == C.RESULT_END_OF_INPUT) { + break; + } + totalBytesPeeked += bytesPeeked; + } + return totalBytesPeeked; + } + + private ExtractorUtil() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorsFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorsFactory.java new file mode 100644 index 0000000000..64b803f65e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ExtractorsFactory.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +/** Factory for arrays of {@link Extractor} instances. */ +public interface ExtractorsFactory { + + /** Returns an array of new {@link Extractor} instances. */ + Extractor[] createExtractors(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacFrameReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacFrameReader.java new file mode 100644 index 0000000000..e8d2b4928b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacFrameReader.java @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacConstants; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacStreamMetadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * Reads and peeks FLAC frame elements according to the <a + * href="https://xiph.org/flac/format.html">FLAC format specification</a>. + */ +public final class FlacFrameReader { + + /** Holds a sample number. */ + public static final class SampleNumberHolder { + /** The sample number. */ + public long sampleNumber; + } + + /** + * Checks whether the given FLAC frame header is valid and, if so, reads it and writes the frame + * first sample number in {@code sampleNumberHolder}. + * + * <p>If the header is valid, the position of {@code data} is moved to the byte following it. + * Otherwise, there is no guarantee on the position. + * + * @param data The array to read the data from, whose position must correspond to the frame + * header. + * @param flacStreamMetadata The stream metadata. + * @param frameStartMarker The frame start marker of the stream. + * @param sampleNumberHolder The holder used to contain the sample number. + * @return Whether the frame header is valid. + */ + public static boolean checkAndReadFrameHeader( + ParsableByteArray data, + FlacStreamMetadata flacStreamMetadata, + int frameStartMarker, + SampleNumberHolder sampleNumberHolder) { + int frameStartPosition = data.getPosition(); + + long frameHeaderBytes = data.readUnsignedInt(); + if (frameHeaderBytes >>> 16 != frameStartMarker) { + return false; + } + + boolean isBlockSizeVariable = (frameHeaderBytes >>> 16 & 1) == 1; + int blockSizeKey = (int) (frameHeaderBytes >> 12 & 0xF); + int sampleRateKey = (int) (frameHeaderBytes >> 8 & 0xF); + int channelAssignmentKey = (int) (frameHeaderBytes >> 4 & 0xF); + int bitsPerSampleKey = (int) (frameHeaderBytes >> 1 & 0x7); + boolean reservedBit = (frameHeaderBytes & 1) == 1; + return checkChannelAssignment(channelAssignmentKey, flacStreamMetadata) + && checkBitsPerSample(bitsPerSampleKey, flacStreamMetadata) + && !reservedBit + && checkAndReadFirstSampleNumber( + data, flacStreamMetadata, isBlockSizeVariable, sampleNumberHolder) + && checkAndReadBlockSizeSamples(data, flacStreamMetadata, blockSizeKey) + && checkAndReadSampleRate(data, flacStreamMetadata, sampleRateKey) + && checkAndReadCrc(data, frameStartPosition); + } + + /** + * Checks whether the given FLAC frame header is valid and, if so, writes the frame first sample + * number in {@code sampleNumberHolder}. + * + * <p>The {@code input} peek position is left unchanged. + * + * @param input The input to get the data from, whose peek position must correspond to the frame + * header. + * @param flacStreamMetadata The stream metadata. + * @param frameStartMarker The frame start marker of the stream. + * @param sampleNumberHolder The holder used to contain the sample number. + * @return Whether the frame header is valid. + */ + public static boolean checkFrameHeaderFromPeek( + ExtractorInput input, + FlacStreamMetadata flacStreamMetadata, + int frameStartMarker, + SampleNumberHolder sampleNumberHolder) + throws IOException, InterruptedException { + long originalPeekPosition = input.getPeekPosition(); + + byte[] frameStartBytes = new byte[2]; + input.peekFully(frameStartBytes, 0, 2); + int frameStart = (frameStartBytes[0] & 0xFF) << 8 | (frameStartBytes[1] & 0xFF); + if (frameStart != frameStartMarker) { + input.resetPeekPosition(); + input.advancePeekPosition((int) (originalPeekPosition - input.getPosition())); + return false; + } + + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.MAX_FRAME_HEADER_SIZE); + System.arraycopy( + frameStartBytes, /* srcPos= */ 0, scratch.data, /* destPos= */ 0, /* length= */ 2); + + int totalBytesPeeked = + ExtractorUtil.peekToLength(input, scratch.data, 2, FlacConstants.MAX_FRAME_HEADER_SIZE - 2); + scratch.setLimit(totalBytesPeeked); + + input.resetPeekPosition(); + input.advancePeekPosition((int) (originalPeekPosition - input.getPosition())); + + return checkAndReadFrameHeader( + scratch, flacStreamMetadata, frameStartMarker, sampleNumberHolder); + } + + /** + * Returns the number of the first sample in the given frame. + * + * <p>The read position of {@code input} is left unchanged. + * + * <p>If no exception is thrown, the peek position is aligned with the read position. Otherwise, + * there is no guarantee on the peek position. + * + * @param input Input stream to get the sample number from (starting from the read position). + * @return The frame first sample number. + * @throws ParserException If an error occurs parsing the sample number. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + */ + public static long getFirstSampleNumber( + ExtractorInput input, FlacStreamMetadata flacStreamMetadata) + throws IOException, InterruptedException { + input.resetPeekPosition(); + input.advancePeekPosition(1); + byte[] blockingStrategyByte = new byte[1]; + input.peekFully(blockingStrategyByte, 0, 1); + boolean isBlockSizeVariable = (blockingStrategyByte[0] & 1) == 1; + input.advancePeekPosition(2); + + int maxUtf8SampleNumberSize = isBlockSizeVariable ? 7 : 6; + ParsableByteArray scratch = new ParsableByteArray(maxUtf8SampleNumberSize); + int totalBytesPeeked = + ExtractorUtil.peekToLength(input, scratch.data, 0, maxUtf8SampleNumberSize); + scratch.setLimit(totalBytesPeeked); + input.resetPeekPosition(); + + SampleNumberHolder sampleNumberHolder = new SampleNumberHolder(); + if (!checkAndReadFirstSampleNumber( + scratch, flacStreamMetadata, isBlockSizeVariable, sampleNumberHolder)) { + throw new ParserException(); + } + + return sampleNumberHolder.sampleNumber; + } + + /** + * Reads the given block size. + * + * @param data The array to read the data from, whose position must correspond to the block size + * bits. + * @param blockSizeKey The key in the block size lookup table. + * @return The block size in samples, or -1 if the {@code blockSizeKey} is invalid. + */ + public static int readFrameBlockSizeSamplesFromKey(ParsableByteArray data, int blockSizeKey) { + switch (blockSizeKey) { + case 1: + return 192; + case 2: + case 3: + case 4: + case 5: + return 576 << (blockSizeKey - 2); + case 6: + return data.readUnsignedByte() + 1; + case 7: + return data.readUnsignedShort() + 1; + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + case 14: + case 15: + return 256 << (blockSizeKey - 8); + default: + return -1; + } + } + + /** + * Checks whether the given channel assignment is valid. + * + * @param channelAssignmentKey The channel assignment lookup key. + * @param flacStreamMetadata The stream metadata. + * @return Whether the channel assignment is valid. + */ + private static boolean checkChannelAssignment( + int channelAssignmentKey, FlacStreamMetadata flacStreamMetadata) { + if (channelAssignmentKey <= 7) { + return channelAssignmentKey == flacStreamMetadata.channels - 1; + } else if (channelAssignmentKey <= 10) { + return flacStreamMetadata.channels == 2; + } else { + return false; + } + } + + /** + * Checks whether the given number of bits per sample is valid. + * + * @param bitsPerSampleKey The bits per sample lookup key. + * @param flacStreamMetadata The stream metadata. + * @return Whether the number of bits per sample is valid. + */ + private static boolean checkBitsPerSample( + int bitsPerSampleKey, FlacStreamMetadata flacStreamMetadata) { + if (bitsPerSampleKey == 0) { + return true; + } + return bitsPerSampleKey == flacStreamMetadata.bitsPerSampleLookupKey; + } + + /** + * Checks whether the given sample number is valid and, if so, reads it and writes it in {@code + * sampleNumberHolder}. + * + * <p>If the sample number is valid, the position of {@code data} is moved to the byte following + * it. Otherwise, there is no guarantee on the position. + * + * @param data The array to read the data from, whose position must correspond to the sample + * number data. + * @param flacStreamMetadata The stream metadata. + * @param isBlockSizeVariable Whether the stream blocking strategy is variable block size or fixed + * block size. + * @param sampleNumberHolder The holder used to contain the sample number. + * @return Whether the sample number is valid. + */ + private static boolean checkAndReadFirstSampleNumber( + ParsableByteArray data, + FlacStreamMetadata flacStreamMetadata, + boolean isBlockSizeVariable, + SampleNumberHolder sampleNumberHolder) { + long utf8Value; + try { + utf8Value = data.readUtf8EncodedLong(); + } catch (NumberFormatException e) { + return false; + } + + sampleNumberHolder.sampleNumber = + isBlockSizeVariable ? utf8Value : utf8Value * flacStreamMetadata.maxBlockSizeSamples; + return true; + } + + /** + * Checks whether the given frame block size key and block size bits are valid and, if so, reads + * the block size bits. + * + * <p>If the block size is valid, the position of {@code data} is moved to the byte following the + * block size bits. Otherwise, there is no guarantee on the position. + * + * @param data The array to read the data from, whose position must correspond to the block size + * bits. + * @param flacStreamMetadata The stream metadata. + * @param blockSizeKey The key in the block size lookup table. + * @return Whether the block size is valid. + */ + private static boolean checkAndReadBlockSizeSamples( + ParsableByteArray data, FlacStreamMetadata flacStreamMetadata, int blockSizeKey) { + int blockSizeSamples = readFrameBlockSizeSamplesFromKey(data, blockSizeKey); + return blockSizeSamples != -1 && blockSizeSamples <= flacStreamMetadata.maxBlockSizeSamples; + } + + /** + * Checks whether the given sample rate key and sample rate bits are valid and, if so, reads the + * sample rate bits. + * + * <p>If the sample rate is valid, the position of {@code data} is moved to the byte following the + * sample rate bits. Otherwise, there is no guarantee on the position. + * + * @param data The array to read the data from, whose position must indicate the sample rate bits. + * @param flacStreamMetadata The stream metadata. + * @param sampleRateKey The key in the sample rate lookup table. + * @return Whether the sample rate is valid. + */ + private static boolean checkAndReadSampleRate( + ParsableByteArray data, FlacStreamMetadata flacStreamMetadata, int sampleRateKey) { + int expectedSampleRate = flacStreamMetadata.sampleRate; + if (sampleRateKey == 0) { + return true; + } else if (sampleRateKey <= 11) { + return sampleRateKey == flacStreamMetadata.sampleRateLookupKey; + } else if (sampleRateKey == 12) { + return data.readUnsignedByte() * 1000 == expectedSampleRate; + } else if (sampleRateKey <= 14) { + int sampleRate = data.readUnsignedShort(); + if (sampleRateKey == 14) { + sampleRate *= 10; + } + return sampleRate == expectedSampleRate; + } else { + return false; + } + } + + /** + * Checks whether the given CRC is valid and, if so, reads it. + * + * <p>If the CRC is valid, the position of {@code data} is moved to the byte following it. + * Otherwise, there is no guarantee on the position. + * + * <p>The {@code data} array must contain the whole frame header. + * + * @param data The array to read the data from, whose position must indicate the CRC. + * @param frameStartPosition The frame start offset in {@code data}. + * @return Whether the CRC is valid. + */ + private static boolean checkAndReadCrc(ParsableByteArray data, int frameStartPosition) { + int crc = data.readUnsignedByte(); + int frameEndPosition = data.getPosition(); + int expectedCrc = + Util.crc8(data.data, frameStartPosition, frameEndPosition - 1, /* initialValue= */ 0); + return crc == expectedCrc; + } + + private FlacFrameReader() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacMetadataReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacMetadataReader.java new file mode 100644 index 0000000000..c5413cf459 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacMetadataReader.java @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.VorbisUtil.CommentHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.flac.PictureFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacConstants; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacStreamMetadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Reads and peeks FLAC stream metadata elements according to the <a + * href="https://xiph.org/flac/format.html">FLAC format specification</a>. + */ +public final class FlacMetadataReader { + + /** Holds a {@link FlacStreamMetadata}. */ + public static final class FlacStreamMetadataHolder { + /** The FLAC stream metadata. */ + @Nullable public FlacStreamMetadata flacStreamMetadata; + + public FlacStreamMetadataHolder(@Nullable FlacStreamMetadata flacStreamMetadata) { + this.flacStreamMetadata = flacStreamMetadata; + } + } + + private static final int STREAM_MARKER = 0x664C6143; // ASCII for "fLaC" + private static final int SYNC_CODE = 0x3FFE; + private static final int SEEK_POINT_SIZE = 18; + + /** + * Peeks ID3 Data. + * + * @param input Input stream to peek the ID3 data from. + * @param parseData Whether to parse the ID3 frames. + * @return The parsed ID3 data, or {@code null} if there is no such data or if {@code parseData} + * is {@code false}. + * @throws IOException If peeking from the input fails. In this case, there is no guarantee on the + * peek position. + * @throws InterruptedException If interrupted while peeking from input. In this case, there is no + * guarantee on the peek position. + */ + @Nullable + public static Metadata peekId3Metadata(ExtractorInput input, boolean parseData) + throws IOException, InterruptedException { + @Nullable + Id3Decoder.FramePredicate id3FramePredicate = parseData ? null : Id3Decoder.NO_FRAMES_PREDICATE; + @Nullable Metadata id3Metadata = new Id3Peeker().peekId3Data(input, id3FramePredicate); + return id3Metadata == null || id3Metadata.length() == 0 ? null : id3Metadata; + } + + /** + * Peeks the FLAC stream marker. + * + * @param input Input stream to peek the stream marker from. + * @return Whether the data peeked is the FLAC stream marker. + * @throws IOException If peeking from the input fails. In this case, the peek position is left + * unchanged. + * @throws InterruptedException If interrupted while peeking from input. In this case, the peek + * position is left unchanged. + */ + public static boolean checkAndPeekStreamMarker(ExtractorInput input) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); + input.peekFully(scratch.data, 0, FlacConstants.STREAM_MARKER_SIZE); + return scratch.readUnsignedInt() == STREAM_MARKER; + } + + /** + * Reads ID3 Data. + * + * <p>If no exception is thrown, the peek position of {@code input} is aligned with the read + * position. + * + * @param input Input stream to read the ID3 data from. + * @param parseData Whether to parse the ID3 frames. + * @return The parsed ID3 data, or {@code null} if there is no such data or if {@code parseData} + * is {@code false}. + * @throws IOException If reading from the input fails. In this case, the read position is left + * unchanged and there is no guarantee on the peek position. + * @throws InterruptedException If interrupted while reading from input. In this case, the read + * position is left unchanged and there is no guarantee on the peek position. + */ + @Nullable + public static Metadata readId3Metadata(ExtractorInput input, boolean parseData) + throws IOException, InterruptedException { + input.resetPeekPosition(); + long startingPeekPosition = input.getPeekPosition(); + @Nullable Metadata id3Metadata = peekId3Metadata(input, parseData); + int peekedId3Bytes = (int) (input.getPeekPosition() - startingPeekPosition); + input.skipFully(peekedId3Bytes); + return id3Metadata; + } + + /** + * Reads the FLAC stream marker. + * + * @param input Input stream to read the stream marker from. + * @throws ParserException If an error occurs parsing the stream marker. In this case, the + * position of {@code input} is advanced by {@link FlacConstants#STREAM_MARKER_SIZE} bytes. + * @throws IOException If reading from the input fails. In this case, the position is left + * unchanged. + * @throws InterruptedException If interrupted while reading from input. In this case, the + * position is left unchanged. + */ + public static void readStreamMarker(ExtractorInput input) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(FlacConstants.STREAM_MARKER_SIZE); + input.readFully(scratch.data, 0, FlacConstants.STREAM_MARKER_SIZE); + if (scratch.readUnsignedInt() != STREAM_MARKER) { + throw new ParserException("Failed to read FLAC stream marker."); + } + } + + /** + * Reads one FLAC metadata block. + * + * <p>If no exception is thrown, the peek position of {@code input} is aligned with the read + * position. + * + * @param input Input stream to read the metadata block from (header included). + * @param metadataHolder A holder for the metadata read. If the stream info block (which must be + * the first metadata block) is read, the holder contains a new instance representing the + * stream info data. If the block read is a Vorbis comment block or a picture block, the + * holder contains a copy of the existing stream metadata with the corresponding metadata + * added. Otherwise, the metadata in the holder is unchanged. + * @return Whether the block read is the last metadata block. + * @throws IllegalArgumentException If the block read is not a stream info block and the metadata + * in {@code metadataHolder} is {@code null}. In this case, the read position will be at the + * start of a metadata block and there is no guarantee on the peek position. + * @throws IOException If reading from the input fails. In this case, the read position will be at + * the start of a metadata block and there is no guarantee on the peek position. + * @throws InterruptedException If interrupted while reading from input. In this case, the read + * position will be at the start of a metadata block and there is no guarantee on the peek + * position. + */ + public static boolean readMetadataBlock( + ExtractorInput input, FlacStreamMetadataHolder metadataHolder) + throws IOException, InterruptedException { + input.resetPeekPosition(); + ParsableBitArray scratch = new ParsableBitArray(new byte[4]); + input.peekFully(scratch.data, 0, FlacConstants.METADATA_BLOCK_HEADER_SIZE); + + boolean isLastMetadataBlock = scratch.readBit(); + int type = scratch.readBits(7); + int length = FlacConstants.METADATA_BLOCK_HEADER_SIZE + scratch.readBits(24); + if (type == FlacConstants.METADATA_TYPE_STREAM_INFO) { + metadataHolder.flacStreamMetadata = readStreamInfoBlock(input); + } else { + FlacStreamMetadata flacStreamMetadata = metadataHolder.flacStreamMetadata; + if (flacStreamMetadata == null) { + throw new IllegalArgumentException(); + } + if (type == FlacConstants.METADATA_TYPE_SEEK_TABLE) { + FlacStreamMetadata.SeekTable seekTable = readSeekTableMetadataBlock(input, length); + metadataHolder.flacStreamMetadata = flacStreamMetadata.copyWithSeekTable(seekTable); + } else if (type == FlacConstants.METADATA_TYPE_VORBIS_COMMENT) { + List<String> vorbisComments = readVorbisCommentMetadataBlock(input, length); + metadataHolder.flacStreamMetadata = + flacStreamMetadata.copyWithVorbisComments(vorbisComments); + } else if (type == FlacConstants.METADATA_TYPE_PICTURE) { + PictureFrame pictureFrame = readPictureMetadataBlock(input, length); + metadataHolder.flacStreamMetadata = + flacStreamMetadata.copyWithPictureFrames(Collections.singletonList(pictureFrame)); + } else { + input.skipFully(length); + } + } + + return isLastMetadataBlock; + } + + /** + * Reads a FLAC seek table metadata block. + * + * <p>The position of {@code data} is moved to the byte following the seek table metadata block + * (placeholder points included). + * + * @param data The array to read the data from, whose position must correspond to the seek table + * metadata block (header included). + * @return The seek table, without the placeholder points. + */ + public static FlacStreamMetadata.SeekTable readSeekTableMetadataBlock(ParsableByteArray data) { + data.skipBytes(1); + int length = data.readUnsignedInt24(); + + long seekTableEndPosition = data.getPosition() + length; + int seekPointCount = length / SEEK_POINT_SIZE; + long[] pointSampleNumbers = new long[seekPointCount]; + long[] pointOffsets = new long[seekPointCount]; + for (int i = 0; i < seekPointCount; i++) { + // The sample number is expected to fit in a signed long, except if it is a placeholder, in + // which case its value is -1. + long sampleNumber = data.readLong(); + if (sampleNumber == -1) { + pointSampleNumbers = Arrays.copyOf(pointSampleNumbers, i); + pointOffsets = Arrays.copyOf(pointOffsets, i); + break; + } + pointSampleNumbers[i] = sampleNumber; + pointOffsets[i] = data.readLong(); + data.skipBytes(2); + } + + data.skipBytes((int) (seekTableEndPosition - data.getPosition())); + return new FlacStreamMetadata.SeekTable(pointSampleNumbers, pointOffsets); + } + + /** + * Returns the frame start marker, consisting of the 2 first bytes of the first frame. + * + * <p>The read position of {@code input} is left unchanged and the peek position is aligned with + * the read position. + * + * @param input Input stream to get the start marker from (starting from the read position). + * @return The frame start marker (which must be the same for all the frames in the stream). + * @throws ParserException If an error occurs parsing the frame start marker. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + */ + public static int getFrameStartMarker(ExtractorInput input) + throws IOException, InterruptedException { + input.resetPeekPosition(); + ParsableByteArray scratch = new ParsableByteArray(2); + input.peekFully(scratch.data, 0, 2); + + int frameStartMarker = scratch.readUnsignedShort(); + int syncCode = frameStartMarker >> 2; + if (syncCode != SYNC_CODE) { + input.resetPeekPosition(); + throw new ParserException("First frame does not start with sync code."); + } + + input.resetPeekPosition(); + return frameStartMarker; + } + + private static FlacStreamMetadata readStreamInfoBlock(ExtractorInput input) + throws IOException, InterruptedException { + byte[] scratchData = new byte[FlacConstants.STREAM_INFO_BLOCK_SIZE]; + input.readFully(scratchData, 0, FlacConstants.STREAM_INFO_BLOCK_SIZE); + return new FlacStreamMetadata( + scratchData, /* offset= */ FlacConstants.METADATA_BLOCK_HEADER_SIZE); + } + + private static FlacStreamMetadata.SeekTable readSeekTableMetadataBlock( + ExtractorInput input, int length) throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(length); + input.readFully(scratch.data, 0, length); + return readSeekTableMetadataBlock(scratch); + } + + private static List<String> readVorbisCommentMetadataBlock(ExtractorInput input, int length) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(length); + input.readFully(scratch.data, 0, length); + scratch.skipBytes(FlacConstants.METADATA_BLOCK_HEADER_SIZE); + CommentHeader commentHeader = + VorbisUtil.readVorbisCommentHeader( + scratch, /* hasMetadataHeader= */ false, /* hasFramingBit= */ false); + return Arrays.asList(commentHeader.comments); + } + + private static PictureFrame readPictureMetadataBlock(ExtractorInput input, int length) + throws IOException, InterruptedException { + ParsableByteArray scratch = new ParsableByteArray(length); + input.readFully(scratch.data, 0, length); + scratch.skipBytes(FlacConstants.METADATA_BLOCK_HEADER_SIZE); + + int pictureType = scratch.readInt(); + int mimeTypeLength = scratch.readInt(); + String mimeType = scratch.readString(mimeTypeLength, Charset.forName(C.ASCII_NAME)); + int descriptionLength = scratch.readInt(); + String description = scratch.readString(descriptionLength); + int width = scratch.readInt(); + int height = scratch.readInt(); + int depth = scratch.readInt(); + int colors = scratch.readInt(); + int pictureDataLength = scratch.readInt(); + byte[] pictureData = new byte[pictureDataLength]; + scratch.readBytes(pictureData, 0, pictureDataLength); + + return new PictureFrame( + pictureType, mimeType, description, width, height, depth, colors, pictureData); + } + + private FlacMetadataReader() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacSeekTableSeekMap.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacSeekTableSeekMap.java new file mode 100644 index 0000000000..56d54596ac --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/FlacSeekTableSeekMap.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacStreamMetadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * A {@link SeekMap} implementation for FLAC streams that contain a <a + * href="https://xiph.org/flac/format.html#metadata_block_seektable">seek table</a>. + */ +public final class FlacSeekTableSeekMap implements SeekMap { + + private final FlacStreamMetadata flacStreamMetadata; + private final long firstFrameOffset; + + /** + * Creates a seek map from the FLAC stream seek table. + * + * @param flacStreamMetadata The stream metadata. + * @param firstFrameOffset The byte offset of the first frame in the stream. + */ + public FlacSeekTableSeekMap(FlacStreamMetadata flacStreamMetadata, long firstFrameOffset) { + this.flacStreamMetadata = flacStreamMetadata; + this.firstFrameOffset = firstFrameOffset; + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public long getDurationUs() { + return flacStreamMetadata.getDurationUs(); + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + Assertions.checkNotNull(flacStreamMetadata.seekTable); + long[] pointSampleNumbers = flacStreamMetadata.seekTable.pointSampleNumbers; + long[] pointOffsets = flacStreamMetadata.seekTable.pointOffsets; + + long targetSampleNumber = flacStreamMetadata.getSampleNumber(timeUs); + int index = + Util.binarySearchFloor( + pointSampleNumbers, + targetSampleNumber, + /* inclusive= */ true, + /* stayInBounds= */ false); + + long seekPointSampleNumber = index == -1 ? 0 : pointSampleNumbers[index]; + long seekPointOffsetFromFirstFrame = index == -1 ? 0 : pointOffsets[index]; + SeekPoint seekPoint = getSeekPoint(seekPointSampleNumber, seekPointOffsetFromFirstFrame); + if (seekPoint.timeUs == timeUs || index == pointSampleNumbers.length - 1) { + return new SeekPoints(seekPoint); + } else { + SeekPoint secondSeekPoint = + getSeekPoint(pointSampleNumbers[index + 1], pointOffsets[index + 1]); + return new SeekPoints(seekPoint, secondSeekPoint); + } + } + + private SeekPoint getSeekPoint(long sampleNumber, long offsetFromFirstFrame) { + long seekTimeUs = sampleNumber * C.MICROS_PER_SECOND / flacStreamMetadata.sampleRate; + long seekPosition = firstFrameOffset + offsetFromFirstFrame; + return new SeekPoint(seekTimeUs, seekPosition); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java new file mode 100644 index 0000000000..11893d6136 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/GaplessInfoHolder.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.CommentFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.InternalFrame; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Holder for gapless playback information. + */ +public final class GaplessInfoHolder { + + private static final String GAPLESS_DOMAIN = "com.apple.iTunes"; + private static final String GAPLESS_DESCRIPTION = "iTunSMPB"; + private static final Pattern GAPLESS_COMMENT_PATTERN = + Pattern.compile("^ [0-9a-fA-F]{8} ([0-9a-fA-F]{8}) ([0-9a-fA-F]{8})"); + + /** + * The number of samples to trim from the start of the decoded audio stream, or + * {@link Format#NO_VALUE} if not set. + */ + public int encoderDelay; + + /** + * The number of samples to trim from the end of the decoded audio stream, or + * {@link Format#NO_VALUE} if not set. + */ + public int encoderPadding; + + /** + * Creates a new holder for gapless playback information. + */ + public GaplessInfoHolder() { + encoderDelay = Format.NO_VALUE; + encoderPadding = Format.NO_VALUE; + } + + /** + * Populates the holder with data from an MP3 Xing header, if valid and non-zero. + * + * @param value The 24-bit value to decode. + * @return Whether the holder was populated. + */ + public boolean setFromXingHeaderValue(int value) { + int encoderDelay = value >> 12; + int encoderPadding = value & 0x0FFF; + if (encoderDelay > 0 || encoderPadding > 0) { + this.encoderDelay = encoderDelay; + this.encoderPadding = encoderPadding; + return true; + } + return false; + } + + /** + * Populates the holder with data parsed from ID3 {@link Metadata}. + * + * @param metadata The metadata from which to parse the gapless information. + * @return Whether the holder was populated. + */ + public boolean setFromMetadata(Metadata metadata) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof CommentFrame) { + CommentFrame commentFrame = (CommentFrame) entry; + if (GAPLESS_DESCRIPTION.equals(commentFrame.description) + && setFromComment(commentFrame.text)) { + return true; + } + } else if (entry instanceof InternalFrame) { + InternalFrame internalFrame = (InternalFrame) entry; + if (GAPLESS_DOMAIN.equals(internalFrame.domain) + && GAPLESS_DESCRIPTION.equals(internalFrame.description) + && setFromComment(internalFrame.text)) { + return true; + } + } + } + return false; + } + + /** + * Populates the holder with data parsed from a gapless playback comment (stored in an ID3 header + * or MPEG 4 user data), if valid and non-zero. + * + * @param data The comment's payload data. + * @return Whether the holder was populated. + */ + private boolean setFromComment(String data) { + Matcher matcher = GAPLESS_COMMENT_PATTERN.matcher(data); + if (matcher.find()) { + try { + int encoderDelay = Integer.parseInt(matcher.group(1), 16); + int encoderPadding = Integer.parseInt(matcher.group(2), 16); + if (encoderDelay > 0 || encoderPadding > 0) { + this.encoderDelay = encoderDelay; + this.encoderPadding = encoderPadding; + return true; + } + } catch (NumberFormatException e) { + // Ignore incorrectly formatted comments. + } + } + return false; + } + + /** + * Returns whether {@link #encoderDelay} and {@link #encoderPadding} have been set. + */ + public boolean hasGaplessInfo() { + return encoderDelay != Format.NO_VALUE && encoderPadding != Format.NO_VALUE; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/Id3Peeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/Id3Peeker.java new file mode 100644 index 0000000000..a0a26c76d8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/Id3Peeker.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; + +/** + * Peeks data from the beginning of an {@link ExtractorInput} to determine if there is any ID3 tag. + */ +public final class Id3Peeker { + + private final ParsableByteArray scratch; + + public Id3Peeker() { + scratch = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + } + + /** + * Peeks ID3 data from the input and parses the first ID3 tag. + * + * @param input The {@link ExtractorInput} from which data should be peeked. + * @param id3FramePredicate Determines which ID3 frames are decoded. May be null to decode all + * frames. + * @return The first ID3 tag decoded into a {@link Metadata} object. May be null if ID3 tag is not + * present in the input. + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + @Nullable + public Metadata peekId3Data( + ExtractorInput input, @Nullable Id3Decoder.FramePredicate id3FramePredicate) + throws IOException, InterruptedException { + int peekedId3Bytes = 0; + Metadata metadata = null; + while (true) { + try { + input.peekFully(scratch.data, /* offset= */ 0, Id3Decoder.ID3_HEADER_LENGTH); + } catch (EOFException e) { + // If input has less than ID3_HEADER_LENGTH, ignore the rest. + break; + } + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != Id3Decoder.ID3_TAG) { + // Not an ID3 tag. + break; + } + scratch.skipBytes(3); // Skip major version, minor version and flags. + int framesLength = scratch.readSynchSafeInt(); + int tagLength = Id3Decoder.ID3_HEADER_LENGTH + framesLength; + + if (metadata == null) { + byte[] id3Data = new byte[tagLength]; + System.arraycopy(scratch.data, 0, id3Data, 0, Id3Decoder.ID3_HEADER_LENGTH); + input.peekFully(id3Data, Id3Decoder.ID3_HEADER_LENGTH, framesLength); + + metadata = new Id3Decoder(id3FramePredicate).decode(id3Data, tagLength); + } else { + input.advancePeekPosition(framesLength); + } + + peekedId3Bytes += tagLength; + } + + input.resetPeekPosition(); + input.advancePeekPosition(peekedId3Bytes); + return metadata; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/MpegAudioHeader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/MpegAudioHeader.java new file mode 100644 index 0000000000..66c3411094 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/MpegAudioHeader.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +/** + * An MPEG audio frame header. + */ +public final class MpegAudioHeader { + + /** + * Theoretical maximum frame size for an MPEG audio stream, which occurs when playing a Layer 2 + * MPEG 2.5 audio stream at 16 kb/s (with padding). The size is 1152 sample/frame * + * 160000 bit/s / (8000 sample/s * 8 bit/byte) + 1 padding byte/frame = 2881 byte/frame. + * The next power of two size is 4 KiB. + */ + public static final int MAX_FRAME_SIZE_BYTES = 4096; + + private static final String[] MIME_TYPE_BY_LAYER = + new String[] {MimeTypes.AUDIO_MPEG_L1, MimeTypes.AUDIO_MPEG_L2, MimeTypes.AUDIO_MPEG}; + private static final int[] SAMPLING_RATE_V1 = {44100, 48000, 32000}; + private static final int[] BITRATE_V1_L1 = { + 32000, 64000, 96000, 128000, 160000, 192000, 224000, 256000, 288000, 320000, 352000, 384000, + 416000, 448000 + }; + private static final int[] BITRATE_V2_L1 = { + 32000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 144000, 160000, 176000, 192000, + 224000, 256000 + }; + private static final int[] BITRATE_V1_L2 = { + 32000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, + 320000, 384000 + }; + private static final int[] BITRATE_V1_L3 = { + 32000, 40000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 160000, 192000, 224000, 256000, + 320000 + }; + private static final int[] BITRATE_V2 = { + 8000, 16000, 24000, 32000, 40000, 48000, 56000, 64000, 80000, 96000, 112000, 128000, 144000, + 160000 + }; + + private static final int SAMPLES_PER_FRAME_L1 = 384; + private static final int SAMPLES_PER_FRAME_L2 = 1152; + private static final int SAMPLES_PER_FRAME_L3_V1 = 1152; + private static final int SAMPLES_PER_FRAME_L3_V2 = 576; + + /** + * Returns the size of the frame associated with {@code header}, or {@link C#LENGTH_UNSET} if it + * is invalid. + */ + public static int getFrameSize(int header) { + if (!isMagicPresent(header)) { + return C.LENGTH_UNSET; + } + + int version = (header >>> 19) & 3; + if (version == 1) { + return C.LENGTH_UNSET; + } + + int layer = (header >>> 17) & 3; + if (layer == 0) { + return C.LENGTH_UNSET; + } + + int bitrateIndex = (header >>> 12) & 15; + if (bitrateIndex == 0 || bitrateIndex == 0xF) { + // Disallow "free" bitrate. + return C.LENGTH_UNSET; + } + + int samplingRateIndex = (header >>> 10) & 3; + if (samplingRateIndex == 3) { + return C.LENGTH_UNSET; + } + + int samplingRate = SAMPLING_RATE_V1[samplingRateIndex]; + if (version == 2) { + // Version 2 + samplingRate /= 2; + } else if (version == 0) { + // Version 2.5 + samplingRate /= 4; + } + + int bitrate; + int padding = (header >>> 9) & 1; + if (layer == 3) { + // Layer I (layer == 3) + bitrate = version == 3 ? BITRATE_V1_L1[bitrateIndex - 1] : BITRATE_V2_L1[bitrateIndex - 1]; + return (12 * bitrate / samplingRate + padding) * 4; + } else { + // Layer II (layer == 2) or III (layer == 1) + if (version == 3) { + bitrate = layer == 2 ? BITRATE_V1_L2[bitrateIndex - 1] : BITRATE_V1_L3[bitrateIndex - 1]; + } else { + // Version 2 or 2.5. + bitrate = BITRATE_V2[bitrateIndex - 1]; + } + } + + if (version == 3) { + // Version 1 + return 144 * bitrate / samplingRate + padding; + } else { + // Version 2 or 2.5 + return (layer == 1 ? 72 : 144) * bitrate / samplingRate + padding; + } + } + + /** + * Returns the number of samples per frame associated with {@code header}, or {@link + * C#LENGTH_UNSET} if it is invalid. + */ + public static int getFrameSampleCount(int header) { + + if (!isMagicPresent(header)) { + return C.LENGTH_UNSET; + } + + int version = (header >>> 19) & 3; + if (version == 1) { + return C.LENGTH_UNSET; + } + + int layer = (header >>> 17) & 3; + if (layer == 0) { + return C.LENGTH_UNSET; + } + + // Those header values are not used but are checked for consistency with the other methods + int bitrateIndex = (header >>> 12) & 15; + int samplingRateIndex = (header >>> 10) & 3; + if (bitrateIndex == 0 || bitrateIndex == 0xF || samplingRateIndex == 3) { + return C.LENGTH_UNSET; + } + + return getFrameSizeInSamples(version, layer); + } + + /** + * Parses {@code headerData}, populating {@code header} with the parsed data. + * + * @param headerData Header data to parse. + * @param header Header to populate with data from {@code headerData}. + * @return True if the header was populated. False otherwise, indicating that {@code headerData} + * is not a valid MPEG audio header. + */ + public static boolean populateHeader(int headerData, MpegAudioHeader header) { + if (!isMagicPresent(headerData)) { + return false; + } + + int version = (headerData >>> 19) & 3; + if (version == 1) { + return false; + } + + int layer = (headerData >>> 17) & 3; + if (layer == 0) { + return false; + } + + int bitrateIndex = (headerData >>> 12) & 15; + if (bitrateIndex == 0 || bitrateIndex == 0xF) { + // Disallow "free" bitrate. + return false; + } + + int samplingRateIndex = (headerData >>> 10) & 3; + if (samplingRateIndex == 3) { + return false; + } + + int sampleRate = SAMPLING_RATE_V1[samplingRateIndex]; + if (version == 2) { + // Version 2 + sampleRate /= 2; + } else if (version == 0) { + // Version 2.5 + sampleRate /= 4; + } + + int padding = (headerData >>> 9) & 1; + int bitrate; + int frameSize; + int samplesPerFrame = getFrameSizeInSamples(version, layer); + if (layer == 3) { + // Layer I (layer == 3) + bitrate = version == 3 ? BITRATE_V1_L1[bitrateIndex - 1] : BITRATE_V2_L1[bitrateIndex - 1]; + frameSize = (12 * bitrate / sampleRate + padding) * 4; + } else { + // Layer II (layer == 2) or III (layer == 1) + if (version == 3) { + // Version 1 + bitrate = layer == 2 ? BITRATE_V1_L2[bitrateIndex - 1] : BITRATE_V1_L3[bitrateIndex - 1]; + frameSize = 144 * bitrate / sampleRate + padding; + } else { + // Version 2 or 2.5. + bitrate = BITRATE_V2[bitrateIndex - 1]; + frameSize = (layer == 1 ? 72 : 144) * bitrate / sampleRate + padding; + } + } + + String mimeType = MIME_TYPE_BY_LAYER[3 - layer]; + int channels = ((headerData >> 6) & 3) == 3 ? 1 : 2; + header.setValues(version, mimeType, frameSize, sampleRate, channels, bitrate, samplesPerFrame); + return true; + } + + private static boolean isMagicPresent(int header) { + return (header & 0xFFE00000) == 0xFFE00000; + } + + private static int getFrameSizeInSamples(int version, int layer) { + switch (layer) { + case 1: + return version == 3 ? SAMPLES_PER_FRAME_L3_V1 : SAMPLES_PER_FRAME_L3_V2; // Layer III + case 2: + return SAMPLES_PER_FRAME_L2; // Layer II + case 3: + return SAMPLES_PER_FRAME_L1; // Layer I + } + throw new IllegalArgumentException(); + } + + /** MPEG audio header version. */ + public int version; + /** The mime type. */ + @Nullable public String mimeType; + /** Size of the frame associated with this header, in bytes. */ + public int frameSize; + /** Sample rate in samples per second. */ + public int sampleRate; + /** Number of audio channels in the frame. */ + public int channels; + /** Bitrate of the frame in bit/s. */ + public int bitrate; + /** Number of samples stored in the frame. */ + public int samplesPerFrame; + + private void setValues( + int version, + String mimeType, + int frameSize, + int sampleRate, + int channels, + int bitrate, + int samplesPerFrame) { + this.version = version; + this.mimeType = mimeType; + this.frameSize = frameSize; + this.sampleRate = sampleRate; + this.channels = channels; + this.bitrate = bitrate; + this.samplesPerFrame = samplesPerFrame; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/PositionHolder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/PositionHolder.java new file mode 100644 index 0000000000..feae7f0bc7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/PositionHolder.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +/** + * Holds a position in the stream. + */ +public final class PositionHolder { + + /** + * The held position. + */ + public long position; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/SeekMap.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/SeekMap.java new file mode 100644 index 0000000000..b3ccad214d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/SeekMap.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Maps seek positions (in microseconds) to corresponding positions (byte offsets) in the stream. + */ +public interface SeekMap { + + /** A {@link SeekMap} that does not support seeking. */ + class Unseekable implements SeekMap { + + private final long durationUs; + private final SeekPoints startSeekPoints; + + /** + * @param durationUs The duration of the stream in microseconds, or {@link C#TIME_UNSET} if the + * duration is unknown. + */ + public Unseekable(long durationUs) { + this(durationUs, 0); + } + + /** + * @param durationUs The duration of the stream in microseconds, or {@link C#TIME_UNSET} if the + * duration is unknown. + * @param startPosition The position (byte offset) of the start of the media. + */ + public Unseekable(long durationUs, long startPosition) { + this.durationUs = durationUs; + startSeekPoints = + new SeekPoints(startPosition == 0 ? SeekPoint.START : new SeekPoint(0, startPosition)); + } + + @Override + public boolean isSeekable() { + return false; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + return startSeekPoints; + } + } + + /** Contains one or two {@link SeekPoint}s. */ + final class SeekPoints { + + /** The first seek point. */ + public final SeekPoint first; + /** The second seek point, or {@link #first} if there's only one seek point. */ + public final SeekPoint second; + + /** @param point The single seek point. */ + public SeekPoints(SeekPoint point) { + this(point, point); + } + + /** + * @param first The first seek point. + * @param second The second seek point. + */ + public SeekPoints(SeekPoint first, SeekPoint second) { + this.first = Assertions.checkNotNull(first); + this.second = Assertions.checkNotNull(second); + } + + @Override + public String toString() { + return "[" + first + (first.equals(second) ? "" : (", " + second)) + "]"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + SeekPoints other = (SeekPoints) obj; + return first.equals(other.first) && second.equals(other.second); + } + + @Override + public int hashCode() { + return (31 * first.hashCode()) + second.hashCode(); + } + } + + /** + * Returns whether seeking is supported. + * + * @return Whether seeking is supported. + */ + boolean isSeekable(); + + /** + * Returns the duration of the stream in microseconds. + * + * @return The duration of the stream in microseconds, or {@link C#TIME_UNSET} if the duration is + * unknown. + */ + long getDurationUs(); + + /** + * Obtains seek points for the specified seek time in microseconds. The returned {@link + * SeekPoints} will contain one or two distinct seek points. + * + * <p>Two seek points [A, B] are returned in the case that seeking can only be performed to + * discrete points in time, there does not exist a seek point at exactly the requested time, and + * there exist seek points on both sides of it. In this case A and B are the closest seek points + * before and after the requested time. A single seek point is returned in all other cases. + * + * @param timeUs A seek time in microseconds. + * @return The corresponding seek points. + */ + SeekPoints getSeekPoints(long timeUs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/SeekPoint.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/SeekPoint.java new file mode 100644 index 0000000000..1c4db35203 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/SeekPoint.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; + +/** Defines a seek point in a media stream. */ +public final class SeekPoint { + + /** A {@link SeekPoint} whose time and byte offset are both set to 0. */ + public static final SeekPoint START = new SeekPoint(0, 0); + + /** The time of the seek point, in microseconds. */ + public final long timeUs; + + /** The byte offset of the seek point. */ + public final long position; + + /** + * @param timeUs The time of the seek point, in microseconds. + * @param position The byte offset of the seek point. + */ + public SeekPoint(long timeUs, long position) { + this.timeUs = timeUs; + this.position = position; + } + + @Override + public String toString() { + return "[timeUs=" + timeUs + ", position=" + position + "]"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + SeekPoint other = (SeekPoint) obj; + return timeUs == other.timeUs && position == other.position; + } + + @Override + public int hashCode() { + int result = (int) timeUs; + result = 31 * result + (int) position; + return result; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/TrackOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/TrackOutput.java new file mode 100644 index 0000000000..fd33bd6027 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/TrackOutput.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.util.Arrays; + +/** + * Receives track level data extracted by an {@link Extractor}. + */ +public interface TrackOutput { + + /** + * Holds data required to decrypt a sample. + */ + final class CryptoData { + + /** + * The encryption mode used for the sample. + */ + @C.CryptoMode public final int cryptoMode; + + /** + * The encryption key associated with the sample. Its contents must not be modified. + */ + public final byte[] encryptionKey; + + /** + * The number of encrypted blocks in the encryption pattern, 0 if pattern encryption does not + * apply. + */ + public final int encryptedBlocks; + + /** + * The number of clear blocks in the encryption pattern, 0 if pattern encryption does not + * apply. + */ + public final int clearBlocks; + + /** + * @param cryptoMode See {@link #cryptoMode}. + * @param encryptionKey See {@link #encryptionKey}. + * @param encryptedBlocks See {@link #encryptedBlocks}. + * @param clearBlocks See {@link #clearBlocks}. + */ + public CryptoData(@C.CryptoMode int cryptoMode, byte[] encryptionKey, int encryptedBlocks, + int clearBlocks) { + this.cryptoMode = cryptoMode; + this.encryptionKey = encryptionKey; + this.encryptedBlocks = encryptedBlocks; + this.clearBlocks = clearBlocks; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + CryptoData other = (CryptoData) obj; + return cryptoMode == other.cryptoMode && encryptedBlocks == other.encryptedBlocks + && clearBlocks == other.clearBlocks && Arrays.equals(encryptionKey, other.encryptionKey); + } + + @Override + public int hashCode() { + int result = cryptoMode; + result = 31 * result + Arrays.hashCode(encryptionKey); + result = 31 * result + encryptedBlocks; + result = 31 * result + clearBlocks; + return result; + } + + } + + /** + * Called when the {@link Format} of the track has been extracted from the stream. + * + * @param format The extracted {@link Format}. + */ + void format(Format format); + + /** + * Called to write sample data to the output. + * + * @param input An {@link ExtractorInput} from which to read the sample data. + * @param length The maximum length to read from the input. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it + * should be considered an error, causing an {@link EOFException} to be thrown. + * @return The number of bytes appended. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException; + + /** + * Called to write sample data to the output. + * + * @param data A {@link ParsableByteArray} from which to read the sample data. + * @param length The number of bytes to read, starting from {@code data.getPosition()}. + */ + void sampleData(ParsableByteArray data, int length); + + /** + * Called when metadata associated with a sample has been extracted from the stream. + * + * <p>The corresponding sample data will have already been passed to the output via calls to + * {@link #sampleData(ExtractorInput, int, boolean)} or {@link #sampleData(ParsableByteArray, + * int)}. + * + * @param timeUs The media timestamp associated with the sample, in microseconds. + * @param flags Flags associated with the sample. See {@code C.BUFFER_FLAG_*}. + * @param size The size of the sample data, in bytes. + * @param offset The number of bytes that have been passed to {@link #sampleData(ExtractorInput, + * int, boolean)} or {@link #sampleData(ParsableByteArray, int)} since the last byte belonging + * to the sample whose metadata is being passed. + * @param encryptionData The encryption data required to decrypt the sample. May be null. + */ + void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData encryptionData); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/VorbisBitArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/VorbisBitArray.java new file mode 100644 index 0000000000..4ea27c0149 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/VorbisBitArray.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Wraps a byte array, providing methods that allow it to be read as a Vorbis bitstream. + * + * @see <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-360002">Vorbis bitpacking + * specification</a> + */ +public final class VorbisBitArray { + + private final byte[] data; + private final int byteLimit; + + private int byteOffset; + private int bitOffset; + + /** + * Creates a new instance that wraps an existing array. + * + * @param data the array to wrap. + */ + public VorbisBitArray(byte[] data) { + this.data = data; + byteLimit = data.length; + } + + /** + * Resets the reading position to zero. + */ + public void reset() { + byteOffset = 0; + bitOffset = 0; + } + + /** + * Reads a single bit. + * + * @return {@code true} if the bit is set, {@code false} otherwise. + */ + public boolean readBit() { + boolean returnValue = (((data[byteOffset] & 0xFF) >> bitOffset) & 0x01) == 1; + skipBits(1); + return returnValue; + } + + /** + * Reads up to 32 bits. + * + * @param numBits The number of bits to read. + * @return An integer whose bottom {@code numBits} bits hold the read data. + */ + public int readBits(int numBits) { + int tempByteOffset = byteOffset; + int bitsRead = Math.min(numBits, 8 - bitOffset); + int returnValue = ((data[tempByteOffset++] & 0xFF) >> bitOffset) & (0xFF >> (8 - bitsRead)); + while (bitsRead < numBits) { + returnValue |= (data[tempByteOffset++] & 0xFF) << bitsRead; + bitsRead += 8; + } + returnValue &= 0xFFFFFFFF >>> (32 - numBits); + skipBits(numBits); + return returnValue; + } + + /** + * Skips {@code numberOfBits} bits. + * + * @param numBits The number of bits to skip. + */ + public void skipBits(int numBits) { + int numBytes = numBits / 8; + byteOffset += numBytes; + bitOffset += numBits - (numBytes * 8); + if (bitOffset > 7) { + byteOffset++; + bitOffset -= 8; + } + assertValidOffset(); + } + + /** + * Returns the reading position in bits. + */ + public int getPosition() { + return byteOffset * 8 + bitOffset; + } + + /** + * Sets the reading position in bits. + * + * @param position The new reading position in bits. + */ + public void setPosition(int position) { + byteOffset = position / 8; + bitOffset = position - (byteOffset * 8); + assertValidOffset(); + } + + /** + * Returns the number of remaining bits. + */ + public int bitsLeft() { + return (byteLimit - byteOffset) * 8 - bitOffset; + } + + private void assertValidOffset() { + // It is fine for position to be at the end of the array, but no further. + Assertions.checkState(byteOffset >= 0 + && (byteOffset < byteLimit || (byteOffset == byteLimit && bitOffset == 0))); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/VorbisUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/VorbisUtil.java new file mode 100644 index 0000000000..bdd3e13b99 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/VorbisUtil.java @@ -0,0 +1,522 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Arrays; + +/** Utility methods for parsing Vorbis streams. */ +public final class VorbisUtil { + + /** Vorbis comment header. */ + public static final class CommentHeader { + + public final String vendor; + public final String[] comments; + public final int length; + + public CommentHeader(String vendor, String[] comments, int length) { + this.vendor = vendor; + this.comments = comments; + this.length = length; + } + } + + /** Vorbis identification header. */ + public static final class VorbisIdHeader { + + public final long version; + public final int channels; + public final long sampleRate; + public final int bitrateMax; + public final int bitrateNominal; + public final int bitrateMin; + public final int blockSize0; + public final int blockSize1; + public final boolean framingFlag; + public final byte[] data; + + public VorbisIdHeader( + long version, + int channels, + long sampleRate, + int bitrateMax, + int bitrateNominal, + int bitrateMin, + int blockSize0, + int blockSize1, + boolean framingFlag, + byte[] data) { + this.version = version; + this.channels = channels; + this.sampleRate = sampleRate; + this.bitrateMax = bitrateMax; + this.bitrateNominal = bitrateNominal; + this.bitrateMin = bitrateMin; + this.blockSize0 = blockSize0; + this.blockSize1 = blockSize1; + this.framingFlag = framingFlag; + this.data = data; + } + + public int getApproximateBitrate() { + return bitrateNominal == 0 ? (bitrateMin + bitrateMax) / 2 : bitrateNominal; + } + } + + /** Vorbis setup header modes. */ + public static final class Mode { + + public final boolean blockFlag; + public final int windowType; + public final int transformType; + public final int mapping; + + public Mode(boolean blockFlag, int windowType, int transformType, int mapping) { + this.blockFlag = blockFlag; + this.windowType = windowType; + this.transformType = transformType; + this.mapping = mapping; + } + } + + private static final String TAG = "VorbisUtil"; + + /** + * Returns ilog(x), which is the index of the highest set bit in {@code x}. + * + * @see <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-1190009.2.1"> + * Vorbis spec</a> + * @param x the value of which the ilog should be calculated. + * @return ilog(x) + */ + public static int iLog(int x) { + int val = 0; + while (x > 0) { + val++; + x >>>= 1; + } + return val; + } + + /** + * Reads a Vorbis identification header from {@code headerData}. + * + * @see <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-630004.2.2">Vorbis + * spec/Identification header</a> + * @param headerData a {@link ParsableByteArray} wrapping the header data. + * @return a {@link VorbisUtil.VorbisIdHeader} with meta data. + * @throws ParserException thrown if invalid capture pattern is detected. + */ + public static VorbisIdHeader readVorbisIdentificationHeader(ParsableByteArray headerData) + throws ParserException { + + verifyVorbisHeaderCapturePattern(0x01, headerData, false); + + long version = headerData.readLittleEndianUnsignedInt(); + int channels = headerData.readUnsignedByte(); + long sampleRate = headerData.readLittleEndianUnsignedInt(); + int bitrateMax = headerData.readLittleEndianInt(); + int bitrateNominal = headerData.readLittleEndianInt(); + int bitrateMin = headerData.readLittleEndianInt(); + + int blockSize = headerData.readUnsignedByte(); + int blockSize0 = (int) Math.pow(2, blockSize & 0x0F); + int blockSize1 = (int) Math.pow(2, (blockSize & 0xF0) >> 4); + + boolean framingFlag = (headerData.readUnsignedByte() & 0x01) > 0; + // raw data of Vorbis setup header has to be passed to decoder as CSD buffer #1 + byte[] data = Arrays.copyOf(headerData.data, headerData.limit()); + + return new VorbisIdHeader(version, channels, sampleRate, bitrateMax, bitrateNominal, bitrateMin, + blockSize0, blockSize1, framingFlag, data); + } + + /** + * Reads a Vorbis comment header. + * + * @see <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-640004.2.3">Vorbis + * spec/Comment header</a> + * @param headerData A {@link ParsableByteArray} wrapping the header data. + * @return A {@link VorbisUtil.CommentHeader} with all the comments. + * @throws ParserException If an error occurs parsing the comment header. + */ + public static CommentHeader readVorbisCommentHeader(ParsableByteArray headerData) + throws ParserException { + return readVorbisCommentHeader( + headerData, /* hasMetadataHeader= */ true, /* hasFramingBit= */ true); + } + + /** + * Reads a Vorbis comment header. + * + * <p>The data provided may not contain the Vorbis metadata common header and the framing bit. + * + * @see <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-640004.2.3">Vorbis + * spec/Comment header</a> + * @param headerData A {@link ParsableByteArray} wrapping the header data. + * @param hasMetadataHeader Whether the {@code headerData} contains a Vorbis metadata common + * header preceding the comment header. + * @param hasFramingBit Whether the {@code headerData} contains a framing bit. + * @return A {@link VorbisUtil.CommentHeader} with all the comments. + * @throws ParserException If an error occurs parsing the comment header. + */ + public static CommentHeader readVorbisCommentHeader( + ParsableByteArray headerData, boolean hasMetadataHeader, boolean hasFramingBit) + throws ParserException { + + if (hasMetadataHeader) { + verifyVorbisHeaderCapturePattern(/* headerType= */ 0x03, headerData, /* quiet= */ false); + } + int length = 7; + + int len = (int) headerData.readLittleEndianUnsignedInt(); + length += 4; + String vendor = headerData.readString(len); + length += vendor.length(); + + long commentListLen = headerData.readLittleEndianUnsignedInt(); + String[] comments = new String[(int) commentListLen]; + length += 4; + for (int i = 0; i < commentListLen; i++) { + len = (int) headerData.readLittleEndianUnsignedInt(); + length += 4; + comments[i] = headerData.readString(len); + length += comments[i].length(); + } + if (hasFramingBit && (headerData.readUnsignedByte() & 0x01) == 0) { + throw new ParserException("framing bit expected to be set"); + } + length += 1; + return new CommentHeader(vendor, comments, length); + } + + /** + * Verifies whether the next bytes in {@code header} are a Vorbis header of the given {@code + * headerType}. + * + * @param headerType the type of the header expected. + * @param header the alleged header bytes. + * @param quiet if {@code true} no exceptions are thrown. Instead {@code false} is returned. + * @return the number of bytes read. + * @throws ParserException thrown if header type or capture pattern is not as expected. + */ + public static boolean verifyVorbisHeaderCapturePattern( + int headerType, ParsableByteArray header, boolean quiet) throws ParserException { + if (header.bytesLeft() < 7) { + if (quiet) { + return false; + } else { + throw new ParserException("too short header: " + header.bytesLeft()); + } + } + + if (header.readUnsignedByte() != headerType) { + if (quiet) { + return false; + } else { + throw new ParserException("expected header type " + Integer.toHexString(headerType)); + } + } + + if (!(header.readUnsignedByte() == 'v' + && header.readUnsignedByte() == 'o' + && header.readUnsignedByte() == 'r' + && header.readUnsignedByte() == 'b' + && header.readUnsignedByte() == 'i' + && header.readUnsignedByte() == 's')) { + if (quiet) { + return false; + } else { + throw new ParserException("expected characters 'vorbis'"); + } + } + return true; + } + + /** + * This method reads the modes which are located at the very end of the Vorbis setup header. + * That's why we need to partially decode or at least read the entire setup header to know where + * to start reading the modes. + * + * @see <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-650004.2.4">Vorbis + * spec/Setup header</a> + * @param headerData a {@link ParsableByteArray} containing setup header data. + * @param channels the number of channels. + * @return an array of {@link Mode}s. + * @throws ParserException thrown if bit stream is invalid. + */ + public static Mode[] readVorbisModes(ParsableByteArray headerData, int channels) + throws ParserException { + + verifyVorbisHeaderCapturePattern(0x05, headerData, false); + + int numberOfBooks = headerData.readUnsignedByte() + 1; + + VorbisBitArray bitArray = new VorbisBitArray(headerData.data); + bitArray.skipBits(headerData.getPosition() * 8); + + for (int i = 0; i < numberOfBooks; i++) { + readBook(bitArray); + } + + int timeCount = bitArray.readBits(6) + 1; + for (int i = 0; i < timeCount; i++) { + if (bitArray.readBits(16) != 0x00) { + throw new ParserException("placeholder of time domain transforms not zeroed out"); + } + } + readFloors(bitArray); + readResidues(bitArray); + readMappings(channels, bitArray); + + Mode[] modes = readModes(bitArray); + if (!bitArray.readBit()) { + throw new ParserException("framing bit after modes not set as expected"); + } + return modes; + } + + private static Mode[] readModes(VorbisBitArray bitArray) { + int modeCount = bitArray.readBits(6) + 1; + Mode[] modes = new Mode[modeCount]; + for (int i = 0; i < modeCount; i++) { + boolean blockFlag = bitArray.readBit(); + int windowType = bitArray.readBits(16); + int transformType = bitArray.readBits(16); + int mapping = bitArray.readBits(8); + modes[i] = new Mode(blockFlag, windowType, transformType, mapping); + } + return modes; + } + + private static void readMappings(int channels, VorbisBitArray bitArray) + throws ParserException { + int mappingsCount = bitArray.readBits(6) + 1; + for (int i = 0; i < mappingsCount; i++) { + int mappingType = bitArray.readBits(16); + if (mappingType != 0) { + Log.e(TAG, "mapping type other than 0 not supported: " + mappingType); + continue; + } + int submaps; + if (bitArray.readBit()) { + submaps = bitArray.readBits(4) + 1; + } else { + submaps = 1; + } + int couplingSteps; + if (bitArray.readBit()) { + couplingSteps = bitArray.readBits(8) + 1; + for (int j = 0; j < couplingSteps; j++) { + bitArray.skipBits(iLog(channels - 1)); // magnitude + bitArray.skipBits(iLog(channels - 1)); // angle + } + } /*else { + couplingSteps = 0; + }*/ + if (bitArray.readBits(2) != 0x00) { + throw new ParserException("to reserved bits must be zero after mapping coupling steps"); + } + if (submaps > 1) { + for (int j = 0; j < channels; j++) { + bitArray.skipBits(4); // mappingMux + } + } + for (int j = 0; j < submaps; j++) { + bitArray.skipBits(8); // discard + bitArray.skipBits(8); // submapFloor + bitArray.skipBits(8); // submapResidue + } + } + } + + private static void readResidues(VorbisBitArray bitArray) throws ParserException { + int residueCount = bitArray.readBits(6) + 1; + for (int i = 0; i < residueCount; i++) { + int residueType = bitArray.readBits(16); + if (residueType > 2) { + throw new ParserException("residueType greater than 2 is not decodable"); + } else { + bitArray.skipBits(24); // begin + bitArray.skipBits(24); // end + bitArray.skipBits(24); // partitionSize (add one) + int classifications = bitArray.readBits(6) + 1; + bitArray.skipBits(8); // classbook + int[] cascade = new int[classifications]; + for (int j = 0; j < classifications; j++) { + int highBits = 0; + int lowBits = bitArray.readBits(3); + if (bitArray.readBit()) { + highBits = bitArray.readBits(5); + } + cascade[j] = highBits * 8 + lowBits; + } + for (int j = 0; j < classifications; j++) { + for (int k = 0; k < 8; k++) { + if ((cascade[j] & (0x01 << k)) != 0) { + bitArray.skipBits(8); // discard + } + } + } + } + } + } + + private static void readFloors(VorbisBitArray bitArray) throws ParserException { + int floorCount = bitArray.readBits(6) + 1; + for (int i = 0; i < floorCount; i++) { + int floorType = bitArray.readBits(16); + switch (floorType) { + case 0: + bitArray.skipBits(8); //order + bitArray.skipBits(16); // rate + bitArray.skipBits(16); // barkMapSize + bitArray.skipBits(6); // amplitudeBits + bitArray.skipBits(8); // amplitudeOffset + int floorNumberOfBooks = bitArray.readBits(4) + 1; + for (int j = 0; j < floorNumberOfBooks; j++) { + bitArray.skipBits(8); + } + break; + case 1: + int partitions = bitArray.readBits(5); + int maximumClass = -1; + int[] partitionClassList = new int[partitions]; + for (int j = 0; j < partitions; j++) { + partitionClassList[j] = bitArray.readBits(4); + if (partitionClassList[j] > maximumClass) { + maximumClass = partitionClassList[j]; + } + } + int[] classDimensions = new int[maximumClass + 1]; + for (int j = 0; j < classDimensions.length; j++) { + classDimensions[j] = bitArray.readBits(3) + 1; + int classSubclasses = bitArray.readBits(2); + if (classSubclasses > 0) { + bitArray.skipBits(8); // classMasterbooks + } + for (int k = 0; k < (1 << classSubclasses); k++) { + bitArray.skipBits(8); // subclassBook (subtract 1) + } + } + bitArray.skipBits(2); // multiplier (add one) + int rangeBits = bitArray.readBits(4); + int count = 0; + for (int j = 0, k = 0; j < partitions; j++) { + int idx = partitionClassList[j]; + count += classDimensions[idx]; + for (; k < count; k++) { + bitArray.skipBits(rangeBits); // floorValue + } + } + break; + default: + throw new ParserException("floor type greater than 1 not decodable: " + floorType); + } + } + } + + private static CodeBook readBook(VorbisBitArray bitArray) throws ParserException { + if (bitArray.readBits(24) != 0x564342) { + throw new ParserException("expected code book to start with [0x56, 0x43, 0x42] at " + + bitArray.getPosition()); + } + int dimensions = bitArray.readBits(16); + int entries = bitArray.readBits(24); + long[] lengthMap = new long[entries]; + + boolean isOrdered = bitArray.readBit(); + if (!isOrdered) { + boolean isSparse = bitArray.readBit(); + for (int i = 0; i < lengthMap.length; i++) { + if (isSparse) { + if (bitArray.readBit()) { + lengthMap[i] = (long) (bitArray.readBits(5) + 1); + } else { // entry unused + lengthMap[i] = 0; + } + } else { // not sparse + lengthMap[i] = (long) (bitArray.readBits(5) + 1); + } + } + } else { + int length = bitArray.readBits(5) + 1; + for (int i = 0; i < lengthMap.length;) { + int num = bitArray.readBits(iLog(entries - i)); + for (int j = 0; j < num && i < lengthMap.length; i++, j++) { + lengthMap[i] = length; + } + length++; + } + } + + int lookupType = bitArray.readBits(4); + if (lookupType > 2) { + throw new ParserException("lookup type greater than 2 not decodable: " + lookupType); + } else if (lookupType == 1 || lookupType == 2) { + bitArray.skipBits(32); // minimumValue + bitArray.skipBits(32); // deltaValue + int valueBits = bitArray.readBits(4) + 1; + bitArray.skipBits(1); // sequenceP + long lookupValuesCount; + if (lookupType == 1) { + if (dimensions != 0) { + lookupValuesCount = mapType1QuantValues(entries, dimensions); + } else { + lookupValuesCount = 0; + } + } else { + lookupValuesCount = (long) entries * dimensions; + } + // discard (no decoding required yet) + bitArray.skipBits((int) (lookupValuesCount * valueBits)); + } + return new CodeBook(dimensions, entries, lengthMap, lookupType, isOrdered); + } + + /** + * @see <a href="http://svn.xiph.org/trunk/vorbis/lib/sharedbook.c">_book_maptype1_quantvals</a> + */ + private static long mapType1QuantValues(long entries, long dimension) { + return (long) Math.floor(Math.pow(entries, 1.d / dimension)); + } + + private VorbisUtil() { + // Prevent instantiation. + } + + private static final class CodeBook { + + public final int dimensions; + public final int entries; + public final long[] lengthMap; + public final int lookupType; + public final boolean isOrdered; + + public CodeBook(int dimensions, int entries, long[] lengthMap, int lookupType, + boolean isOrdered) { + this.dimensions = dimensions; + this.entries = entries; + this.lengthMap = lengthMap; + this.lookupType = lookupType; + this.isOrdered = isOrdered; + } + + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java new file mode 100644 index 0000000000..35f539a394 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/amr/AmrExtractor.java @@ -0,0 +1,383 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.amr; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ConstantBitrateSeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; + +/** + * Extracts data from the AMR containers format (either AMR or AMR-WB). This follows RFC-4867, + * section 5. + * + * <p>This extractor only supports single-channel AMR container formats. + */ +public final class AmrExtractor implements Extractor { + + /** Factory for {@link AmrExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new AmrExtractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_ENABLE_CONSTANT_BITRATE_SEEKING}) + public @interface Flags {} + /** + * Flag to force enable seeking using a constant bitrate assumption in cases where seeking would + * otherwise not be possible. + */ + public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING = 1; + + /** + * The frame size in bytes, including header (1 byte), for each of the 16 frame types for AMR + * narrow band. + */ + private static final int[] frameSizeBytesByTypeNb = { + 13, + 14, + 16, + 18, + 20, + 21, + 27, + 32, + 6, // AMR SID + 7, // GSM-EFR SID + 6, // TDMA-EFR SID + 6, // PDC-EFR SID + 1, // Future use + 1, // Future use + 1, // Future use + 1 // No data + }; + + /** + * The frame size in bytes, including header (1 byte), for each of the 16 frame types for AMR wide + * band. + */ + private static final int[] frameSizeBytesByTypeWb = { + 18, + 24, + 33, + 37, + 41, + 47, + 51, + 59, + 61, + 6, // AMR-WB SID + 1, // Future use + 1, // Future use + 1, // Future use + 1, // Future use + 1, // speech lost + 1 // No data + }; + + private static final byte[] amrSignatureNb = Util.getUtf8Bytes("#!AMR\n"); + private static final byte[] amrSignatureWb = Util.getUtf8Bytes("#!AMR-WB\n"); + + /** Theoretical maximum frame size for a AMR frame. */ + private static final int MAX_FRAME_SIZE_BYTES = frameSizeBytesByTypeWb[8]; + /** + * The required number of samples in the stream with same sample size to classify the stream as a + * constant-bitrate-stream. + */ + private static final int NUM_SAME_SIZE_CONSTANT_BIT_RATE_THRESHOLD = 20; + + private static final int SAMPLE_RATE_WB = 16_000; + private static final int SAMPLE_RATE_NB = 8_000; + private static final int SAMPLE_TIME_PER_FRAME_US = 20_000; + + private final byte[] scratch; + private final @Flags int flags; + + private boolean isWideBand; + private long currentSampleTimeUs; + private int currentSampleSize; + private int currentSampleBytesRemaining; + private boolean hasOutputSeekMap; + private long firstSamplePosition; + private int firstSampleSize; + private int numSamplesWithSameSize; + private long timeOffsetUs; + + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; + @Nullable private SeekMap seekMap; + private boolean hasOutputFormat; + + public AmrExtractor() { + this(/* flags= */ 0); + } + + /** @param flags Flags that control the extractor's behavior. */ + public AmrExtractor(@Flags int flags) { + this.flags = flags; + scratch = new byte[1]; + firstSampleSize = C.LENGTH_UNSET; + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return readAmrHeader(input); + } + + @Override + public void init(ExtractorOutput extractorOutput) { + this.extractorOutput = extractorOutput; + trackOutput = extractorOutput.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + extractorOutput.endTracks(); + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + if (input.getPosition() == 0) { + if (!readAmrHeader(input)) { + throw new ParserException("Could not find AMR header."); + } + } + maybeOutputFormat(); + int sampleReadResult = readSample(input); + maybeOutputSeekMap(input.getLength(), sampleReadResult); + return sampleReadResult; + } + + @Override + public void seek(long position, long timeUs) { + currentSampleTimeUs = 0; + currentSampleSize = 0; + currentSampleBytesRemaining = 0; + if (position != 0 && seekMap instanceof ConstantBitrateSeekMap) { + timeOffsetUs = ((ConstantBitrateSeekMap) seekMap).getTimeUsAtPosition(position); + } else { + timeOffsetUs = 0; + } + } + + @Override + public void release() { + // Do nothing + } + + /* package */ static int frameSizeBytesByTypeNb(int frameType) { + return frameSizeBytesByTypeNb[frameType]; + } + + /* package */ static int frameSizeBytesByTypeWb(int frameType) { + return frameSizeBytesByTypeWb[frameType]; + } + + /* package */ static byte[] amrSignatureNb() { + return Arrays.copyOf(amrSignatureNb, amrSignatureNb.length); + } + + /* package */ static byte[] amrSignatureWb() { + return Arrays.copyOf(amrSignatureWb, amrSignatureWb.length); + } + + // Internal methods. + + /** + * Peeks the AMR header from the beginning of the input, and consumes it if it exists. + * + * @param input The {@link ExtractorInput} from which data should be peeked/read. + * @return Whether the AMR header has been read. + */ + private boolean readAmrHeader(ExtractorInput input) throws IOException, InterruptedException { + if (peekAmrSignature(input, amrSignatureNb)) { + isWideBand = false; + input.skipFully(amrSignatureNb.length); + return true; + } else if (peekAmrSignature(input, amrSignatureWb)) { + isWideBand = true; + input.skipFully(amrSignatureWb.length); + return true; + } + return false; + } + + /** Peeks from the beginning of the input to see if the given AMR signature exists. */ + private boolean peekAmrSignature(ExtractorInput input, byte[] amrSignature) + throws IOException, InterruptedException { + input.resetPeekPosition(); + byte[] header = new byte[amrSignature.length]; + input.peekFully(header, 0, amrSignature.length); + return Arrays.equals(header, amrSignature); + } + + private void maybeOutputFormat() { + if (!hasOutputFormat) { + hasOutputFormat = true; + String mimeType = isWideBand ? MimeTypes.AUDIO_AMR_WB : MimeTypes.AUDIO_AMR_NB; + int sampleRate = isWideBand ? SAMPLE_RATE_WB : SAMPLE_RATE_NB; + trackOutput.format( + Format.createAudioSampleFormat( + /* id= */ null, + mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + MAX_FRAME_SIZE_BYTES, + /* channelCount= */ 1, + sampleRate, + /* pcmEncoding= */ Format.NO_VALUE, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null)); + } + } + + private int readSample(ExtractorInput extractorInput) throws IOException, InterruptedException { + if (currentSampleBytesRemaining == 0) { + try { + currentSampleSize = peekNextSampleSize(extractorInput); + } catch (EOFException e) { + return RESULT_END_OF_INPUT; + } + currentSampleBytesRemaining = currentSampleSize; + if (firstSampleSize == C.LENGTH_UNSET) { + firstSamplePosition = extractorInput.getPosition(); + firstSampleSize = currentSampleSize; + } + if (firstSampleSize == currentSampleSize) { + numSamplesWithSameSize++; + } + } + + int bytesAppended = + trackOutput.sampleData( + extractorInput, currentSampleBytesRemaining, /* allowEndOfInput= */ true); + if (bytesAppended == C.RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + currentSampleBytesRemaining -= bytesAppended; + if (currentSampleBytesRemaining > 0) { + return RESULT_CONTINUE; + } + + trackOutput.sampleMetadata( + timeOffsetUs + currentSampleTimeUs, + C.BUFFER_FLAG_KEY_FRAME, + currentSampleSize, + /* offset= */ 0, + /* encryptionData= */ null); + currentSampleTimeUs += SAMPLE_TIME_PER_FRAME_US; + return RESULT_CONTINUE; + } + + private int peekNextSampleSize(ExtractorInput extractorInput) + throws IOException, InterruptedException { + extractorInput.resetPeekPosition(); + extractorInput.peekFully(scratch, /* offset= */ 0, /* length= */ 1); + + byte frameHeader = scratch[0]; + if ((frameHeader & 0x83) > 0) { + // The padding bits are at bit-1 positions in the following pattern: 1000 0011 + // Padding bits must be 0. + throw new ParserException("Invalid padding bits for frame header " + frameHeader); + } + + int frameType = (frameHeader >> 3) & 0x0f; + return getFrameSizeInBytes(frameType); + } + + private int getFrameSizeInBytes(int frameType) throws ParserException { + if (!isValidFrameType(frameType)) { + throw new ParserException( + "Illegal AMR " + (isWideBand ? "WB" : "NB") + " frame type " + frameType); + } + + return isWideBand ? frameSizeBytesByTypeWb[frameType] : frameSizeBytesByTypeNb[frameType]; + } + + private boolean isValidFrameType(int frameType) { + return frameType >= 0 + && frameType <= 15 + && (isWideBandValidFrameType(frameType) || isNarrowBandValidFrameType(frameType)); + } + + private boolean isWideBandValidFrameType(int frameType) { + // For wide band, type 10-13 are for future use. + return isWideBand && (frameType < 10 || frameType > 13); + } + + private boolean isNarrowBandValidFrameType(int frameType) { + // For narrow band, type 12-14 are for future use. + return !isWideBand && (frameType < 12 || frameType > 14); + } + + private void maybeOutputSeekMap(long inputLength, int sampleReadResult) { + if (hasOutputSeekMap) { + return; + } + + if ((flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) == 0 + || inputLength == C.LENGTH_UNSET + || (firstSampleSize != C.LENGTH_UNSET && firstSampleSize != currentSampleSize)) { + seekMap = new SeekMap.Unseekable(C.TIME_UNSET); + extractorOutput.seekMap(seekMap); + hasOutputSeekMap = true; + } else if (numSamplesWithSameSize >= NUM_SAME_SIZE_CONSTANT_BIT_RATE_THRESHOLD + || sampleReadResult == RESULT_END_OF_INPUT) { + seekMap = getConstantBitrateSeekMap(inputLength); + extractorOutput.seekMap(seekMap); + hasOutputSeekMap = true; + } + } + + private SeekMap getConstantBitrateSeekMap(long inputLength) { + int bitrate = getBitrateFromFrameSize(firstSampleSize, SAMPLE_TIME_PER_FRAME_US); + return new ConstantBitrateSeekMap(inputLength, firstSamplePosition, bitrate, firstSampleSize); + } + + /** + * Returns the stream bitrate, given a frame size and the duration of that frame in microseconds. + * + * @param frameSize The size of each frame in the stream. + * @param durationUsPerFrame The duration of the given frame in microseconds. + * @return The stream bitrate. + */ + private static int getBitrateFromFrameSize(int frameSize, long durationUsPerFrame) { + return (int) ((frameSize * C.BITS_PER_BYTE * C.MICROS_PER_SECOND) / durationUsPerFrame); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java new file mode 100644 index 0000000000..d13b1f394d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flac/FlacBinarySearchSeeker.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flac; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.BinarySearchSeeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacFrameReader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacFrameReader.SampleNumberHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacConstants; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacStreamMetadata; +import java.io.IOException; + +/** + * A {@link SeekMap} implementation for FLAC stream using binary search. + * + * <p>This seeker performs seeking by using binary search within the stream, until it finds the + * frame that contains the target sample. + */ +/* package */ final class FlacBinarySearchSeeker extends BinarySearchSeeker { + + /** + * Creates a {@link FlacBinarySearchSeeker}. + * + * @param flacStreamMetadata The stream metadata. + * @param frameStartMarker The frame start marker, consisting of the 2 bytes by which every frame + * in the stream must start. + * @param firstFramePosition The byte offset of the first frame in the stream. + * @param inputLength The length of the stream in bytes. + */ + public FlacBinarySearchSeeker( + FlacStreamMetadata flacStreamMetadata, + int frameStartMarker, + long firstFramePosition, + long inputLength) { + super( + /* seekTimestampConverter= */ flacStreamMetadata::getSampleNumber, + new FlacTimestampSeeker(flacStreamMetadata, frameStartMarker), + flacStreamMetadata.getDurationUs(), + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ flacStreamMetadata.totalSamples, + /* floorBytePosition= */ firstFramePosition, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ flacStreamMetadata.getApproxBytesPerFrame(), + /* minimumSearchRange= */ Math.max( + FlacConstants.MIN_FRAME_HEADER_SIZE, flacStreamMetadata.minFrameSize)); + } + + private static final class FlacTimestampSeeker implements TimestampSeeker { + + private final FlacStreamMetadata flacStreamMetadata; + private final int frameStartMarker; + private final SampleNumberHolder sampleNumberHolder; + + private FlacTimestampSeeker(FlacStreamMetadata flacStreamMetadata, int frameStartMarker) { + this.flacStreamMetadata = flacStreamMetadata; + this.frameStartMarker = frameStartMarker; + sampleNumberHolder = new SampleNumberHolder(); + } + + @Override + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetSampleNumber) + throws IOException, InterruptedException { + long searchPosition = input.getPosition(); + + // Find left frame. + long leftFrameFirstSampleNumber = findNextFrame(input); + long leftFramePosition = input.getPeekPosition(); + + input.advancePeekPosition( + Math.max(FlacConstants.MIN_FRAME_HEADER_SIZE, flacStreamMetadata.minFrameSize)); + + // Find right frame. + long rightFrameFirstSampleNumber = findNextFrame(input); + long rightFramePosition = input.getPeekPosition(); + + if (leftFrameFirstSampleNumber <= targetSampleNumber + && rightFrameFirstSampleNumber > targetSampleNumber) { + return TimestampSearchResult.targetFoundResult(leftFramePosition); + } else if (rightFrameFirstSampleNumber <= targetSampleNumber) { + return TimestampSearchResult.underestimatedResult( + rightFrameFirstSampleNumber, rightFramePosition); + } else { + return TimestampSearchResult.overestimatedResult( + leftFrameFirstSampleNumber, searchPosition); + } + } + + /** + * Searches for the next frame in {@code input}. + * + * <p>The peek position is advanced to the start of the found frame, or at the end of the stream + * if no frame was found. + * + * @param input The input from which to search (starting from the peek position). + * @return The number of the first sample in the found frame, or the total number of samples in + * the stream if no frame was found. + * @throws IOException If peeking from the input fails. In this case, there is no guarantee on + * the peek position. + * @throws InterruptedException If interrupted while peeking from input. In this case, there is + * no guarantee on the peek position. + */ + private long findNextFrame(ExtractorInput input) throws IOException, InterruptedException { + while (input.getPeekPosition() < input.getLength() - FlacConstants.MIN_FRAME_HEADER_SIZE + && !FlacFrameReader.checkFrameHeaderFromPeek( + input, flacStreamMetadata, frameStartMarker, sampleNumberHolder)) { + input.advancePeekPosition(1); + } + + if (input.getPeekPosition() >= input.getLength() - FlacConstants.MIN_FRAME_HEADER_SIZE) { + input.advancePeekPosition((int) (input.getLength() - input.getPeekPosition())); + return flacStreamMetadata.totalSamples; + } + + return sampleNumberHolder.sampleNumber; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java new file mode 100644 index 0000000000..fa997001e8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flac/FlacExtractor.java @@ -0,0 +1,411 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flac; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacFrameReader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacFrameReader.SampleNumberHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacMetadataReader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacSeekTableSeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacConstants; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacStreamMetadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Extracts data from FLAC container format. + * + * <p>The format specification can be found at https://xiph.org/flac/format.html. + */ +public final class FlacExtractor implements Extractor { + + /** Factory for {@link FlacExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlacExtractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_DISABLE_ID3_METADATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_ID3_METADATA}) + public @interface Flags {} + + /** + * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not + * required. + */ + public static final int FLAG_DISABLE_ID3_METADATA = 1; + + /** Parser state. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_READ_ID3_METADATA, + STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES, + STATE_READ_STREAM_MARKER, + STATE_READ_METADATA_BLOCKS, + STATE_GET_FRAME_START_MARKER, + STATE_READ_FRAMES + }) + private @interface State {} + + private static final int STATE_READ_ID3_METADATA = 0; + private static final int STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES = 1; + private static final int STATE_READ_STREAM_MARKER = 2; + private static final int STATE_READ_METADATA_BLOCKS = 3; + private static final int STATE_GET_FRAME_START_MARKER = 4; + private static final int STATE_READ_FRAMES = 5; + + /** Arbitrary buffer length of 32KB, which is ~170ms of 16-bit stereo PCM audio at 48KHz. */ + private static final int BUFFER_LENGTH = 32 * 1024; + + /** Value of an unknown sample number. */ + private static final int SAMPLE_NUMBER_UNKNOWN = -1; + + private final byte[] streamMarkerAndInfoBlock; + private final ParsableByteArray buffer; + private final boolean id3MetadataDisabled; + + private final SampleNumberHolder sampleNumberHolder; + + @MonotonicNonNull private ExtractorOutput extractorOutput; + @MonotonicNonNull private TrackOutput trackOutput; + + private @State int state; + @Nullable private Metadata id3Metadata; + @MonotonicNonNull private FlacStreamMetadata flacStreamMetadata; + private int minFrameSize; + private int frameStartMarker; + @MonotonicNonNull private FlacBinarySearchSeeker binarySearchSeeker; + private int currentFrameBytesWritten; + private long currentFrameFirstSampleNumber; + + /** Constructs an instance with {@code flags = 0}. */ + public FlacExtractor() { + this(/* flags= */ 0); + } + + /** + * Constructs an instance. + * + * @param flags Flags that control the extractor's behavior. Possible flags are described by + * {@link Flags}. + */ + public FlacExtractor(int flags) { + streamMarkerAndInfoBlock = + new byte[FlacConstants.STREAM_MARKER_SIZE + FlacConstants.STREAM_INFO_BLOCK_SIZE]; + buffer = new ParsableByteArray(new byte[BUFFER_LENGTH], /* limit= */ 0); + id3MetadataDisabled = (flags & FLAG_DISABLE_ID3_METADATA) != 0; + sampleNumberHolder = new SampleNumberHolder(); + state = STATE_READ_ID3_METADATA; + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + FlacMetadataReader.peekId3Metadata(input, /* parseData= */ false); + return FlacMetadataReader.checkAndPeekStreamMarker(input); + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + trackOutput = output.track(/* id= */ 0, C.TRACK_TYPE_AUDIO); + output.endTracks(); + } + + @Override + public @ReadResult int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + switch (state) { + case STATE_READ_ID3_METADATA: + readId3Metadata(input); + return Extractor.RESULT_CONTINUE; + case STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES: + getStreamMarkerAndInfoBlockBytes(input); + return Extractor.RESULT_CONTINUE; + case STATE_READ_STREAM_MARKER: + readStreamMarker(input); + return Extractor.RESULT_CONTINUE; + case STATE_READ_METADATA_BLOCKS: + readMetadataBlocks(input); + return Extractor.RESULT_CONTINUE; + case STATE_GET_FRAME_START_MARKER: + getFrameStartMarker(input); + return Extractor.RESULT_CONTINUE; + case STATE_READ_FRAMES: + return readFrames(input, seekPosition); + default: + throw new IllegalStateException(); + } + } + + @Override + public void seek(long position, long timeUs) { + if (position == 0) { + state = STATE_READ_ID3_METADATA; + } else if (binarySearchSeeker != null) { + binarySearchSeeker.setSeekTargetUs(timeUs); + } + currentFrameFirstSampleNumber = timeUs == 0 ? 0 : SAMPLE_NUMBER_UNKNOWN; + currentFrameBytesWritten = 0; + buffer.reset(); + } + + @Override + public void release() { + // Do nothing. + } + + // Private methods. + + private void readId3Metadata(ExtractorInput input) throws IOException, InterruptedException { + id3Metadata = FlacMetadataReader.readId3Metadata(input, /* parseData= */ !id3MetadataDisabled); + state = STATE_GET_STREAM_MARKER_AND_INFO_BLOCK_BYTES; + } + + private void getStreamMarkerAndInfoBlockBytes(ExtractorInput input) + throws IOException, InterruptedException { + input.peekFully(streamMarkerAndInfoBlock, 0, streamMarkerAndInfoBlock.length); + input.resetPeekPosition(); + state = STATE_READ_STREAM_MARKER; + } + + private void readStreamMarker(ExtractorInput input) throws IOException, InterruptedException { + FlacMetadataReader.readStreamMarker(input); + state = STATE_READ_METADATA_BLOCKS; + } + + private void readMetadataBlocks(ExtractorInput input) throws IOException, InterruptedException { + boolean isLastMetadataBlock = false; + FlacMetadataReader.FlacStreamMetadataHolder metadataHolder = + new FlacMetadataReader.FlacStreamMetadataHolder(flacStreamMetadata); + while (!isLastMetadataBlock) { + isLastMetadataBlock = FlacMetadataReader.readMetadataBlock(input, metadataHolder); + // Save the current metadata in case an exception occurs. + flacStreamMetadata = castNonNull(metadataHolder.flacStreamMetadata); + } + + Assertions.checkNotNull(flacStreamMetadata); + minFrameSize = Math.max(flacStreamMetadata.minFrameSize, FlacConstants.MIN_FRAME_HEADER_SIZE); + castNonNull(trackOutput) + .format(flacStreamMetadata.getFormat(streamMarkerAndInfoBlock, id3Metadata)); + + state = STATE_GET_FRAME_START_MARKER; + } + + private void getFrameStartMarker(ExtractorInput input) throws IOException, InterruptedException { + frameStartMarker = FlacMetadataReader.getFrameStartMarker(input); + castNonNull(extractorOutput) + .seekMap( + getSeekMap( + /* firstFramePosition= */ input.getPosition(), + /* streamLength= */ input.getLength())); + + state = STATE_READ_FRAMES; + } + + private @ReadResult int readFrames(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + Assertions.checkNotNull(trackOutput); + Assertions.checkNotNull(flacStreamMetadata); + + // Handle pending binary search seek if necessary. + if (binarySearchSeeker != null && binarySearchSeeker.isSeeking()) { + return binarySearchSeeker.handlePendingSeek(input, seekPosition); + } + + // Set current frame first sample number if it became unknown after seeking. + if (currentFrameFirstSampleNumber == SAMPLE_NUMBER_UNKNOWN) { + currentFrameFirstSampleNumber = + FlacFrameReader.getFirstSampleNumber(input, flacStreamMetadata); + return Extractor.RESULT_CONTINUE; + } + + // Copy more bytes into the buffer. + int currentLimit = buffer.limit(); + boolean foundEndOfInput = false; + if (currentLimit < BUFFER_LENGTH) { + int bytesRead = + input.read( + buffer.data, /* offset= */ currentLimit, /* length= */ BUFFER_LENGTH - currentLimit); + foundEndOfInput = bytesRead == C.RESULT_END_OF_INPUT; + if (!foundEndOfInput) { + buffer.setLimit(currentLimit + bytesRead); + } else if (buffer.bytesLeft() == 0) { + outputSampleMetadata(); + return Extractor.RESULT_END_OF_INPUT; + } + } + + // Search for a frame. + int positionBeforeFindingAFrame = buffer.getPosition(); + + // Skip frame search on the bytes within the minimum frame size. + if (currentFrameBytesWritten < minFrameSize) { + buffer.skipBytes(Math.min(minFrameSize - currentFrameBytesWritten, buffer.bytesLeft())); + } + + long nextFrameFirstSampleNumber = findFrame(buffer, foundEndOfInput); + int numberOfFrameBytes = buffer.getPosition() - positionBeforeFindingAFrame; + buffer.setPosition(positionBeforeFindingAFrame); + trackOutput.sampleData(buffer, numberOfFrameBytes); + currentFrameBytesWritten += numberOfFrameBytes; + + // Frame found. + if (nextFrameFirstSampleNumber != SAMPLE_NUMBER_UNKNOWN) { + outputSampleMetadata(); + currentFrameBytesWritten = 0; + currentFrameFirstSampleNumber = nextFrameFirstSampleNumber; + } + + if (buffer.bytesLeft() < FlacConstants.MAX_FRAME_HEADER_SIZE) { + // The next frame header may not fit in the rest of the buffer, so put the trailing bytes at + // the start of the buffer, and reset the position and limit. + System.arraycopy( + buffer.data, buffer.getPosition(), buffer.data, /* destPos= */ 0, buffer.bytesLeft()); + buffer.reset(buffer.bytesLeft()); + } + + return Extractor.RESULT_CONTINUE; + } + + private SeekMap getSeekMap(long firstFramePosition, long streamLength) { + Assertions.checkNotNull(flacStreamMetadata); + if (flacStreamMetadata.seekTable != null) { + return new FlacSeekTableSeekMap(flacStreamMetadata, firstFramePosition); + } else if (streamLength != C.LENGTH_UNSET && flacStreamMetadata.totalSamples > 0) { + binarySearchSeeker = + new FlacBinarySearchSeeker( + flacStreamMetadata, frameStartMarker, firstFramePosition, streamLength); + return binarySearchSeeker.getSeekMap(); + } else { + return new SeekMap.Unseekable(flacStreamMetadata.getDurationUs()); + } + } + + /** + * Searches for the start of a frame in {@code data}. + * + * <ul> + * <li>If the search is successful, the position is set to the start of the found frame. + * <li>Otherwise, the position is set to the first unsearched byte. + * </ul> + * + * @param data The array to be searched. + * @param foundEndOfInput If the end of input was met when filling in the {@code data}. + * @return The number of the first sample in the frame found, or {@code SAMPLE_NUMBER_UNKNOWN} if + * the search was not successful. + */ + private long findFrame(ParsableByteArray data, boolean foundEndOfInput) { + Assertions.checkNotNull(flacStreamMetadata); + + int frameOffset = data.getPosition(); + while (frameOffset <= data.limit() - FlacConstants.MAX_FRAME_HEADER_SIZE) { + data.setPosition(frameOffset); + if (FlacFrameReader.checkAndReadFrameHeader( + data, flacStreamMetadata, frameStartMarker, sampleNumberHolder)) { + data.setPosition(frameOffset); + return sampleNumberHolder.sampleNumber; + } + frameOffset++; + } + + if (foundEndOfInput) { + // Verify whether there is a frame of size < MAX_FRAME_HEADER_SIZE at the end of the stream by + // checking at every position at a distance between MAX_FRAME_HEADER_SIZE and minFrameSize + // from the buffer limit if it corresponds to a valid frame header. + // At every offset, the different possibilities are: + // 1. The current offset indicates the start of a valid frame header. In this case, consider + // that a frame has been found and stop searching. + // 2. A frame starting at the current offset would be invalid. In this case, keep looking for + // a valid frame header. + // 3. The current offset could be the start of a valid frame header, but there is not enough + // bytes remaining to complete the header. As the end of the file has been reached, this + // means that the current offset does not correspond to a new frame and that the last bytes + // of the last frame happen to be a valid partial frame header. This case can occur in two + // ways: + // 3.1. An attempt to read past the buffer is made when reading the potential frame header. + // 3.2. Reading the potential frame header does not exceed the buffer size, but exceeds the + // buffer limit. + // Note that the third case is very unlikely. It never happens if the end of the input has not + // been reached as it is always made sure that the buffer has at least MAX_FRAME_HEADER_SIZE + // bytes available when reading a potential frame header. + while (frameOffset <= data.limit() - minFrameSize) { + data.setPosition(frameOffset); + boolean frameFound; + try { + frameFound = + FlacFrameReader.checkAndReadFrameHeader( + data, flacStreamMetadata, frameStartMarker, sampleNumberHolder); + } catch (IndexOutOfBoundsException e) { + // Case 3.1. + frameFound = false; + } + if (data.getPosition() > data.limit()) { + // TODO: Remove (and update above comments) once [Internal ref: b/147657250] is fixed. + // Case 3.2. + frameFound = false; + } + if (frameFound) { + // Case 1. + data.setPosition(frameOffset); + return sampleNumberHolder.sampleNumber; + } + frameOffset++; + } + // The end of the frame is the end of the file. + data.setPosition(data.limit()); + } else { + data.setPosition(frameOffset); + } + + return SAMPLE_NUMBER_UNKNOWN; + } + + private void outputSampleMetadata() { + long timeUs = + currentFrameFirstSampleNumber + * C.MICROS_PER_SECOND + / castNonNull(flacStreamMetadata).sampleRate; + castNonNull(trackOutput) + .sampleMetadata( + timeUs, + C.BUFFER_FLAG_KEY_FRAME, + currentFrameBytesWritten, + /* offset= */ 0, + /* encryptionData= */ null); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java new file mode 100644 index 0000000000..54dbaec003 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/AudioTagPayloadReader.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flv; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.CodecSpecificDataUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Collections; + +/** + * Parses audio tags from an FLV stream and extracts AAC frames. + */ +/* package */ final class AudioTagPayloadReader extends TagPayloadReader { + + private static final int AUDIO_FORMAT_MP3 = 2; + private static final int AUDIO_FORMAT_ALAW = 7; + private static final int AUDIO_FORMAT_ULAW = 8; + private static final int AUDIO_FORMAT_AAC = 10; + + private static final int AAC_PACKET_TYPE_SEQUENCE_HEADER = 0; + private static final int AAC_PACKET_TYPE_AAC_RAW = 1; + + private static final int[] AUDIO_SAMPLING_RATE_TABLE = new int[] {5512, 11025, 22050, 44100}; + + // State variables + private boolean hasParsedAudioDataHeader; + private boolean hasOutputFormat; + private int audioFormat; + + public AudioTagPayloadReader(TrackOutput output) { + super(output); + } + + @Override + public void seek() { + // Do nothing. + } + + @Override + protected boolean parseHeader(ParsableByteArray data) throws UnsupportedFormatException { + if (!hasParsedAudioDataHeader) { + int header = data.readUnsignedByte(); + audioFormat = (header >> 4) & 0x0F; + if (audioFormat == AUDIO_FORMAT_MP3) { + int sampleRateIndex = (header >> 2) & 0x03; + int sampleRate = AUDIO_SAMPLING_RATE_TABLE[sampleRateIndex]; + Format format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_MPEG, null, + Format.NO_VALUE, Format.NO_VALUE, 1, sampleRate, null, null, 0, null); + output.format(format); + hasOutputFormat = true; + } else if (audioFormat == AUDIO_FORMAT_ALAW || audioFormat == AUDIO_FORMAT_ULAW) { + String type = audioFormat == AUDIO_FORMAT_ALAW ? MimeTypes.AUDIO_ALAW + : MimeTypes.AUDIO_MLAW; + Format format = + Format.createAudioSampleFormat( + /* id= */ null, + /* sampleMimeType= */ type, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + /* channelCount= */ 1, + /* sampleRate= */ 8000, + /* pcmEncoding= */ Format.NO_VALUE, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + output.format(format); + hasOutputFormat = true; + } else if (audioFormat != AUDIO_FORMAT_AAC) { + throw new UnsupportedFormatException("Audio format not supported: " + audioFormat); + } + hasParsedAudioDataHeader = true; + } else { + // Skip header if it was parsed previously. + data.skipBytes(1); + } + return true; + } + + @Override + protected boolean parsePayload(ParsableByteArray data, long timeUs) throws ParserException { + if (audioFormat == AUDIO_FORMAT_MP3) { + int sampleSize = data.bytesLeft(); + output.sampleData(data, sampleSize); + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + return true; + } else { + int packetType = data.readUnsignedByte(); + if (packetType == AAC_PACKET_TYPE_SEQUENCE_HEADER && !hasOutputFormat) { + // Parse the sequence header. + byte[] audioSpecificConfig = new byte[data.bytesLeft()]; + data.readBytes(audioSpecificConfig, 0, audioSpecificConfig.length); + Pair<Integer, Integer> audioParams = CodecSpecificDataUtil.parseAacAudioSpecificConfig( + audioSpecificConfig); + Format format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_AAC, null, + Format.NO_VALUE, Format.NO_VALUE, audioParams.second, audioParams.first, + Collections.singletonList(audioSpecificConfig), null, 0, null); + output.format(format); + hasOutputFormat = true; + return false; + } else if (audioFormat != AUDIO_FORMAT_AAC || packetType == AAC_PACKET_TYPE_AAC_RAW) { + int sampleSize = data.bytesLeft(); + output.sampleData(data, sampleSize); + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + return true; + } else { + return false; + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java new file mode 100644 index 0000000000..a7438b190f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/FlvExtractor.java @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flv; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Extracts data from the FLV container format. + */ +public final class FlvExtractor implements Extractor { + + /** Factory for {@link FlvExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new FlvExtractor()}; + + /** Extractor states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_READING_FLV_HEADER, + STATE_SKIPPING_TO_TAG_HEADER, + STATE_READING_TAG_HEADER, + STATE_READING_TAG_DATA + }) + private @interface States {} + + private static final int STATE_READING_FLV_HEADER = 1; + private static final int STATE_SKIPPING_TO_TAG_HEADER = 2; + private static final int STATE_READING_TAG_HEADER = 3; + private static final int STATE_READING_TAG_DATA = 4; + + // Header sizes. + private static final int FLV_HEADER_SIZE = 9; + private static final int FLV_TAG_HEADER_SIZE = 11; + + // Tag types. + private static final int TAG_TYPE_AUDIO = 8; + private static final int TAG_TYPE_VIDEO = 9; + private static final int TAG_TYPE_SCRIPT_DATA = 18; + + // FLV container identifier. + private static final int FLV_TAG = 0x00464c56; + + private final ParsableByteArray scratch; + private final ParsableByteArray headerBuffer; + private final ParsableByteArray tagHeaderBuffer; + private final ParsableByteArray tagData; + private final ScriptTagPayloadReader metadataReader; + + private ExtractorOutput extractorOutput; + private @States int state; + private boolean outputFirstSample; + private long mediaTagTimestampOffsetUs; + private int bytesToNextTagHeader; + private int tagType; + private int tagDataSize; + private long tagTimestampUs; + private boolean outputSeekMap; + private AudioTagPayloadReader audioReader; + private VideoTagPayloadReader videoReader; + + public FlvExtractor() { + scratch = new ParsableByteArray(4); + headerBuffer = new ParsableByteArray(FLV_HEADER_SIZE); + tagHeaderBuffer = new ParsableByteArray(FLV_TAG_HEADER_SIZE); + tagData = new ParsableByteArray(); + metadataReader = new ScriptTagPayloadReader(); + state = STATE_READING_FLV_HEADER; + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // Check if file starts with "FLV" tag + input.peekFully(scratch.data, 0, 3); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != FLV_TAG) { + return false; + } + + // Checking reserved flags are set to 0 + input.peekFully(scratch.data, 0, 2); + scratch.setPosition(0); + if ((scratch.readUnsignedShort() & 0xFA) != 0) { + return false; + } + + // Read data offset + input.peekFully(scratch.data, 0, 4); + scratch.setPosition(0); + int dataOffset = scratch.readInt(); + + input.resetPeekPosition(); + input.advancePeekPosition(dataOffset); + + // Checking first "previous tag size" is set to 0 + input.peekFully(scratch.data, 0, 4); + scratch.setPosition(0); + + return scratch.readInt() == 0; + } + + @Override + public void init(ExtractorOutput output) { + this.extractorOutput = output; + } + + @Override + public void seek(long position, long timeUs) { + state = STATE_READING_FLV_HEADER; + outputFirstSample = false; + bytesToNextTagHeader = 0; + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, + InterruptedException { + while (true) { + switch (state) { + case STATE_READING_FLV_HEADER: + if (!readFlvHeader(input)) { + return RESULT_END_OF_INPUT; + } + break; + case STATE_SKIPPING_TO_TAG_HEADER: + skipToTagHeader(input); + break; + case STATE_READING_TAG_HEADER: + if (!readTagHeader(input)) { + return RESULT_END_OF_INPUT; + } + break; + case STATE_READING_TAG_DATA: + if (readTagData(input)) { + return RESULT_CONTINUE; + } + break; + default: + // Never happens. + throw new IllegalStateException(); + } + } + } + + /** + * Reads an FLV container header from the provided {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @return True if header was read successfully. False if the end of stream was reached. + * @throws IOException If an error occurred reading or parsing data from the source. + * @throws InterruptedException If the thread was interrupted. + */ + private boolean readFlvHeader(ExtractorInput input) throws IOException, InterruptedException { + if (!input.readFully(headerBuffer.data, 0, FLV_HEADER_SIZE, true)) { + // We've reached the end of the stream. + return false; + } + + headerBuffer.setPosition(0); + headerBuffer.skipBytes(4); + int flags = headerBuffer.readUnsignedByte(); + boolean hasAudio = (flags & 0x04) != 0; + boolean hasVideo = (flags & 0x01) != 0; + if (hasAudio && audioReader == null) { + audioReader = new AudioTagPayloadReader( + extractorOutput.track(TAG_TYPE_AUDIO, C.TRACK_TYPE_AUDIO)); + } + if (hasVideo && videoReader == null) { + videoReader = new VideoTagPayloadReader( + extractorOutput.track(TAG_TYPE_VIDEO, C.TRACK_TYPE_VIDEO)); + } + extractorOutput.endTracks(); + + // We need to skip any additional content in the FLV header, plus the 4 byte previous tag size. + bytesToNextTagHeader = headerBuffer.readInt() - FLV_HEADER_SIZE + 4; + state = STATE_SKIPPING_TO_TAG_HEADER; + return true; + } + + /** + * Skips over data to reach the next tag header. + * + * @param input The {@link ExtractorInput} from which to read. + * @throws IOException If an error occurred skipping data from the source. + * @throws InterruptedException If the thread was interrupted. + */ + private void skipToTagHeader(ExtractorInput input) throws IOException, InterruptedException { + input.skipFully(bytesToNextTagHeader); + bytesToNextTagHeader = 0; + state = STATE_READING_TAG_HEADER; + } + + /** + * Reads a tag header from the provided {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @return True if tag header was read successfully. Otherwise, false. + * @throws IOException If an error occurred reading or parsing data from the source. + * @throws InterruptedException If the thread was interrupted. + */ + private boolean readTagHeader(ExtractorInput input) throws IOException, InterruptedException { + if (!input.readFully(tagHeaderBuffer.data, 0, FLV_TAG_HEADER_SIZE, true)) { + // We've reached the end of the stream. + return false; + } + + tagHeaderBuffer.setPosition(0); + tagType = tagHeaderBuffer.readUnsignedByte(); + tagDataSize = tagHeaderBuffer.readUnsignedInt24(); + tagTimestampUs = tagHeaderBuffer.readUnsignedInt24(); + tagTimestampUs = ((tagHeaderBuffer.readUnsignedByte() << 24) | tagTimestampUs) * 1000L; + tagHeaderBuffer.skipBytes(3); // streamId + state = STATE_READING_TAG_DATA; + return true; + } + + /** + * Reads the body of a tag from the provided {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @return True if the data was consumed by a reader. False if it was skipped. + * @throws IOException If an error occurred reading or parsing data from the source. + * @throws InterruptedException If the thread was interrupted. + */ + private boolean readTagData(ExtractorInput input) throws IOException, InterruptedException { + boolean wasConsumed = true; + boolean wasSampleOutput = false; + long timestampUs = getCurrentTimestampUs(); + if (tagType == TAG_TYPE_AUDIO && audioReader != null) { + ensureReadyForMediaOutput(); + wasSampleOutput = audioReader.consume(prepareTagData(input), timestampUs); + } else if (tagType == TAG_TYPE_VIDEO && videoReader != null) { + ensureReadyForMediaOutput(); + wasSampleOutput = videoReader.consume(prepareTagData(input), timestampUs); + } else if (tagType == TAG_TYPE_SCRIPT_DATA && !outputSeekMap) { + wasSampleOutput = metadataReader.consume(prepareTagData(input), timestampUs); + long durationUs = metadataReader.getDurationUs(); + if (durationUs != C.TIME_UNSET) { + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); + outputSeekMap = true; + } + } else { + input.skipFully(tagDataSize); + wasConsumed = false; + } + if (!outputFirstSample && wasSampleOutput) { + outputFirstSample = true; + mediaTagTimestampOffsetUs = + metadataReader.getDurationUs() == C.TIME_UNSET ? -tagTimestampUs : 0; + } + bytesToNextTagHeader = 4; // There's a 4 byte previous tag size before the next header. + state = STATE_SKIPPING_TO_TAG_HEADER; + return wasConsumed; + } + + private ParsableByteArray prepareTagData(ExtractorInput input) throws IOException, + InterruptedException { + if (tagDataSize > tagData.capacity()) { + tagData.reset(new byte[Math.max(tagData.capacity() * 2, tagDataSize)], 0); + } else { + tagData.setPosition(0); + } + tagData.setLimit(tagDataSize); + input.readFully(tagData.data, 0, tagDataSize); + return tagData; + } + + private void ensureReadyForMediaOutput() { + if (!outputSeekMap) { + extractorOutput.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + outputSeekMap = true; + } + } + + private long getCurrentTimestampUs() { + return outputFirstSample + ? (mediaTagTimestampOffsetUs + tagTimestampUs) + : (metadataReader.getDurationUs() == C.TIME_UNSET ? 0 : tagTimestampUs); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java new file mode 100644 index 0000000000..1494bf1c2e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/ScriptTagPayloadReader.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flv; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * Parses Script Data tags from an FLV stream and extracts metadata information. + */ +/* package */ final class ScriptTagPayloadReader extends TagPayloadReader { + + private static final String NAME_METADATA = "onMetaData"; + private static final String KEY_DURATION = "duration"; + + // AMF object types + private static final int AMF_TYPE_NUMBER = 0; + private static final int AMF_TYPE_BOOLEAN = 1; + private static final int AMF_TYPE_STRING = 2; + private static final int AMF_TYPE_OBJECT = 3; + private static final int AMF_TYPE_ECMA_ARRAY = 8; + private static final int AMF_TYPE_END_MARKER = 9; + private static final int AMF_TYPE_STRICT_ARRAY = 10; + private static final int AMF_TYPE_DATE = 11; + + private long durationUs; + + public ScriptTagPayloadReader() { + super(new DummyTrackOutput()); + durationUs = C.TIME_UNSET; + } + + public long getDurationUs() { + return durationUs; + } + + @Override + public void seek() { + // Do nothing. + } + + @Override + protected boolean parseHeader(ParsableByteArray data) { + return true; + } + + @Override + protected boolean parsePayload(ParsableByteArray data, long timeUs) throws ParserException { + int nameType = readAmfType(data); + if (nameType != AMF_TYPE_STRING) { + // Should never happen. + throw new ParserException(); + } + String name = readAmfString(data); + if (!NAME_METADATA.equals(name)) { + // We're only interested in metadata. + return false; + } + int type = readAmfType(data); + if (type != AMF_TYPE_ECMA_ARRAY) { + // We're not interested in this metadata. + return false; + } + // Set the duration to the value contained in the metadata, if present. + Map<String, Object> metadata = readAmfEcmaArray(data); + if (metadata.containsKey(KEY_DURATION)) { + double durationSeconds = (double) metadata.get(KEY_DURATION); + if (durationSeconds > 0.0) { + durationUs = (long) (durationSeconds * C.MICROS_PER_SECOND); + } + } + return false; + } + + private static int readAmfType(ParsableByteArray data) { + return data.readUnsignedByte(); + } + + /** + * Read a boolean from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static Boolean readAmfBoolean(ParsableByteArray data) { + return data.readUnsignedByte() == 1; + } + + /** + * Read a double number from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static Double readAmfDouble(ParsableByteArray data) { + return Double.longBitsToDouble(data.readLong()); + } + + /** + * Read a string from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static String readAmfString(ParsableByteArray data) { + int size = data.readUnsignedShort(); + int position = data.getPosition(); + data.skipBytes(size); + return new String(data.data, position, size); + } + + /** + * Read an array from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static ArrayList<Object> readAmfStrictArray(ParsableByteArray data) { + int count = data.readUnsignedIntToInt(); + ArrayList<Object> list = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + int type = readAmfType(data); + Object value = readAmfData(data, type); + if (value != null) { + list.add(value); + } + } + return list; + } + + /** + * Read an object from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static HashMap<String, Object> readAmfObject(ParsableByteArray data) { + HashMap<String, Object> array = new HashMap<>(); + while (true) { + String key = readAmfString(data); + int type = readAmfType(data); + if (type == AMF_TYPE_END_MARKER) { + break; + } + Object value = readAmfData(data, type); + if (value != null) { + array.put(key, value); + } + } + return array; + } + + /** + * Read an ECMA array from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static HashMap<String, Object> readAmfEcmaArray(ParsableByteArray data) { + int count = data.readUnsignedIntToInt(); + HashMap<String, Object> array = new HashMap<>(count); + for (int i = 0; i < count; i++) { + String key = readAmfString(data); + int type = readAmfType(data); + Object value = readAmfData(data, type); + if (value != null) { + array.put(key, value); + } + } + return array; + } + + /** + * Read a date from an AMF encoded buffer. + * + * @param data The buffer from which to read. + * @return The value read from the buffer. + */ + private static Date readAmfDate(ParsableByteArray data) { + Date date = new Date((long) readAmfDouble(data).doubleValue()); + data.skipBytes(2); // Skip reserved bytes. + return date; + } + + @Nullable + private static Object readAmfData(ParsableByteArray data, int type) { + switch (type) { + case AMF_TYPE_NUMBER: + return readAmfDouble(data); + case AMF_TYPE_BOOLEAN: + return readAmfBoolean(data); + case AMF_TYPE_STRING: + return readAmfString(data); + case AMF_TYPE_OBJECT: + return readAmfObject(data); + case AMF_TYPE_ECMA_ARRAY: + return readAmfEcmaArray(data); + case AMF_TYPE_STRICT_ARRAY: + return readAmfStrictArray(data); + case AMF_TYPE_DATE: + return readAmfDate(data); + default: + // We don't log a warning because there are types that we knowingly don't support. + return null; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/TagPayloadReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/TagPayloadReader.java new file mode 100644 index 0000000000..3f8b51244a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/TagPayloadReader.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flv; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Extracts individual samples from FLV tags, preserving original order. + */ +/* package */ abstract class TagPayloadReader { + + /** + * Thrown when the format is not supported. + */ + public static final class UnsupportedFormatException extends ParserException { + + public UnsupportedFormatException(String msg) { + super(msg); + } + + } + + protected final TrackOutput output; + + /** + * @param output A {@link TrackOutput} to which samples should be written. + */ + protected TagPayloadReader(TrackOutput output) { + this.output = output; + } + + /** + * Notifies the reader that a seek has occurred. + * <p> + * Following a call to this method, the data passed to the next invocation of + * {@link #consume(ParsableByteArray, long)} will not be a continuation of the data that + * was previously passed. Hence the reader should reset any internal state. + */ + public abstract void seek(); + + /** + * Consumes payload data. + * + * @param data The payload data to consume. + * @param timeUs The timestamp associated with the payload. + * @return Whether a sample was output. + * @throws ParserException If an error occurs parsing the data. + */ + public final boolean consume(ParsableByteArray data, long timeUs) throws ParserException { + return parseHeader(data) && parsePayload(data, timeUs); + } + + /** + * Parses tag header. + * + * @param data Buffer where the tag header is stored. + * @return Whether the header was parsed successfully. + * @throws ParserException If an error occurs parsing the header. + */ + protected abstract boolean parseHeader(ParsableByteArray data) throws ParserException; + + /** + * Parses tag payload. + * + * @param data Buffer where tag payload is stored. + * @param timeUs Time position of the frame. + * @return Whether a sample was output. + * @throws ParserException If an error occurs parsing the payload. + */ + protected abstract boolean parsePayload(ParsableByteArray data, long timeUs) + throws ParserException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java new file mode 100644 index 0000000000..6ed5206144 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/flv/VideoTagPayloadReader.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.flv; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.AvcConfig; + +/** + * Parses video tags from an FLV stream and extracts H.264 nal units. + */ +/* package */ final class VideoTagPayloadReader extends TagPayloadReader { + + // Video codec. + private static final int VIDEO_CODEC_AVC = 7; + + // Frame types. + private static final int VIDEO_FRAME_KEYFRAME = 1; + private static final int VIDEO_FRAME_VIDEO_INFO = 5; + + // Packet types. + private static final int AVC_PACKET_TYPE_SEQUENCE_HEADER = 0; + private static final int AVC_PACKET_TYPE_AVC_NALU = 1; + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalLength; + private int nalUnitLengthFieldLength; + + // State variables. + private boolean hasOutputFormat; + private boolean hasOutputKeyframe; + private int frameType; + + /** + * @param output A {@link TrackOutput} to which samples should be written. + */ + public VideoTagPayloadReader(TrackOutput output) { + super(output); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalLength = new ParsableByteArray(4); + } + + @Override + public void seek() { + hasOutputKeyframe = false; + } + + @Override + protected boolean parseHeader(ParsableByteArray data) throws UnsupportedFormatException { + int header = data.readUnsignedByte(); + int frameType = (header >> 4) & 0x0F; + int videoCodec = (header & 0x0F); + // Support just H.264 encoded content. + if (videoCodec != VIDEO_CODEC_AVC) { + throw new UnsupportedFormatException("Video format not supported: " + videoCodec); + } + this.frameType = frameType; + return (frameType != VIDEO_FRAME_VIDEO_INFO); + } + + @Override + protected boolean parsePayload(ParsableByteArray data, long timeUs) throws ParserException { + int packetType = data.readUnsignedByte(); + int compositionTimeMs = data.readInt24(); + + timeUs += compositionTimeMs * 1000L; + // Parse avc sequence header in case this was not done before. + if (packetType == AVC_PACKET_TYPE_SEQUENCE_HEADER && !hasOutputFormat) { + ParsableByteArray videoSequence = new ParsableByteArray(new byte[data.bytesLeft()]); + data.readBytes(videoSequence.data, 0, data.bytesLeft()); + AvcConfig avcConfig = AvcConfig.parse(videoSequence); + nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; + // Construct and output the format. + Format format = Format.createVideoSampleFormat(null, MimeTypes.VIDEO_H264, null, + Format.NO_VALUE, Format.NO_VALUE, avcConfig.width, avcConfig.height, Format.NO_VALUE, + avcConfig.initializationData, Format.NO_VALUE, avcConfig.pixelWidthAspectRatio, null); + output.format(format); + hasOutputFormat = true; + return false; + } else if (packetType == AVC_PACKET_TYPE_AVC_NALU && hasOutputFormat) { + boolean isKeyframe = frameType == VIDEO_FRAME_KEYFRAME; + if (!hasOutputKeyframe && !isKeyframe) { + return false; + } + // TODO: Deduplicate with Mp4Extractor. + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalLengthData = nalLength.data; + nalLengthData[0] = 0; + nalLengthData[1] = 0; + nalLengthData[2] = 0; + int nalUnitLengthFieldLengthDiff = 4 - nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + int bytesWritten = 0; + int bytesToWrite; + while (data.bytesLeft() > 0) { + // Read the NAL length so that we know where we find the next one. + data.readBytes(nalLength.data, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); + nalLength.setPosition(0); + bytesToWrite = nalLength.readUnsignedIntToInt(); + + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + bytesWritten += 4; + + // Write the payload of the NAL unit. + output.sampleData(data, bytesToWrite); + bytesWritten += bytesToWrite; + } + output.sampleMetadata( + timeUs, isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0, bytesWritten, 0, null); + hasOutputKeyframe = true; + return true; + } else { + return false; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/DefaultEbmlReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/DefaultEbmlReader.java new file mode 100644 index 0000000000..b4e160fa74 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/DefaultEbmlReader.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; + +/** + * Default implementation of {@link EbmlReader}. + */ +/* package */ final class DefaultEbmlReader implements EbmlReader { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ELEMENT_STATE_READ_ID, ELEMENT_STATE_READ_CONTENT_SIZE, ELEMENT_STATE_READ_CONTENT}) + private @interface ElementState {} + + private static final int ELEMENT_STATE_READ_ID = 0; + private static final int ELEMENT_STATE_READ_CONTENT_SIZE = 1; + private static final int ELEMENT_STATE_READ_CONTENT = 2; + + private static final int MAX_ID_BYTES = 4; + private static final int MAX_LENGTH_BYTES = 8; + + private static final int MAX_INTEGER_ELEMENT_SIZE_BYTES = 8; + private static final int VALID_FLOAT32_ELEMENT_SIZE_BYTES = 4; + private static final int VALID_FLOAT64_ELEMENT_SIZE_BYTES = 8; + + private final byte[] scratch; + private final ArrayDeque<MasterElement> masterElementsStack; + private final VarintReader varintReader; + + private EbmlProcessor processor; + private @ElementState int elementState; + private int elementId; + private long elementContentSize; + + public DefaultEbmlReader() { + scratch = new byte[8]; + masterElementsStack = new ArrayDeque<>(); + varintReader = new VarintReader(); + } + + @Override + public void init(EbmlProcessor processor) { + this.processor = processor; + } + + @Override + public void reset() { + elementState = ELEMENT_STATE_READ_ID; + masterElementsStack.clear(); + varintReader.reset(); + } + + @Override + public boolean read(ExtractorInput input) throws IOException, InterruptedException { + Assertions.checkNotNull(processor); + while (true) { + if (!masterElementsStack.isEmpty() + && input.getPosition() >= masterElementsStack.peek().elementEndPosition) { + processor.endMasterElement(masterElementsStack.pop().elementId); + return true; + } + + if (elementState == ELEMENT_STATE_READ_ID) { + long result = varintReader.readUnsignedVarint(input, true, false, MAX_ID_BYTES); + if (result == C.RESULT_MAX_LENGTH_EXCEEDED) { + result = maybeResyncToNextLevel1Element(input); + } + if (result == C.RESULT_END_OF_INPUT) { + return false; + } + // Element IDs are at most 4 bytes, so we can cast to integers. + elementId = (int) result; + elementState = ELEMENT_STATE_READ_CONTENT_SIZE; + } + + if (elementState == ELEMENT_STATE_READ_CONTENT_SIZE) { + elementContentSize = varintReader.readUnsignedVarint(input, false, true, MAX_LENGTH_BYTES); + elementState = ELEMENT_STATE_READ_CONTENT; + } + + @EbmlProcessor.ElementType int type = processor.getElementType(elementId); + switch (type) { + case EbmlProcessor.ELEMENT_TYPE_MASTER: + long elementContentPosition = input.getPosition(); + long elementEndPosition = elementContentPosition + elementContentSize; + masterElementsStack.push(new MasterElement(elementId, elementEndPosition)); + processor.startMasterElement(elementId, elementContentPosition, elementContentSize); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT: + if (elementContentSize > MAX_INTEGER_ELEMENT_SIZE_BYTES) { + throw new ParserException("Invalid integer size: " + elementContentSize); + } + processor.integerElement(elementId, readInteger(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_FLOAT: + if (elementContentSize != VALID_FLOAT32_ELEMENT_SIZE_BYTES + && elementContentSize != VALID_FLOAT64_ELEMENT_SIZE_BYTES) { + throw new ParserException("Invalid float size: " + elementContentSize); + } + processor.floatElement(elementId, readFloat(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_STRING: + if (elementContentSize > Integer.MAX_VALUE) { + throw new ParserException("String element size: " + elementContentSize); + } + processor.stringElement(elementId, readString(input, (int) elementContentSize)); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_BINARY: + processor.binaryElement(elementId, (int) elementContentSize, input); + elementState = ELEMENT_STATE_READ_ID; + return true; + case EbmlProcessor.ELEMENT_TYPE_UNKNOWN: + input.skipFully((int) elementContentSize); + elementState = ELEMENT_STATE_READ_ID; + break; + default: + throw new ParserException("Invalid element type " + type); + } + } + } + + /** + * Does a byte by byte search to try and find the next level 1 element. This method is called if + * some invalid data is encountered in the parser. + * + * @param input The {@link ExtractorInput} from which data has to be read. + * @return id of the next level 1 element that has been found. + * @throws EOFException If the end of input was encountered when searching for the next level 1 + * element. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private long maybeResyncToNextLevel1Element(ExtractorInput input) throws IOException, + InterruptedException { + input.resetPeekPosition(); + while (true) { + input.peekFully(scratch, 0, MAX_ID_BYTES); + int varintLength = VarintReader.parseUnsignedVarintLength(scratch[0]); + if (varintLength != C.LENGTH_UNSET && varintLength <= MAX_ID_BYTES) { + int potentialId = (int) VarintReader.assembleVarint(scratch, varintLength, false); + if (processor.isLevel1Element(potentialId)) { + input.skipFully(varintLength); + return potentialId; + } + } + input.skipFully(1); + } + } + + /** + * Reads and returns an integer of length {@code byteLength} from the {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the integer being read. + * @return The read integer value. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private long readInteger(ExtractorInput input, int byteLength) + throws IOException, InterruptedException { + input.readFully(scratch, 0, byteLength); + long value = 0; + for (int i = 0; i < byteLength; i++) { + value = (value << 8) | (scratch[i] & 0xFF); + } + return value; + } + + /** + * Reads and returns a float of length {@code byteLength} from the {@link ExtractorInput}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the float being read. + * @return The read float value. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private double readFloat(ExtractorInput input, int byteLength) + throws IOException, InterruptedException { + long integerValue = readInteger(input, byteLength); + double floatValue; + if (byteLength == VALID_FLOAT32_ELEMENT_SIZE_BYTES) { + floatValue = Float.intBitsToFloat((int) integerValue); + } else { + floatValue = Double.longBitsToDouble(integerValue); + } + return floatValue; + } + + /** + * Reads a string of length {@code byteLength} from the {@link ExtractorInput}. Zero padding is + * removed, so the returned string may be shorter than {@code byteLength}. + * + * @param input The {@link ExtractorInput} from which to read. + * @param byteLength The length of the string being read, including zero padding. + * @return The read string value. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private String readString(ExtractorInput input, int byteLength) + throws IOException, InterruptedException { + if (byteLength == 0) { + return ""; + } + byte[] stringBytes = new byte[byteLength]; + input.readFully(stringBytes, 0, byteLength); + // Remove zero padding. + int trimmedLength = byteLength; + while (trimmedLength > 0 && stringBytes[trimmedLength - 1] == 0) { + trimmedLength--; + } + return new String(stringBytes, 0, trimmedLength); + } + + /** + * Used in {@link #masterElementsStack} to track when the current master element ends, so that + * {@link EbmlProcessor#endMasterElement(int)} can be called. + */ + private static final class MasterElement { + + private final int elementId; + private final long elementEndPosition; + + private MasterElement(int elementId, long elementEndPosition) { + this.elementId = elementId; + this.elementEndPosition = elementEndPosition; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/EbmlProcessor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/EbmlProcessor.java new file mode 100644 index 0000000000..188ced0554 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/EbmlProcessor.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Defines EBML element IDs/types and processes events. */ +public interface EbmlProcessor { + + /** + * EBML element types. One of {@link #ELEMENT_TYPE_UNKNOWN}, {@link #ELEMENT_TYPE_MASTER}, {@link + * #ELEMENT_TYPE_UNSIGNED_INT}, {@link #ELEMENT_TYPE_STRING}, {@link #ELEMENT_TYPE_BINARY} or + * {@link #ELEMENT_TYPE_FLOAT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ELEMENT_TYPE_UNKNOWN, + ELEMENT_TYPE_MASTER, + ELEMENT_TYPE_UNSIGNED_INT, + ELEMENT_TYPE_STRING, + ELEMENT_TYPE_BINARY, + ELEMENT_TYPE_FLOAT + }) + @interface ElementType {} + /** Type for unknown elements. */ + int ELEMENT_TYPE_UNKNOWN = 0; + /** Type for elements that contain child elements. */ + int ELEMENT_TYPE_MASTER = 1; + /** Type for integer value elements of up to 8 bytes. */ + int ELEMENT_TYPE_UNSIGNED_INT = 2; + /** Type for string elements. */ + int ELEMENT_TYPE_STRING = 3; + /** Type for binary elements. */ + int ELEMENT_TYPE_BINARY = 4; + /** Type for IEEE floating point value elements of either 4 or 8 bytes. */ + int ELEMENT_TYPE_FLOAT = 5; + + /** + * Maps an element ID to a corresponding type. + * + * <p>If {@link #ELEMENT_TYPE_UNKNOWN} is returned then the element is skipped. Note that all + * children of a skipped element are also skipped. + * + * @param id The element ID to map. + * @return One of {@link #ELEMENT_TYPE_UNKNOWN}, {@link #ELEMENT_TYPE_MASTER}, {@link + * #ELEMENT_TYPE_UNSIGNED_INT}, {@link #ELEMENT_TYPE_STRING}, {@link #ELEMENT_TYPE_BINARY} and + * {@link #ELEMENT_TYPE_FLOAT}. + */ + @ElementType + int getElementType(int id); + + /** + * Checks if the given id is that of a level 1 element. + * + * @param id The element ID. + * @return Whether the given id is that of a level 1 element. + */ + boolean isLevel1Element(int id); + + /** + * Called when the start of a master element is encountered. + * <p> + * Following events should be considered as taking place within this element until a matching call + * to {@link #endMasterElement(int)} is made. + * <p> + * Note that it is possible for another master element of the same element ID to be nested within + * itself. + * + * @param id The element ID. + * @param contentPosition The position of the start of the element's content in the stream. + * @param contentSize The size of the element's content in bytes. + * @throws ParserException If a parsing error occurs. + */ + void startMasterElement(int id, long contentPosition, long contentSize) throws ParserException; + + /** + * Called when the end of a master element is encountered. + * + * @param id The element ID. + * @throws ParserException If a parsing error occurs. + */ + void endMasterElement(int id) throws ParserException; + + /** + * Called when an integer element is encountered. + * + * @param id The element ID. + * @param value The integer value that the element contains. + * @throws ParserException If a parsing error occurs. + */ + void integerElement(int id, long value) throws ParserException; + + /** + * Called when a float element is encountered. + * + * @param id The element ID. + * @param value The float value that the element contains + * @throws ParserException If a parsing error occurs. + */ + void floatElement(int id, double value) throws ParserException; + + /** + * Called when a string element is encountered. + * + * @param id The element ID. + * @param value The string value that the element contains. + * @throws ParserException If a parsing error occurs. + */ + void stringElement(int id, String value) throws ParserException; + + /** + * Called when a binary element is encountered. + * <p> + * The element header (containing the element ID and content size) will already have been read. + * Implementations are required to consume the whole remainder of the element, which is + * {@code contentSize} bytes in length, before returning. Implementations are permitted to fail + * (by throwing an exception) having partially consumed the data, however if they do this, they + * must consume the remainder of the content when called again. + * + * @param id The element ID. + * @param contentsSize The element's content size. + * @param input The {@link ExtractorInput} from which data should be read. + * @throws ParserException If a parsing error occurs. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + void binaryElement(int id, int contentsSize, ExtractorInput input) + throws IOException, InterruptedException; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/EbmlReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/EbmlReader.java new file mode 100644 index 0000000000..1416a9087e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/EbmlReader.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import java.io.IOException; + +/** + * Event-driven EBML reader that delivers events to an {@link EbmlProcessor}. + * + * <p>EBML can be summarized as a binary XML format somewhat similar to Protocol Buffers. It was + * originally designed for the Matroska container format. More information about EBML and Matroska + * is available <a href="http://www.matroska.org/technical/specs/index.html">here</a>. + */ +/* package */ interface EbmlReader { + + /** + * Initializes the extractor with an {@link EbmlProcessor}. + * + * @param processor An {@link EbmlProcessor} to process events. + */ + void init(EbmlProcessor processor); + + /** + * Resets the state of the reader. + * <p> + * Subsequent calls to {@link #read(ExtractorInput)} will start reading a new EBML structure + * from scratch. + */ + void reset(); + + /** + * Reads from an {@link ExtractorInput}, invoking an event callback if possible. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @return True if data can continue to be read. False if the end of the input was encountered. + * @throws ParserException If parsing fails. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + boolean read(ExtractorInput input) throws IOException, InterruptedException; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java new file mode 100644 index 0000000000..d9587cd27e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/MatroskaExtractor.java @@ -0,0 +1,2331 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv; + +import android.util.Pair; +import android.util.SparseArray; +import androidx.annotation.CallSuper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac3Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ChunkIndex; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.LongArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.AvcConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.ColorInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.HevcConfig; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** Extracts data from the Matroska and WebM container formats. */ +public class MatroskaExtractor implements Extractor { + + /** Factory for {@link MatroskaExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new MatroskaExtractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_DISABLE_SEEK_FOR_CUES}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_DISABLE_SEEK_FOR_CUES}) + public @interface Flags {} + /** + * Flag to disable seeking for cues. + * <p> + * Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its + * position is specified in the seek head and if it's after the first cluster. Setting this flag + * disables seeking to the cues element. If the cues element is after the first cluster then the + * media is treated as being unseekable. + */ + public static final int FLAG_DISABLE_SEEK_FOR_CUES = 1; + + private static final String TAG = "MatroskaExtractor"; + + private static final int UNSET_ENTRY_ID = -1; + + private static final int BLOCK_STATE_START = 0; + private static final int BLOCK_STATE_HEADER = 1; + private static final int BLOCK_STATE_DATA = 2; + + private static final String DOC_TYPE_MATROSKA = "matroska"; + private static final String DOC_TYPE_WEBM = "webm"; + private static final String CODEC_ID_VP8 = "V_VP8"; + private static final String CODEC_ID_VP9 = "V_VP9"; + private static final String CODEC_ID_AV1 = "V_AV1"; + private static final String CODEC_ID_MPEG2 = "V_MPEG2"; + private static final String CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP"; + private static final String CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP"; + private static final String CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP"; + private static final String CODEC_ID_H264 = "V_MPEG4/ISO/AVC"; + private static final String CODEC_ID_H265 = "V_MPEGH/ISO/HEVC"; + private static final String CODEC_ID_FOURCC = "V_MS/VFW/FOURCC"; + private static final String CODEC_ID_THEORA = "V_THEORA"; + private static final String CODEC_ID_VORBIS = "A_VORBIS"; + private static final String CODEC_ID_OPUS = "A_OPUS"; + private static final String CODEC_ID_AAC = "A_AAC"; + private static final String CODEC_ID_MP2 = "A_MPEG/L2"; + private static final String CODEC_ID_MP3 = "A_MPEG/L3"; + private static final String CODEC_ID_AC3 = "A_AC3"; + private static final String CODEC_ID_E_AC3 = "A_EAC3"; + private static final String CODEC_ID_TRUEHD = "A_TRUEHD"; + private static final String CODEC_ID_DTS = "A_DTS"; + private static final String CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS"; + private static final String CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS"; + private static final String CODEC_ID_FLAC = "A_FLAC"; + private static final String CODEC_ID_ACM = "A_MS/ACM"; + private static final String CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT"; + private static final String CODEC_ID_SUBRIP = "S_TEXT/UTF8"; + private static final String CODEC_ID_ASS = "S_TEXT/ASS"; + private static final String CODEC_ID_VOBSUB = "S_VOBSUB"; + private static final String CODEC_ID_PGS = "S_HDMV/PGS"; + private static final String CODEC_ID_DVBSUB = "S_DVBSUB"; + + private static final int VORBIS_MAX_INPUT_SIZE = 8192; + private static final int OPUS_MAX_INPUT_SIZE = 5760; + private static final int ENCRYPTION_IV_SIZE = 8; + private static final int TRACK_TYPE_AUDIO = 2; + + private static final int ID_EBML = 0x1A45DFA3; + private static final int ID_EBML_READ_VERSION = 0x42F7; + private static final int ID_DOC_TYPE = 0x4282; + private static final int ID_DOC_TYPE_READ_VERSION = 0x4285; + private static final int ID_SEGMENT = 0x18538067; + private static final int ID_SEGMENT_INFO = 0x1549A966; + private static final int ID_SEEK_HEAD = 0x114D9B74; + private static final int ID_SEEK = 0x4DBB; + private static final int ID_SEEK_ID = 0x53AB; + private static final int ID_SEEK_POSITION = 0x53AC; + private static final int ID_INFO = 0x1549A966; + private static final int ID_TIMECODE_SCALE = 0x2AD7B1; + private static final int ID_DURATION = 0x4489; + private static final int ID_CLUSTER = 0x1F43B675; + private static final int ID_TIME_CODE = 0xE7; + private static final int ID_SIMPLE_BLOCK = 0xA3; + private static final int ID_BLOCK_GROUP = 0xA0; + private static final int ID_BLOCK = 0xA1; + private static final int ID_BLOCK_DURATION = 0x9B; + private static final int ID_BLOCK_ADDITIONS = 0x75A1; + private static final int ID_BLOCK_MORE = 0xA6; + private static final int ID_BLOCK_ADD_ID = 0xEE; + private static final int ID_BLOCK_ADDITIONAL = 0xA5; + private static final int ID_REFERENCE_BLOCK = 0xFB; + private static final int ID_TRACKS = 0x1654AE6B; + private static final int ID_TRACK_ENTRY = 0xAE; + private static final int ID_TRACK_NUMBER = 0xD7; + private static final int ID_TRACK_TYPE = 0x83; + private static final int ID_FLAG_DEFAULT = 0x88; + private static final int ID_FLAG_FORCED = 0x55AA; + private static final int ID_DEFAULT_DURATION = 0x23E383; + private static final int ID_MAX_BLOCK_ADDITION_ID = 0x55EE; + private static final int ID_NAME = 0x536E; + private static final int ID_CODEC_ID = 0x86; + private static final int ID_CODEC_PRIVATE = 0x63A2; + private static final int ID_CODEC_DELAY = 0x56AA; + private static final int ID_SEEK_PRE_ROLL = 0x56BB; + private static final int ID_VIDEO = 0xE0; + private static final int ID_PIXEL_WIDTH = 0xB0; + private static final int ID_PIXEL_HEIGHT = 0xBA; + private static final int ID_DISPLAY_WIDTH = 0x54B0; + private static final int ID_DISPLAY_HEIGHT = 0x54BA; + private static final int ID_DISPLAY_UNIT = 0x54B2; + private static final int ID_AUDIO = 0xE1; + private static final int ID_CHANNELS = 0x9F; + private static final int ID_AUDIO_BIT_DEPTH = 0x6264; + private static final int ID_SAMPLING_FREQUENCY = 0xB5; + private static final int ID_CONTENT_ENCODINGS = 0x6D80; + private static final int ID_CONTENT_ENCODING = 0x6240; + private static final int ID_CONTENT_ENCODING_ORDER = 0x5031; + private static final int ID_CONTENT_ENCODING_SCOPE = 0x5032; + private static final int ID_CONTENT_COMPRESSION = 0x5034; + private static final int ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254; + private static final int ID_CONTENT_COMPRESSION_SETTINGS = 0x4255; + private static final int ID_CONTENT_ENCRYPTION = 0x5035; + private static final int ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1; + private static final int ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7; + private static final int ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8; + private static final int ID_CUES = 0x1C53BB6B; + private static final int ID_CUE_POINT = 0xBB; + private static final int ID_CUE_TIME = 0xB3; + private static final int ID_CUE_TRACK_POSITIONS = 0xB7; + private static final int ID_CUE_CLUSTER_POSITION = 0xF1; + private static final int ID_LANGUAGE = 0x22B59C; + private static final int ID_PROJECTION = 0x7670; + private static final int ID_PROJECTION_TYPE = 0x7671; + private static final int ID_PROJECTION_PRIVATE = 0x7672; + private static final int ID_PROJECTION_POSE_YAW = 0x7673; + private static final int ID_PROJECTION_POSE_PITCH = 0x7674; + private static final int ID_PROJECTION_POSE_ROLL = 0x7675; + private static final int ID_STEREO_MODE = 0x53B8; + private static final int ID_COLOUR = 0x55B0; + private static final int ID_COLOUR_RANGE = 0x55B9; + private static final int ID_COLOUR_TRANSFER = 0x55BA; + private static final int ID_COLOUR_PRIMARIES = 0x55BB; + private static final int ID_MAX_CLL = 0x55BC; + private static final int ID_MAX_FALL = 0x55BD; + private static final int ID_MASTERING_METADATA = 0x55D0; + private static final int ID_PRIMARY_R_CHROMATICITY_X = 0x55D1; + private static final int ID_PRIMARY_R_CHROMATICITY_Y = 0x55D2; + private static final int ID_PRIMARY_G_CHROMATICITY_X = 0x55D3; + private static final int ID_PRIMARY_G_CHROMATICITY_Y = 0x55D4; + private static final int ID_PRIMARY_B_CHROMATICITY_X = 0x55D5; + private static final int ID_PRIMARY_B_CHROMATICITY_Y = 0x55D6; + private static final int ID_WHITE_POINT_CHROMATICITY_X = 0x55D7; + private static final int ID_WHITE_POINT_CHROMATICITY_Y = 0x55D8; + private static final int ID_LUMNINANCE_MAX = 0x55D9; + private static final int ID_LUMNINANCE_MIN = 0x55DA; + + /** + * BlockAddID value for ITU T.35 metadata in a VP9 track. See also + * https://www.webmproject.org/docs/container/. + */ + private static final int BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4; + + private static final int LACING_NONE = 0; + private static final int LACING_XIPH = 1; + private static final int LACING_FIXED_SIZE = 2; + private static final int LACING_EBML = 3; + + private static final int FOURCC_COMPRESSION_DIVX = 0x58564944; + private static final int FOURCC_COMPRESSION_H263 = 0x33363248; + private static final int FOURCC_COMPRESSION_VC1 = 0x31435657; + + /** + * A template for the prefix that must be added to each subrip sample. + * + * <p>The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be replaced with + * the duration of the subtitle. + * + * <p>Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". + */ + private static final byte[] SUBRIP_PREFIX = + new byte[] { + 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, + 48, 58, 48, 48, 44, 48, 48, 48, 10 + }; + /** + * The byte offset of the end timecode in {@link #SUBRIP_PREFIX}. + */ + private static final int SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19; + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in a subrip timecode (milliseconds). + */ + private static final long SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR = 1000; + /** + * The format of a subrip timecode. + */ + private static final String SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d"; + + /** + * Matroska specific format line for SSA subtitles. + */ + private static final byte[] SSA_DIALOGUE_FORMAT = Util.getUtf8Bytes("Format: Start, End, " + + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text"); + /** + * A template for the prefix that must be added to each SSA sample. + * + * <p>The display time of each subtitle is passed as {@code timeUs} to {@link + * TrackOutput#sampleMetadata}. The start and end timecodes in this template are relative to + * {@code timeUs}. Hence the start timecode is always zero. The 12 byte end timecode starting at + * {@link #SUBRIP_PREFIX_END_TIMECODE_OFFSET} is set to a dummy value, and must be replaced with + * the duration of the subtitle. + * + * <p>Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". + */ + private static final byte[] SSA_PREFIX = + new byte[] { + 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, + 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 + }; + /** + * The byte offset of the end timecode in {@link #SSA_PREFIX}. + */ + private static final int SSA_PREFIX_END_TIMECODE_OFFSET = 21; + /** + * The value by which to divide a time in microseconds to convert it to the unit of the last value + * in an SSA timecode (1/100ths of a second). + */ + private static final long SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR = 10000; + /** + * The format of an SSA timecode. + */ + private static final String SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d"; + + /** + * The length in bytes of a WAVEFORMATEX structure. + */ + private static final int WAVE_FORMAT_SIZE = 18; + /** + * Format tag indicating a WAVEFORMATEXTENSIBLE structure. + */ + private static final int WAVE_FORMAT_EXTENSIBLE = 0xFFFE; + /** + * Format tag for PCM. + */ + private static final int WAVE_FORMAT_PCM = 1; + /** + * Sub format for PCM. + */ + private static final UUID WAVE_SUBFORMAT_PCM = new UUID(0x0100000000001000L, 0x800000AA00389B71L); + + private final EbmlReader reader; + private final VarintReader varintReader; + private final SparseArray<Track> tracks; + private final boolean seekForCuesEnabled; + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalLength; + private final ParsableByteArray scratch; + private final ParsableByteArray vorbisNumPageSamples; + private final ParsableByteArray seekEntryIdBytes; + private final ParsableByteArray sampleStrippedBytes; + private final ParsableByteArray subtitleSample; + private final ParsableByteArray encryptionInitializationVector; + private final ParsableByteArray encryptionSubsampleData; + private final ParsableByteArray blockAdditionalData; + private ByteBuffer encryptionSubsampleDataBuffer; + + private long segmentContentSize; + private long segmentContentPosition = C.POSITION_UNSET; + private long timecodeScale = C.TIME_UNSET; + private long durationTimecode = C.TIME_UNSET; + private long durationUs = C.TIME_UNSET; + + // The track corresponding to the current TrackEntry element, or null. + private Track currentTrack; + + // Whether a seek map has been sent to the output. + private boolean sentSeekMap; + + // Master seek entry related elements. + private int seekEntryId; + private long seekEntryPosition; + + // Cue related elements. + private boolean seekForCues; + private long cuesContentPosition = C.POSITION_UNSET; + private long seekPositionAfterBuildingCues = C.POSITION_UNSET; + private long clusterTimecodeUs = C.TIME_UNSET; + private LongArray cueTimesUs; + private LongArray cueClusterPositions; + private boolean seenClusterPositionForCurrentCuePoint; + + // Reading state. + private boolean haveOutputSample; + + // Block reading state. + private int blockState; + private long blockTimeUs; + private long blockDurationUs; + private int blockSampleIndex; + private int blockSampleCount; + private int[] blockSampleSizes; + private int blockTrackNumber; + private int blockTrackNumberLength; + @C.BufferFlags + private int blockFlags; + private int blockAdditionalId; + private boolean blockHasReferenceBlock; + + // Sample writing state. + private int sampleBytesRead; + private int sampleBytesWritten; + private int sampleCurrentNalBytesRemaining; + private boolean sampleEncodingHandled; + private boolean sampleSignalByteRead; + private boolean samplePartitionCountRead; + private int samplePartitionCount; + private byte sampleSignalByte; + private boolean sampleInitializationVectorRead; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + + public MatroskaExtractor() { + this(0); + } + + public MatroskaExtractor(@Flags int flags) { + this(new DefaultEbmlReader(), flags); + } + + /* package */ MatroskaExtractor(EbmlReader reader, @Flags int flags) { + this.reader = reader; + this.reader.init(new InnerEbmlProcessor()); + seekForCuesEnabled = (flags & FLAG_DISABLE_SEEK_FOR_CUES) == 0; + varintReader = new VarintReader(); + tracks = new SparseArray<>(); + scratch = new ParsableByteArray(4); + vorbisNumPageSamples = new ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()); + seekEntryIdBytes = new ParsableByteArray(4); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalLength = new ParsableByteArray(4); + sampleStrippedBytes = new ParsableByteArray(); + subtitleSample = new ParsableByteArray(); + encryptionInitializationVector = new ParsableByteArray(ENCRYPTION_IV_SIZE); + encryptionSubsampleData = new ParsableByteArray(); + blockAdditionalData = new ParsableByteArray(); + } + + @Override + public final boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return new Sniffer().sniff(input); + } + + @Override + public final void init(ExtractorOutput output) { + extractorOutput = output; + } + + @CallSuper + @Override + public void seek(long position, long timeUs) { + clusterTimecodeUs = C.TIME_UNSET; + blockState = BLOCK_STATE_START; + reader.reset(); + varintReader.reset(); + resetWriteSampleData(); + for (int i = 0; i < tracks.size(); i++) { + tracks.valueAt(i).reset(); + } + } + + @Override + public final void release() { + // Do nothing + } + + @Override + public final int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + haveOutputSample = false; + boolean continueReading = true; + while (continueReading && !haveOutputSample) { + continueReading = reader.read(input); + if (continueReading && maybeSeekForCues(seekPosition, input.getPosition())) { + return Extractor.RESULT_SEEK; + } + } + if (!continueReading) { + for (int i = 0; i < tracks.size(); i++) { + tracks.valueAt(i).outputPendingSampleMetadata(); + } + return Extractor.RESULT_END_OF_INPUT; + } + return Extractor.RESULT_CONTINUE; + } + + /** + * Maps an element ID to a corresponding type. + * + * @see EbmlProcessor#getElementType(int) + */ + @CallSuper + @EbmlProcessor.ElementType + protected int getElementType(int id) { + switch (id) { + case ID_EBML: + case ID_SEGMENT: + case ID_SEEK_HEAD: + case ID_SEEK: + case ID_INFO: + case ID_CLUSTER: + case ID_TRACKS: + case ID_TRACK_ENTRY: + case ID_AUDIO: + case ID_VIDEO: + case ID_CONTENT_ENCODINGS: + case ID_CONTENT_ENCODING: + case ID_CONTENT_COMPRESSION: + case ID_CONTENT_ENCRYPTION: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS: + case ID_CUES: + case ID_CUE_POINT: + case ID_CUE_TRACK_POSITIONS: + case ID_BLOCK_GROUP: + case ID_BLOCK_ADDITIONS: + case ID_BLOCK_MORE: + case ID_PROJECTION: + case ID_COLOUR: + case ID_MASTERING_METADATA: + return EbmlProcessor.ELEMENT_TYPE_MASTER; + case ID_EBML_READ_VERSION: + case ID_DOC_TYPE_READ_VERSION: + case ID_SEEK_POSITION: + case ID_TIMECODE_SCALE: + case ID_TIME_CODE: + case ID_BLOCK_DURATION: + case ID_PIXEL_WIDTH: + case ID_PIXEL_HEIGHT: + case ID_DISPLAY_WIDTH: + case ID_DISPLAY_HEIGHT: + case ID_DISPLAY_UNIT: + case ID_TRACK_NUMBER: + case ID_TRACK_TYPE: + case ID_FLAG_DEFAULT: + case ID_FLAG_FORCED: + case ID_DEFAULT_DURATION: + case ID_MAX_BLOCK_ADDITION_ID: + case ID_CODEC_DELAY: + case ID_SEEK_PRE_ROLL: + case ID_CHANNELS: + case ID_AUDIO_BIT_DEPTH: + case ID_CONTENT_ENCODING_ORDER: + case ID_CONTENT_ENCODING_SCOPE: + case ID_CONTENT_COMPRESSION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_ALGORITHM: + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + case ID_CUE_TIME: + case ID_CUE_CLUSTER_POSITION: + case ID_REFERENCE_BLOCK: + case ID_STEREO_MODE: + case ID_COLOUR_RANGE: + case ID_COLOUR_TRANSFER: + case ID_COLOUR_PRIMARIES: + case ID_MAX_CLL: + case ID_MAX_FALL: + case ID_PROJECTION_TYPE: + case ID_BLOCK_ADD_ID: + return EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT; + case ID_DOC_TYPE: + case ID_NAME: + case ID_CODEC_ID: + case ID_LANGUAGE: + return EbmlProcessor.ELEMENT_TYPE_STRING; + case ID_SEEK_ID: + case ID_CONTENT_COMPRESSION_SETTINGS: + case ID_CONTENT_ENCRYPTION_KEY_ID: + case ID_SIMPLE_BLOCK: + case ID_BLOCK: + case ID_CODEC_PRIVATE: + case ID_PROJECTION_PRIVATE: + case ID_BLOCK_ADDITIONAL: + return EbmlProcessor.ELEMENT_TYPE_BINARY; + case ID_DURATION: + case ID_SAMPLING_FREQUENCY: + case ID_PRIMARY_R_CHROMATICITY_X: + case ID_PRIMARY_R_CHROMATICITY_Y: + case ID_PRIMARY_G_CHROMATICITY_X: + case ID_PRIMARY_G_CHROMATICITY_Y: + case ID_PRIMARY_B_CHROMATICITY_X: + case ID_PRIMARY_B_CHROMATICITY_Y: + case ID_WHITE_POINT_CHROMATICITY_X: + case ID_WHITE_POINT_CHROMATICITY_Y: + case ID_LUMNINANCE_MAX: + case ID_LUMNINANCE_MIN: + case ID_PROJECTION_POSE_YAW: + case ID_PROJECTION_POSE_PITCH: + case ID_PROJECTION_POSE_ROLL: + return EbmlProcessor.ELEMENT_TYPE_FLOAT; + default: + return EbmlProcessor.ELEMENT_TYPE_UNKNOWN; + } + } + + /** + * Checks if the given id is that of a level 1 element. + * + * @see EbmlProcessor#isLevel1Element(int) + */ + @CallSuper + protected boolean isLevel1Element(int id) { + return id == ID_SEGMENT_INFO || id == ID_CLUSTER || id == ID_CUES || id == ID_TRACKS; + } + + /** + * Called when the start of a master element is encountered. + * + * @see EbmlProcessor#startMasterElement(int, long, long) + */ + @CallSuper + protected void startMasterElement(int id, long contentPosition, long contentSize) + throws ParserException { + switch (id) { + case ID_SEGMENT: + if (segmentContentPosition != C.POSITION_UNSET + && segmentContentPosition != contentPosition) { + throw new ParserException("Multiple Segment elements not supported"); + } + segmentContentPosition = contentPosition; + segmentContentSize = contentSize; + break; + case ID_SEEK: + seekEntryId = UNSET_ENTRY_ID; + seekEntryPosition = C.POSITION_UNSET; + break; + case ID_CUES: + cueTimesUs = new LongArray(); + cueClusterPositions = new LongArray(); + break; + case ID_CUE_POINT: + seenClusterPositionForCurrentCuePoint = false; + break; + case ID_CLUSTER: + if (!sentSeekMap) { + // We need to build cues before parsing the cluster. + if (seekForCuesEnabled && cuesContentPosition != C.POSITION_UNSET) { + // We know where the Cues element is located. Seek to request it. + seekForCues = true; + } else { + // We don't know where the Cues element is located. It's most likely omitted. Allow + // playback, but disable seeking. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs)); + sentSeekMap = true; + } + } + break; + case ID_BLOCK_GROUP: + blockHasReferenceBlock = false; + break; + case ID_CONTENT_ENCODING: + // TODO: check and fail if more than one content encoding is present. + break; + case ID_CONTENT_ENCRYPTION: + currentTrack.hasContentEncryption = true; + break; + case ID_TRACK_ENTRY: + currentTrack = new Track(); + break; + case ID_MASTERING_METADATA: + currentTrack.hasColorInfo = true; + break; + default: + break; + } + } + + /** + * Called when the end of a master element is encountered. + * + * @see EbmlProcessor#endMasterElement(int) + */ + @CallSuper + protected void endMasterElement(int id) throws ParserException { + switch (id) { + case ID_SEGMENT_INFO: + if (timecodeScale == C.TIME_UNSET) { + // timecodeScale was omitted. Use the default value. + timecodeScale = 1000000; + } + if (durationTimecode != C.TIME_UNSET) { + durationUs = scaleTimecodeToUs(durationTimecode); + } + break; + case ID_SEEK: + if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.POSITION_UNSET) { + throw new ParserException("Mandatory element SeekID or SeekPosition not found"); + } + if (seekEntryId == ID_CUES) { + cuesContentPosition = seekEntryPosition; + } + break; + case ID_CUES: + if (!sentSeekMap) { + extractorOutput.seekMap(buildSeekMap()); + sentSeekMap = true; + } else { + // We have already built the cues. Ignore. + } + break; + case ID_BLOCK_GROUP: + if (blockState != BLOCK_STATE_DATA) { + // We've skipped this block (due to incompatible track number). + return; + } + // Commit sample metadata. + int sampleOffset = 0; + for (int i = 0; i < blockSampleCount; i++) { + sampleOffset += blockSampleSizes[i]; + } + Track track = tracks.get(blockTrackNumber); + for (int i = 0; i < blockSampleCount; i++) { + long sampleTimeUs = blockTimeUs + (i * track.defaultSampleDurationNs) / 1000; + int sampleFlags = blockFlags; + if (i == 0 && !blockHasReferenceBlock) { + // If the ReferenceBlock element was not found in this block, then the first frame is a + // keyframe. + sampleFlags |= C.BUFFER_FLAG_KEY_FRAME; + } + int sampleSize = blockSampleSizes[i]; + sampleOffset -= sampleSize; // The offset is to the end of the sample. + commitSampleToOutput(track, sampleTimeUs, sampleFlags, sampleSize, sampleOffset); + } + blockState = BLOCK_STATE_START; + break; + case ID_CONTENT_ENCODING: + if (currentTrack.hasContentEncryption) { + if (currentTrack.cryptoData == null) { + throw new ParserException("Encrypted Track found but ContentEncKeyID was not found"); + } + currentTrack.drmInitData = new DrmInitData(new SchemeData(C.UUID_NIL, + MimeTypes.VIDEO_WEBM, currentTrack.cryptoData.encryptionKey)); + } + break; + case ID_CONTENT_ENCODINGS: + if (currentTrack.hasContentEncryption && currentTrack.sampleStrippedBytes != null) { + throw new ParserException("Combining encryption and compression is not supported"); + } + break; + case ID_TRACK_ENTRY: + if (isCodecSupported(currentTrack.codecId)) { + currentTrack.initializeOutput(extractorOutput, currentTrack.number); + tracks.put(currentTrack.number, currentTrack); + } + currentTrack = null; + break; + case ID_TRACKS: + if (tracks.size() == 0) { + throw new ParserException("No valid tracks were found"); + } + extractorOutput.endTracks(); + break; + default: + break; + } + } + + /** + * Called when an integer element is encountered. + * + * @see EbmlProcessor#integerElement(int, long) + */ + @CallSuper + protected void integerElement(int id, long value) throws ParserException { + switch (id) { + case ID_EBML_READ_VERSION: + // Validate that EBMLReadVersion is supported. This extractor only supports v1. + if (value != 1) { + throw new ParserException("EBMLReadVersion " + value + " not supported"); + } + break; + case ID_DOC_TYPE_READ_VERSION: + // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. + if (value < 1 || value > 2) { + throw new ParserException("DocTypeReadVersion " + value + " not supported"); + } + break; + case ID_SEEK_POSITION: + // Seek Position is the relative offset beginning from the Segment. So to get absolute + // offset from the beginning of the file, we need to add segmentContentPosition to it. + seekEntryPosition = value + segmentContentPosition; + break; + case ID_TIMECODE_SCALE: + timecodeScale = value; + break; + case ID_PIXEL_WIDTH: + currentTrack.width = (int) value; + break; + case ID_PIXEL_HEIGHT: + currentTrack.height = (int) value; + break; + case ID_DISPLAY_WIDTH: + currentTrack.displayWidth = (int) value; + break; + case ID_DISPLAY_HEIGHT: + currentTrack.displayHeight = (int) value; + break; + case ID_DISPLAY_UNIT: + currentTrack.displayUnit = (int) value; + break; + case ID_TRACK_NUMBER: + currentTrack.number = (int) value; + break; + case ID_FLAG_DEFAULT: + currentTrack.flagDefault = value == 1; + break; + case ID_FLAG_FORCED: + currentTrack.flagForced = value == 1; + break; + case ID_TRACK_TYPE: + currentTrack.type = (int) value; + break; + case ID_DEFAULT_DURATION: + currentTrack.defaultSampleDurationNs = (int) value; + break; + case ID_MAX_BLOCK_ADDITION_ID: + currentTrack.maxBlockAdditionId = (int) value; + break; + case ID_CODEC_DELAY: + currentTrack.codecDelayNs = value; + break; + case ID_SEEK_PRE_ROLL: + currentTrack.seekPreRollNs = value; + break; + case ID_CHANNELS: + currentTrack.channelCount = (int) value; + break; + case ID_AUDIO_BIT_DEPTH: + currentTrack.audioBitDepth = (int) value; + break; + case ID_REFERENCE_BLOCK: + blockHasReferenceBlock = true; + break; + case ID_CONTENT_ENCODING_ORDER: + // This extractor only supports one ContentEncoding element and hence the order has to be 0. + if (value != 0) { + throw new ParserException("ContentEncodingOrder " + value + " not supported"); + } + break; + case ID_CONTENT_ENCODING_SCOPE: + // This extractor only supports the scope of all frames. + if (value != 1) { + throw new ParserException("ContentEncodingScope " + value + " not supported"); + } + break; + case ID_CONTENT_COMPRESSION_ALGORITHM: + // This extractor only supports header stripping. + if (value != 3) { + throw new ParserException("ContentCompAlgo " + value + " not supported"); + } + break; + case ID_CONTENT_ENCRYPTION_ALGORITHM: + // Only the value 5 (AES) is allowed according to the WebM specification. + if (value != 5) { + throw new ParserException("ContentEncAlgo " + value + " not supported"); + } + break; + case ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE: + // Only the value 1 is allowed according to the WebM specification. + if (value != 1) { + throw new ParserException("AESSettingsCipherMode " + value + " not supported"); + } + break; + case ID_CUE_TIME: + cueTimesUs.add(scaleTimecodeToUs(value)); + break; + case ID_CUE_CLUSTER_POSITION: + if (!seenClusterPositionForCurrentCuePoint) { + // If there's more than one video/audio track, then there could be more than one + // CueTrackPositions within a single CuePoint. In such a case, ignore all but the first + // one (since the cluster position will be quite close for all the tracks). + cueClusterPositions.add(value); + seenClusterPositionForCurrentCuePoint = true; + } + break; + case ID_TIME_CODE: + clusterTimecodeUs = scaleTimecodeToUs(value); + break; + case ID_BLOCK_DURATION: + blockDurationUs = scaleTimecodeToUs(value); + break; + case ID_STEREO_MODE: + int layout = (int) value; + switch (layout) { + case 0: + currentTrack.stereoMode = C.STEREO_MODE_MONO; + break; + case 1: + currentTrack.stereoMode = C.STEREO_MODE_LEFT_RIGHT; + break; + case 3: + currentTrack.stereoMode = C.STEREO_MODE_TOP_BOTTOM; + break; + case 15: + currentTrack.stereoMode = C.STEREO_MODE_STEREO_MESH; + break; + default: + break; + } + break; + case ID_COLOUR_PRIMARIES: + currentTrack.hasColorInfo = true; + switch ((int) value) { + case 1: + currentTrack.colorSpace = C.COLOR_SPACE_BT709; + break; + case 4: // BT.470M. + case 5: // BT.470BG. + case 6: // SMPTE 170M. + case 7: // SMPTE 240M. + currentTrack.colorSpace = C.COLOR_SPACE_BT601; + break; + case 9: + currentTrack.colorSpace = C.COLOR_SPACE_BT2020; + break; + default: + break; + } + break; + case ID_COLOUR_TRANSFER: + switch ((int) value) { + case 1: // BT.709. + case 6: // SMPTE 170M. + case 7: // SMPTE 240M. + currentTrack.colorTransfer = C.COLOR_TRANSFER_SDR; + break; + case 16: + currentTrack.colorTransfer = C.COLOR_TRANSFER_ST2084; + break; + case 18: + currentTrack.colorTransfer = C.COLOR_TRANSFER_HLG; + break; + default: + break; + } + break; + case ID_COLOUR_RANGE: + switch((int) value) { + case 1: // Broadcast range. + currentTrack.colorRange = C.COLOR_RANGE_LIMITED; + break; + case 2: + currentTrack.colorRange = C.COLOR_RANGE_FULL; + break; + default: + break; + } + break; + case ID_MAX_CLL: + currentTrack.maxContentLuminance = (int) value; + break; + case ID_MAX_FALL: + currentTrack.maxFrameAverageLuminance = (int) value; + break; + case ID_PROJECTION_TYPE: + switch ((int) value) { + case 0: + currentTrack.projectionType = C.PROJECTION_RECTANGULAR; + break; + case 1: + currentTrack.projectionType = C.PROJECTION_EQUIRECTANGULAR; + break; + case 2: + currentTrack.projectionType = C.PROJECTION_CUBEMAP; + break; + case 3: + currentTrack.projectionType = C.PROJECTION_MESH; + break; + default: + break; + } + break; + case ID_BLOCK_ADD_ID: + blockAdditionalId = (int) value; + break; + default: + break; + } + } + + /** + * Called when a float element is encountered. + * + * @see EbmlProcessor#floatElement(int, double) + */ + @CallSuper + protected void floatElement(int id, double value) throws ParserException { + switch (id) { + case ID_DURATION: + durationTimecode = (long) value; + break; + case ID_SAMPLING_FREQUENCY: + currentTrack.sampleRate = (int) value; + break; + case ID_PRIMARY_R_CHROMATICITY_X: + currentTrack.primaryRChromaticityX = (float) value; + break; + case ID_PRIMARY_R_CHROMATICITY_Y: + currentTrack.primaryRChromaticityY = (float) value; + break; + case ID_PRIMARY_G_CHROMATICITY_X: + currentTrack.primaryGChromaticityX = (float) value; + break; + case ID_PRIMARY_G_CHROMATICITY_Y: + currentTrack.primaryGChromaticityY = (float) value; + break; + case ID_PRIMARY_B_CHROMATICITY_X: + currentTrack.primaryBChromaticityX = (float) value; + break; + case ID_PRIMARY_B_CHROMATICITY_Y: + currentTrack.primaryBChromaticityY = (float) value; + break; + case ID_WHITE_POINT_CHROMATICITY_X: + currentTrack.whitePointChromaticityX = (float) value; + break; + case ID_WHITE_POINT_CHROMATICITY_Y: + currentTrack.whitePointChromaticityY = (float) value; + break; + case ID_LUMNINANCE_MAX: + currentTrack.maxMasteringLuminance = (float) value; + break; + case ID_LUMNINANCE_MIN: + currentTrack.minMasteringLuminance = (float) value; + break; + case ID_PROJECTION_POSE_YAW: + currentTrack.projectionPoseYaw = (float) value; + break; + case ID_PROJECTION_POSE_PITCH: + currentTrack.projectionPosePitch = (float) value; + break; + case ID_PROJECTION_POSE_ROLL: + currentTrack.projectionPoseRoll = (float) value; + break; + default: + break; + } + } + + /** + * Called when a string element is encountered. + * + * @see EbmlProcessor#stringElement(int, String) + */ + @CallSuper + protected void stringElement(int id, String value) throws ParserException { + switch (id) { + case ID_DOC_TYPE: + // Validate that DocType is supported. + if (!DOC_TYPE_WEBM.equals(value) && !DOC_TYPE_MATROSKA.equals(value)) { + throw new ParserException("DocType " + value + " not supported"); + } + break; + case ID_NAME: + currentTrack.name = value; + break; + case ID_CODEC_ID: + currentTrack.codecId = value; + break; + case ID_LANGUAGE: + currentTrack.language = value; + break; + default: + break; + } + } + + /** + * Called when a binary element is encountered. + * + * @see EbmlProcessor#binaryElement(int, int, ExtractorInput) + */ + @CallSuper + protected void binaryElement(int id, int contentSize, ExtractorInput input) + throws IOException, InterruptedException { + switch (id) { + case ID_SEEK_ID: + Arrays.fill(seekEntryIdBytes.data, (byte) 0); + input.readFully(seekEntryIdBytes.data, 4 - contentSize, contentSize); + seekEntryIdBytes.setPosition(0); + seekEntryId = (int) seekEntryIdBytes.readUnsignedInt(); + break; + case ID_CODEC_PRIVATE: + currentTrack.codecPrivate = new byte[contentSize]; + input.readFully(currentTrack.codecPrivate, 0, contentSize); + break; + case ID_PROJECTION_PRIVATE: + currentTrack.projectionData = new byte[contentSize]; + input.readFully(currentTrack.projectionData, 0, contentSize); + break; + case ID_CONTENT_COMPRESSION_SETTINGS: + // This extractor only supports header stripping, so the payload is the stripped bytes. + currentTrack.sampleStrippedBytes = new byte[contentSize]; + input.readFully(currentTrack.sampleStrippedBytes, 0, contentSize); + break; + case ID_CONTENT_ENCRYPTION_KEY_ID: + byte[] encryptionKey = new byte[contentSize]; + input.readFully(encryptionKey, 0, contentSize); + currentTrack.cryptoData = new TrackOutput.CryptoData(C.CRYPTO_MODE_AES_CTR, encryptionKey, + 0, 0); // We assume patternless AES-CTR. + break; + case ID_SIMPLE_BLOCK: + case ID_BLOCK: + // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure + // and http://matroska.org/technical/specs/index.html#block_structure + // for info about how data is organized in SimpleBlock and Block elements respectively. They + // differ only in the way flags are specified. + + if (blockState == BLOCK_STATE_START) { + blockTrackNumber = (int) varintReader.readUnsignedVarint(input, false, true, 8); + blockTrackNumberLength = varintReader.getLastLength(); + blockDurationUs = C.TIME_UNSET; + blockState = BLOCK_STATE_HEADER; + scratch.reset(); + } + + Track track = tracks.get(blockTrackNumber); + + // Ignore the block if we don't know about the track to which it belongs. + if (track == null) { + input.skipFully(contentSize - blockTrackNumberLength); + blockState = BLOCK_STATE_START; + return; + } + + if (blockState == BLOCK_STATE_HEADER) { + // Read the relative timecode (2 bytes) and flags (1 byte). + readScratch(input, 3); + int lacing = (scratch.data[2] & 0x06) >> 1; + if (lacing == LACING_NONE) { + blockSampleCount = 1; + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1); + blockSampleSizes[0] = contentSize - blockTrackNumberLength - 3; + } else { + // Read the sample count (1 byte). + readScratch(input, 4); + blockSampleCount = (scratch.data[3] & 0xFF) + 1; + blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount); + if (lacing == LACING_FIXED_SIZE) { + int blockLacingSampleSize = + (contentSize - blockTrackNumberLength - 4) / blockSampleCount; + Arrays.fill(blockSampleSizes, 0, blockSampleCount, blockLacingSampleSize); + } else if (lacing == LACING_XIPH) { + int totalSamplesSize = 0; + int headerSize = 4; + for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { + blockSampleSizes[sampleIndex] = 0; + int byteValue; + do { + readScratch(input, ++headerSize); + byteValue = scratch.data[headerSize - 1] & 0xFF; + blockSampleSizes[sampleIndex] += byteValue; + } while (byteValue == 0xFF); + totalSamplesSize += blockSampleSizes[sampleIndex]; + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize; + } else if (lacing == LACING_EBML) { + int totalSamplesSize = 0; + int headerSize = 4; + for (int sampleIndex = 0; sampleIndex < blockSampleCount - 1; sampleIndex++) { + blockSampleSizes[sampleIndex] = 0; + readScratch(input, ++headerSize); + if (scratch.data[headerSize - 1] == 0) { + throw new ParserException("No valid varint length mask found"); + } + long readValue = 0; + for (int i = 0; i < 8; i++) { + int lengthMask = 1 << (7 - i); + if ((scratch.data[headerSize - 1] & lengthMask) != 0) { + int readPosition = headerSize - 1; + headerSize += i; + readScratch(input, headerSize); + readValue = (scratch.data[readPosition++] & 0xFF) & ~lengthMask; + while (readPosition < headerSize) { + readValue <<= 8; + readValue |= (scratch.data[readPosition++] & 0xFF); + } + // The first read value is the first size. Later values are signed offsets. + if (sampleIndex > 0) { + readValue -= (1L << (6 + i * 7)) - 1; + } + break; + } + } + if (readValue < Integer.MIN_VALUE || readValue > Integer.MAX_VALUE) { + throw new ParserException("EBML lacing sample size out of range."); + } + int intReadValue = (int) readValue; + blockSampleSizes[sampleIndex] = + sampleIndex == 0 + ? intReadValue + : blockSampleSizes[sampleIndex - 1] + intReadValue; + totalSamplesSize += blockSampleSizes[sampleIndex]; + } + blockSampleSizes[blockSampleCount - 1] = + contentSize - blockTrackNumberLength - headerSize - totalSamplesSize; + } else { + // Lacing is always in the range 0--3. + throw new ParserException("Unexpected lacing value: " + lacing); + } + } + + int timecode = (scratch.data[0] << 8) | (scratch.data[1] & 0xFF); + blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode); + boolean isInvisible = (scratch.data[2] & 0x08) == 0x08; + boolean isKeyframe = track.type == TRACK_TYPE_AUDIO + || (id == ID_SIMPLE_BLOCK && (scratch.data[2] & 0x80) == 0x80); + blockFlags = (isKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0) + | (isInvisible ? C.BUFFER_FLAG_DECODE_ONLY : 0); + blockState = BLOCK_STATE_DATA; + blockSampleIndex = 0; + } + + if (id == ID_SIMPLE_BLOCK) { + // For SimpleBlock, we can write sample data and immediately commit the corresponding + // sample metadata. + while (blockSampleIndex < blockSampleCount) { + int sampleSize = writeSampleData(input, track, blockSampleSizes[blockSampleIndex]); + long sampleTimeUs = + blockTimeUs + (blockSampleIndex * track.defaultSampleDurationNs) / 1000; + commitSampleToOutput(track, sampleTimeUs, blockFlags, sampleSize, /* offset= */ 0); + blockSampleIndex++; + } + blockState = BLOCK_STATE_START; + } else { + // For Block, we need to wait until the end of the BlockGroup element before committing + // sample metadata. This is so that we can handle ReferenceBlock (which can be used to + // infer whether the first sample in the block is a keyframe), and BlockAdditions (which + // can contain additional sample data to append) contained in the block group. Just output + // the sample data, storing the final sample sizes for when we commit the metadata. + while (blockSampleIndex < blockSampleCount) { + blockSampleSizes[blockSampleIndex] = + writeSampleData(input, track, blockSampleSizes[blockSampleIndex]); + blockSampleIndex++; + } + } + + break; + case ID_BLOCK_ADDITIONAL: + if (blockState != BLOCK_STATE_DATA) { + return; + } + handleBlockAdditionalData( + tracks.get(blockTrackNumber), blockAdditionalId, input, contentSize); + break; + default: + throw new ParserException("Unexpected id: " + id); + } + } + + protected void handleBlockAdditionalData( + Track track, int blockAdditionalId, ExtractorInput input, int contentSize) + throws IOException, InterruptedException { + if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 + && CODEC_ID_VP9.equals(track.codecId)) { + blockAdditionalData.reset(contentSize); + input.readFully(blockAdditionalData.data, 0, contentSize); + } else { + // Unhandled block additional data. + input.skipFully(contentSize); + } + } + + private void commitSampleToOutput( + Track track, long timeUs, @C.BufferFlags int flags, int size, int offset) { + if (track.trueHdSampleRechunker != null) { + track.trueHdSampleRechunker.sampleMetadata(track, timeUs, flags, size, offset); + } else { + if (CODEC_ID_SUBRIP.equals(track.codecId) || CODEC_ID_ASS.equals(track.codecId)) { + if (blockSampleCount > 1) { + Log.w(TAG, "Skipping subtitle sample in laced block."); + } else if (blockDurationUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping subtitle sample with no duration."); + } else { + setSubtitleEndTime(track.codecId, blockDurationUs, subtitleSample.data); + // Note: If we ever want to support DRM protected subtitles then we'll need to output the + // appropriate encryption data here. + track.output.sampleData(subtitleSample, subtitleSample.limit()); + size += subtitleSample.limit(); + } + } + + if ((flags & C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) { + if (blockSampleCount > 1) { + // There were multiple samples in the block. Appending the additional data to the last + // sample doesn't make sense. Skip instead. + flags &= ~C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA; + } else { + // Append supplemental data. + int blockAdditionalSize = blockAdditionalData.limit(); + track.output.sampleData(blockAdditionalData, blockAdditionalSize); + size += blockAdditionalSize; + } + } + track.output.sampleMetadata(timeUs, flags, size, offset, track.cryptoData); + } + haveOutputSample = true; + } + + /** + * Ensures {@link #scratch} contains at least {@code requiredLength} bytes of data, reading from + * the extractor input if necessary. + */ + private void readScratch(ExtractorInput input, int requiredLength) + throws IOException, InterruptedException { + if (scratch.limit() >= requiredLength) { + return; + } + if (scratch.capacity() < requiredLength) { + scratch.reset(Arrays.copyOf(scratch.data, Math.max(scratch.data.length * 2, requiredLength)), + scratch.limit()); + } + input.readFully(scratch.data, scratch.limit(), requiredLength - scratch.limit()); + scratch.setLimit(requiredLength); + } + + /** + * Writes data for a single sample to the track output. + * + * @param input The input from which to read sample data. + * @param track The track to output the sample to. + * @param size The size of the sample data on the input side. + * @return The final size of the written sample. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private int writeSampleData(ExtractorInput input, Track track, int size) + throws IOException, InterruptedException { + if (CODEC_ID_SUBRIP.equals(track.codecId)) { + writeSubtitleSampleData(input, SUBRIP_PREFIX, size); + return finishWriteSampleData(); + } else if (CODEC_ID_ASS.equals(track.codecId)) { + writeSubtitleSampleData(input, SSA_PREFIX, size); + return finishWriteSampleData(); + } + + TrackOutput output = track.output; + if (!sampleEncodingHandled) { + if (track.hasContentEncryption) { + // If the sample is encrypted, read its encryption signal byte and set the IV size. + // Clear the encrypted flag. + blockFlags &= ~C.BUFFER_FLAG_ENCRYPTED; + if (!sampleSignalByteRead) { + input.readFully(scratch.data, 0, 1); + sampleBytesRead++; + if ((scratch.data[0] & 0x80) == 0x80) { + throw new ParserException("Extension bit is set in signal byte"); + } + sampleSignalByte = scratch.data[0]; + sampleSignalByteRead = true; + } + boolean isEncrypted = (sampleSignalByte & 0x01) == 0x01; + if (isEncrypted) { + boolean hasSubsampleEncryption = (sampleSignalByte & 0x02) == 0x02; + blockFlags |= C.BUFFER_FLAG_ENCRYPTED; + if (!sampleInitializationVectorRead) { + input.readFully(encryptionInitializationVector.data, 0, ENCRYPTION_IV_SIZE); + sampleBytesRead += ENCRYPTION_IV_SIZE; + sampleInitializationVectorRead = true; + // Write the signal byte, containing the IV size and the subsample encryption flag. + scratch.data[0] = (byte) (ENCRYPTION_IV_SIZE | (hasSubsampleEncryption ? 0x80 : 0x00)); + scratch.setPosition(0); + output.sampleData(scratch, 1); + sampleBytesWritten++; + // Write the IV. + encryptionInitializationVector.setPosition(0); + output.sampleData(encryptionInitializationVector, ENCRYPTION_IV_SIZE); + sampleBytesWritten += ENCRYPTION_IV_SIZE; + } + if (hasSubsampleEncryption) { + if (!samplePartitionCountRead) { + input.readFully(scratch.data, 0, 1); + sampleBytesRead++; + scratch.setPosition(0); + samplePartitionCount = scratch.readUnsignedByte(); + samplePartitionCountRead = true; + } + int samplePartitionDataSize = samplePartitionCount * 4; + scratch.reset(samplePartitionDataSize); + input.readFully(scratch.data, 0, samplePartitionDataSize); + sampleBytesRead += samplePartitionDataSize; + short subsampleCount = (short) (1 + (samplePartitionCount / 2)); + int subsampleDataSize = 2 + 6 * subsampleCount; + if (encryptionSubsampleDataBuffer == null + || encryptionSubsampleDataBuffer.capacity() < subsampleDataSize) { + encryptionSubsampleDataBuffer = ByteBuffer.allocate(subsampleDataSize); + } + encryptionSubsampleDataBuffer.position(0); + encryptionSubsampleDataBuffer.putShort(subsampleCount); + // Loop through the partition offsets and write out the data in the way ExoPlayer + // wants it (ISO 23001-7 Part 7): + // 2 bytes - sub sample count. + // for each sub sample: + // 2 bytes - clear data size. + // 4 bytes - encrypted data size. + int partitionOffset = 0; + for (int i = 0; i < samplePartitionCount; i++) { + int previousPartitionOffset = partitionOffset; + partitionOffset = scratch.readUnsignedIntToInt(); + if ((i % 2) == 0) { + encryptionSubsampleDataBuffer.putShort( + (short) (partitionOffset - previousPartitionOffset)); + } else { + encryptionSubsampleDataBuffer.putInt(partitionOffset - previousPartitionOffset); + } + } + int finalPartitionSize = size - sampleBytesRead - partitionOffset; + if ((samplePartitionCount % 2) == 1) { + encryptionSubsampleDataBuffer.putInt(finalPartitionSize); + } else { + encryptionSubsampleDataBuffer.putShort((short) finalPartitionSize); + encryptionSubsampleDataBuffer.putInt(0); + } + encryptionSubsampleData.reset(encryptionSubsampleDataBuffer.array(), subsampleDataSize); + output.sampleData(encryptionSubsampleData, subsampleDataSize); + sampleBytesWritten += subsampleDataSize; + } + } + } else if (track.sampleStrippedBytes != null) { + // If the sample has header stripping, prepare to read/output the stripped bytes first. + sampleStrippedBytes.reset(track.sampleStrippedBytes, track.sampleStrippedBytes.length); + } + + if (track.maxBlockAdditionId > 0) { + blockFlags |= C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA; + blockAdditionalData.reset(); + // If there is supplemental data, the structure of the sample data is: + // sample size (4 bytes) || sample data || supplemental data + scratch.reset(/* limit= */ 4); + scratch.data[0] = (byte) ((size >> 24) & 0xFF); + scratch.data[1] = (byte) ((size >> 16) & 0xFF); + scratch.data[2] = (byte) ((size >> 8) & 0xFF); + scratch.data[3] = (byte) (size & 0xFF); + output.sampleData(scratch, 4); + sampleBytesWritten += 4; + } + + sampleEncodingHandled = true; + } + size += sampleStrippedBytes.limit(); + + if (CODEC_ID_H264.equals(track.codecId) || CODEC_ID_H265.equals(track.codecId)) { + // TODO: Deduplicate with Mp4Extractor. + + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalLengthData = nalLength.data; + nalLengthData[0] = 0; + nalLengthData[1] = 0; + nalLengthData[2] = 0; + int nalUnitLengthFieldLength = track.nalUnitLengthFieldLength; + int nalUnitLengthFieldLengthDiff = 4 - track.nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + while (sampleBytesRead < size) { + if (sampleCurrentNalBytesRemaining == 0) { + // Read the NAL length so that we know where we find the next one. + writeToTarget( + input, nalLengthData, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); + sampleBytesRead += nalUnitLengthFieldLength; + nalLength.setPosition(0); + sampleCurrentNalBytesRemaining = nalLength.readUnsignedIntToInt(); + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + sampleBytesWritten += 4; + } else { + // Write the payload of the NAL unit. + int bytesWritten = writeToOutput(input, output, sampleCurrentNalBytesRemaining); + sampleBytesRead += bytesWritten; + sampleBytesWritten += bytesWritten; + sampleCurrentNalBytesRemaining -= bytesWritten; + } + } + } else { + if (track.trueHdSampleRechunker != null) { + Assertions.checkState(sampleStrippedBytes.limit() == 0); + track.trueHdSampleRechunker.startSample(input); + } + while (sampleBytesRead < size) { + int bytesWritten = writeToOutput(input, output, size - sampleBytesRead); + sampleBytesRead += bytesWritten; + sampleBytesWritten += bytesWritten; + } + } + + if (CODEC_ID_VORBIS.equals(track.codecId)) { + // Vorbis decoder in android MediaCodec [1] expects the last 4 bytes of the sample to be the + // number of samples in the current page. This definition holds good only for Ogg and + // irrelevant for Matroska. So we always set this to -1 (the decoder will ignore this value if + // we set it to -1). The android platform media extractor [2] does the same. + // [1] https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/codecs/vorbis/dec/SoftVorbis.cpp#314 + // [2] https://android.googlesource.com/platform/frameworks/av/+/lollipop-release/media/libstagefright/NuMediaExtractor.cpp#474 + vorbisNumPageSamples.setPosition(0); + output.sampleData(vorbisNumPageSamples, 4); + sampleBytesWritten += 4; + } + + return finishWriteSampleData(); + } + + /** + * Called by {@link #writeSampleData(ExtractorInput, Track, int)} when the sample has been + * written. Returns the final sample size and resets state for the next sample. + */ + private int finishWriteSampleData() { + int sampleSize = sampleBytesWritten; + resetWriteSampleData(); + return sampleSize; + } + + /** Resets state used by {@link #writeSampleData(ExtractorInput, Track, int)}. */ + private void resetWriteSampleData() { + sampleBytesRead = 0; + sampleBytesWritten = 0; + sampleCurrentNalBytesRemaining = 0; + sampleEncodingHandled = false; + sampleSignalByteRead = false; + samplePartitionCountRead = false; + samplePartitionCount = 0; + sampleSignalByte = (byte) 0; + sampleInitializationVectorRead = false; + sampleStrippedBytes.reset(); + } + + private void writeSubtitleSampleData(ExtractorInput input, byte[] samplePrefix, int size) + throws IOException, InterruptedException { + int sizeWithPrefix = samplePrefix.length + size; + if (subtitleSample.capacity() < sizeWithPrefix) { + // Initialize subripSample to contain the required prefix and have space to hold a subtitle + // twice as long as this one. + subtitleSample.data = Arrays.copyOf(samplePrefix, sizeWithPrefix + size); + } else { + System.arraycopy(samplePrefix, 0, subtitleSample.data, 0, samplePrefix.length); + } + input.readFully(subtitleSample.data, samplePrefix.length, size); + subtitleSample.reset(sizeWithPrefix); + // Defer writing the data to the track output. We need to modify the sample data by setting + // the correct end timecode, which we might not have yet. + } + + /** + * Overwrites the end timecode in {@code subtitleData} with the correctly formatted time derived + * from {@code durationUs}. + * + * <p>See documentation on {@link #SSA_DIALOGUE_FORMAT} and {@link #SUBRIP_PREFIX} for why we use + * the duration as the end timecode. + * + * @param codecId The subtitle codec; must be {@link #CODEC_ID_SUBRIP} or {@link #CODEC_ID_ASS}. + * @param durationUs The duration of the sample, in microseconds. + * @param subtitleData The subtitle sample in which to overwrite the end timecode (output + * parameter). + */ + private static void setSubtitleEndTime(String codecId, long durationUs, byte[] subtitleData) { + byte[] endTimecode; + int endTimecodeOffset; + switch (codecId) { + case CODEC_ID_SUBRIP: + endTimecode = + formatSubtitleTimecode( + durationUs, SUBRIP_TIMECODE_FORMAT, SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET; + break; + case CODEC_ID_ASS: + endTimecode = + formatSubtitleTimecode( + durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR); + endTimecodeOffset = SSA_PREFIX_END_TIMECODE_OFFSET; + break; + default: + throw new IllegalArgumentException(); + } + System.arraycopy(endTimecode, 0, subtitleData, endTimecodeOffset, endTimecode.length); + } + + /** + * Formats {@code timeUs} using {@code timecodeFormat}, and sets it as the end timecode in {@code + * subtitleSampleData}. + */ + private static byte[] formatSubtitleTimecode( + long timeUs, String timecodeFormat, long lastTimecodeValueScalingFactor) { + Assertions.checkArgument(timeUs != C.TIME_UNSET); + byte[] timeCodeData; + int hours = (int) (timeUs / (3600 * C.MICROS_PER_SECOND)); + timeUs -= (hours * 3600 * C.MICROS_PER_SECOND); + int minutes = (int) (timeUs / (60 * C.MICROS_PER_SECOND)); + timeUs -= (minutes * 60 * C.MICROS_PER_SECOND); + int seconds = (int) (timeUs / C.MICROS_PER_SECOND); + timeUs -= (seconds * C.MICROS_PER_SECOND); + int lastValue = (int) (timeUs / lastTimecodeValueScalingFactor); + timeCodeData = + Util.getUtf8Bytes( + String.format(Locale.US, timecodeFormat, hours, minutes, seconds, lastValue)); + return timeCodeData; + } + + /** + * Writes {@code length} bytes of sample data into {@code target} at {@code offset}, consisting of + * pending {@link #sampleStrippedBytes} and any remaining data read from {@code input}. + */ + private void writeToTarget(ExtractorInput input, byte[] target, int offset, int length) + throws IOException, InterruptedException { + int pendingStrippedBytes = Math.min(length, sampleStrippedBytes.bytesLeft()); + input.readFully(target, offset + pendingStrippedBytes, length - pendingStrippedBytes); + if (pendingStrippedBytes > 0) { + sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes); + } + } + + /** + * Outputs up to {@code length} bytes of sample data to {@code output}, consisting of either + * {@link #sampleStrippedBytes} or data read from {@code input}. + */ + private int writeToOutput(ExtractorInput input, TrackOutput output, int length) + throws IOException, InterruptedException { + int bytesWritten; + int strippedBytesLeft = sampleStrippedBytes.bytesLeft(); + if (strippedBytesLeft > 0) { + bytesWritten = Math.min(length, strippedBytesLeft); + output.sampleData(sampleStrippedBytes, bytesWritten); + } else { + bytesWritten = output.sampleData(input, length, false); + } + return bytesWritten; + } + + /** + * Builds a {@link SeekMap} from the recently gathered Cues information. + * + * @return The built {@link SeekMap}. The returned {@link SeekMap} may be unseekable if cues + * information was missing or incomplete. + */ + private SeekMap buildSeekMap() { + if (segmentContentPosition == C.POSITION_UNSET || durationUs == C.TIME_UNSET + || cueTimesUs == null || cueTimesUs.size() == 0 + || cueClusterPositions == null || cueClusterPositions.size() != cueTimesUs.size()) { + // Cues information is missing or incomplete. + cueTimesUs = null; + cueClusterPositions = null; + return new SeekMap.Unseekable(durationUs); + } + int cuePointsSize = cueTimesUs.size(); + int[] sizes = new int[cuePointsSize]; + long[] offsets = new long[cuePointsSize]; + long[] durationsUs = new long[cuePointsSize]; + long[] timesUs = new long[cuePointsSize]; + for (int i = 0; i < cuePointsSize; i++) { + timesUs[i] = cueTimesUs.get(i); + offsets[i] = segmentContentPosition + cueClusterPositions.get(i); + } + for (int i = 0; i < cuePointsSize - 1; i++) { + sizes[i] = (int) (offsets[i + 1] - offsets[i]); + durationsUs[i] = timesUs[i + 1] - timesUs[i]; + } + sizes[cuePointsSize - 1] = + (int) (segmentContentPosition + segmentContentSize - offsets[cuePointsSize - 1]); + durationsUs[cuePointsSize - 1] = durationUs - timesUs[cuePointsSize - 1]; + + long lastDurationUs = durationsUs[cuePointsSize - 1]; + if (lastDurationUs <= 0) { + Log.w(TAG, "Discarding last cue point with unexpected duration: " + lastDurationUs); + sizes = Arrays.copyOf(sizes, sizes.length - 1); + offsets = Arrays.copyOf(offsets, offsets.length - 1); + durationsUs = Arrays.copyOf(durationsUs, durationsUs.length - 1); + timesUs = Arrays.copyOf(timesUs, timesUs.length - 1); + } + + cueTimesUs = null; + cueClusterPositions = null; + return new ChunkIndex(sizes, offsets, durationsUs, timesUs); + } + + /** + * Updates the position of the holder to Cues element's position if the extractor configuration + * permits use of master seek entry. After building Cues sets the holder's position back to where + * it was before. + * + * @param seekPosition The holder whose position will be updated. + * @param currentPosition Current position of the input. + * @return Whether the seek position was updated. + */ + private boolean maybeSeekForCues(PositionHolder seekPosition, long currentPosition) { + if (seekForCues) { + seekPositionAfterBuildingCues = currentPosition; + seekPosition.position = cuesContentPosition; + seekForCues = false; + return true; + } + // After parsing Cues, seek back to original position if available. We will not do this unless + // we seeked to get to the Cues in the first place. + if (sentSeekMap && seekPositionAfterBuildingCues != C.POSITION_UNSET) { + seekPosition.position = seekPositionAfterBuildingCues; + seekPositionAfterBuildingCues = C.POSITION_UNSET; + return true; + } + return false; + } + + private long scaleTimecodeToUs(long unscaledTimecode) throws ParserException { + if (timecodeScale == C.TIME_UNSET) { + throw new ParserException("Can't scale timecode prior to timecodeScale being set."); + } + return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000); + } + + private static boolean isCodecSupported(String codecId) { + return CODEC_ID_VP8.equals(codecId) + || CODEC_ID_VP9.equals(codecId) + || CODEC_ID_AV1.equals(codecId) + || CODEC_ID_MPEG2.equals(codecId) + || CODEC_ID_MPEG4_SP.equals(codecId) + || CODEC_ID_MPEG4_ASP.equals(codecId) + || CODEC_ID_MPEG4_AP.equals(codecId) + || CODEC_ID_H264.equals(codecId) + || CODEC_ID_H265.equals(codecId) + || CODEC_ID_FOURCC.equals(codecId) + || CODEC_ID_THEORA.equals(codecId) + || CODEC_ID_OPUS.equals(codecId) + || CODEC_ID_VORBIS.equals(codecId) + || CODEC_ID_AAC.equals(codecId) + || CODEC_ID_MP2.equals(codecId) + || CODEC_ID_MP3.equals(codecId) + || CODEC_ID_AC3.equals(codecId) + || CODEC_ID_E_AC3.equals(codecId) + || CODEC_ID_TRUEHD.equals(codecId) + || CODEC_ID_DTS.equals(codecId) + || CODEC_ID_DTS_EXPRESS.equals(codecId) + || CODEC_ID_DTS_LOSSLESS.equals(codecId) + || CODEC_ID_FLAC.equals(codecId) + || CODEC_ID_ACM.equals(codecId) + || CODEC_ID_PCM_INT_LIT.equals(codecId) + || CODEC_ID_SUBRIP.equals(codecId) + || CODEC_ID_ASS.equals(codecId) + || CODEC_ID_VOBSUB.equals(codecId) + || CODEC_ID_PGS.equals(codecId) + || CODEC_ID_DVBSUB.equals(codecId); + } + + /** + * Returns an array that can store (at least) {@code length} elements, which will be either a new + * array or {@code array} if it's not null and large enough. + */ + private static int[] ensureArrayCapacity(int[] array, int length) { + if (array == null) { + return new int[length]; + } else if (array.length >= length) { + return array; + } else { + // Double the size to avoid allocating constantly if the required length increases gradually. + return new int[Math.max(array.length * 2, length)]; + } + } + + /** Passes events through to the outer {@link MatroskaExtractor}. */ + private final class InnerEbmlProcessor implements EbmlProcessor { + + @Override + @ElementType + public int getElementType(int id) { + return MatroskaExtractor.this.getElementType(id); + } + + @Override + public boolean isLevel1Element(int id) { + return MatroskaExtractor.this.isLevel1Element(id); + } + + @Override + public void startMasterElement(int id, long contentPosition, long contentSize) + throws ParserException { + MatroskaExtractor.this.startMasterElement(id, contentPosition, contentSize); + } + + @Override + public void endMasterElement(int id) throws ParserException { + MatroskaExtractor.this.endMasterElement(id); + } + + @Override + public void integerElement(int id, long value) throws ParserException { + MatroskaExtractor.this.integerElement(id, value); + } + + @Override + public void floatElement(int id, double value) throws ParserException { + MatroskaExtractor.this.floatElement(id, value); + } + + @Override + public void stringElement(int id, String value) throws ParserException { + MatroskaExtractor.this.stringElement(id, value); + } + + @Override + public void binaryElement(int id, int contentsSize, ExtractorInput input) + throws IOException, InterruptedException { + MatroskaExtractor.this.binaryElement(id, contentsSize, input); + } + } + + /** + * Rechunks TrueHD sample data into groups of {@link Ac3Util#TRUEHD_RECHUNK_SAMPLE_COUNT} samples. + */ + private static final class TrueHdSampleRechunker { + + private final byte[] syncframePrefix; + + private boolean foundSyncframe; + private int chunkSampleCount; + private long chunkTimeUs; + private @C.BufferFlags int chunkFlags; + private int chunkSize; + private int chunkOffset; + + public TrueHdSampleRechunker() { + syncframePrefix = new byte[Ac3Util.TRUEHD_SYNCFRAME_PREFIX_LENGTH]; + } + + public void reset() { + foundSyncframe = false; + chunkSampleCount = 0; + } + + public void startSample(ExtractorInput input) throws IOException, InterruptedException { + if (foundSyncframe) { + return; + } + input.peekFully(syncframePrefix, 0, Ac3Util.TRUEHD_SYNCFRAME_PREFIX_LENGTH); + input.resetPeekPosition(); + if (Ac3Util.parseTrueHdSyncframeAudioSampleCount(syncframePrefix) == 0) { + return; + } + foundSyncframe = true; + } + + public void sampleMetadata( + Track track, long timeUs, @C.BufferFlags int flags, int size, int offset) { + if (!foundSyncframe) { + return; + } + if (chunkSampleCount++ == 0) { + // This is the first sample in the chunk. + chunkTimeUs = timeUs; + chunkFlags = flags; + chunkSize = 0; + } + chunkSize += size; + chunkOffset = offset; // The offset is to the end of the sample. + if (chunkSampleCount >= Ac3Util.TRUEHD_RECHUNK_SAMPLE_COUNT) { + outputPendingSampleMetadata(track); + } + } + + public void outputPendingSampleMetadata(Track track) { + if (chunkSampleCount > 0) { + track.output.sampleMetadata( + chunkTimeUs, chunkFlags, chunkSize, chunkOffset, track.cryptoData); + chunkSampleCount = 0; + } + } + } + + private static final class Track { + + private static final int DISPLAY_UNIT_PIXELS = 0; + private static final int MAX_CHROMATICITY = 50000; // Defined in CTA-861.3. + /** + * Default max content light level (CLL) that should be encoded into hdrStaticInfo. + */ + private static final int DEFAULT_MAX_CLL = 1000; // nits. + + /** + * Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. + */ + private static final int DEFAULT_MAX_FALL = 200; // nits. + + // Common elements. + public String name; + public String codecId; + public int number; + public int type; + public int defaultSampleDurationNs; + public int maxBlockAdditionId; + public boolean hasContentEncryption; + public byte[] sampleStrippedBytes; + public TrackOutput.CryptoData cryptoData; + public byte[] codecPrivate; + public DrmInitData drmInitData; + + // Video elements. + public int width = Format.NO_VALUE; + public int height = Format.NO_VALUE; + public int displayWidth = Format.NO_VALUE; + public int displayHeight = Format.NO_VALUE; + public int displayUnit = DISPLAY_UNIT_PIXELS; + @C.Projection public int projectionType = Format.NO_VALUE; + public float projectionPoseYaw = 0f; + public float projectionPosePitch = 0f; + public float projectionPoseRoll = 0f; + public byte[] projectionData = null; + @C.StereoMode + public int stereoMode = Format.NO_VALUE; + public boolean hasColorInfo = false; + @C.ColorSpace + public int colorSpace = Format.NO_VALUE; + @C.ColorTransfer + public int colorTransfer = Format.NO_VALUE; + @C.ColorRange + public int colorRange = Format.NO_VALUE; + public int maxContentLuminance = DEFAULT_MAX_CLL; + public int maxFrameAverageLuminance = DEFAULT_MAX_FALL; + public float primaryRChromaticityX = Format.NO_VALUE; + public float primaryRChromaticityY = Format.NO_VALUE; + public float primaryGChromaticityX = Format.NO_VALUE; + public float primaryGChromaticityY = Format.NO_VALUE; + public float primaryBChromaticityX = Format.NO_VALUE; + public float primaryBChromaticityY = Format.NO_VALUE; + public float whitePointChromaticityX = Format.NO_VALUE; + public float whitePointChromaticityY = Format.NO_VALUE; + public float maxMasteringLuminance = Format.NO_VALUE; + public float minMasteringLuminance = Format.NO_VALUE; + + // Audio elements. Initially set to their default values. + public int channelCount = 1; + public int audioBitDepth = Format.NO_VALUE; + public int sampleRate = 8000; + public long codecDelayNs = 0; + public long seekPreRollNs = 0; + @Nullable public TrueHdSampleRechunker trueHdSampleRechunker; + + // Text elements. + public boolean flagForced; + public boolean flagDefault = true; + private String language = "eng"; + + // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. + public TrackOutput output; + public int nalUnitLengthFieldLength; + + /** Initializes the track with an output. */ + public void initializeOutput(ExtractorOutput output, int trackId) throws ParserException { + String mimeType; + int maxInputSize = Format.NO_VALUE; + @C.PcmEncoding int pcmEncoding = Format.NO_VALUE; + List<byte[]> initializationData = null; + switch (codecId) { + case CODEC_ID_VP8: + mimeType = MimeTypes.VIDEO_VP8; + break; + case CODEC_ID_VP9: + mimeType = MimeTypes.VIDEO_VP9; + break; + case CODEC_ID_AV1: + mimeType = MimeTypes.VIDEO_AV1; + break; + case CODEC_ID_MPEG2: + mimeType = MimeTypes.VIDEO_MPEG2; + break; + case CODEC_ID_MPEG4_SP: + case CODEC_ID_MPEG4_ASP: + case CODEC_ID_MPEG4_AP: + mimeType = MimeTypes.VIDEO_MP4V; + initializationData = + codecPrivate == null ? null : Collections.singletonList(codecPrivate); + break; + case CODEC_ID_H264: + mimeType = MimeTypes.VIDEO_H264; + AvcConfig avcConfig = AvcConfig.parse(new ParsableByteArray(codecPrivate)); + initializationData = avcConfig.initializationData; + nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; + break; + case CODEC_ID_H265: + mimeType = MimeTypes.VIDEO_H265; + HevcConfig hevcConfig = HevcConfig.parse(new ParsableByteArray(codecPrivate)); + initializationData = hevcConfig.initializationData; + nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength; + break; + case CODEC_ID_FOURCC: + Pair<String, List<byte[]>> pair = parseFourCcPrivate(new ParsableByteArray(codecPrivate)); + mimeType = pair.first; + initializationData = pair.second; + break; + case CODEC_ID_THEORA: + // TODO: This can be set to the real mimeType if/when we work out what initializationData + // should be set to for this case. + mimeType = MimeTypes.VIDEO_UNKNOWN; + break; + case CODEC_ID_VORBIS: + mimeType = MimeTypes.AUDIO_VORBIS; + maxInputSize = VORBIS_MAX_INPUT_SIZE; + initializationData = parseVorbisCodecPrivate(codecPrivate); + break; + case CODEC_ID_OPUS: + mimeType = MimeTypes.AUDIO_OPUS; + maxInputSize = OPUS_MAX_INPUT_SIZE; + initializationData = new ArrayList<>(3); + initializationData.add(codecPrivate); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(codecDelayNs).array()); + initializationData.add( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(seekPreRollNs).array()); + break; + case CODEC_ID_AAC: + mimeType = MimeTypes.AUDIO_AAC; + initializationData = Collections.singletonList(codecPrivate); + break; + case CODEC_ID_MP2: + mimeType = MimeTypes.AUDIO_MPEG_L2; + maxInputSize = MpegAudioHeader.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_MP3: + mimeType = MimeTypes.AUDIO_MPEG; + maxInputSize = MpegAudioHeader.MAX_FRAME_SIZE_BYTES; + break; + case CODEC_ID_AC3: + mimeType = MimeTypes.AUDIO_AC3; + break; + case CODEC_ID_E_AC3: + mimeType = MimeTypes.AUDIO_E_AC3; + break; + case CODEC_ID_TRUEHD: + mimeType = MimeTypes.AUDIO_TRUEHD; + trueHdSampleRechunker = new TrueHdSampleRechunker(); + break; + case CODEC_ID_DTS: + case CODEC_ID_DTS_EXPRESS: + mimeType = MimeTypes.AUDIO_DTS; + break; + case CODEC_ID_DTS_LOSSLESS: + mimeType = MimeTypes.AUDIO_DTS_HD; + break; + case CODEC_ID_FLAC: + mimeType = MimeTypes.AUDIO_FLAC; + initializationData = Collections.singletonList(codecPrivate); + break; + case CODEC_ID_ACM: + mimeType = MimeTypes.AUDIO_RAW; + if (parseMsAcmCodecPrivate(new ParsableByteArray(codecPrivate))) { + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Unsupported PCM bit depth: " + audioBitDepth + ". Setting mimeType to " + + mimeType); + } + } else { + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Non-PCM MS/ACM is unsupported. Setting mimeType to " + mimeType); + } + break; + case CODEC_ID_PCM_INT_LIT: + mimeType = MimeTypes.AUDIO_RAW; + pcmEncoding = Util.getPcmEncoding(audioBitDepth); + if (pcmEncoding == C.ENCODING_INVALID) { + pcmEncoding = Format.NO_VALUE; + mimeType = MimeTypes.AUDIO_UNKNOWN; + Log.w(TAG, "Unsupported PCM bit depth: " + audioBitDepth + ". Setting mimeType to " + + mimeType); + } + break; + case CODEC_ID_SUBRIP: + mimeType = MimeTypes.APPLICATION_SUBRIP; + break; + case CODEC_ID_ASS: + mimeType = MimeTypes.TEXT_SSA; + break; + case CODEC_ID_VOBSUB: + mimeType = MimeTypes.APPLICATION_VOBSUB; + initializationData = Collections.singletonList(codecPrivate); + break; + case CODEC_ID_PGS: + mimeType = MimeTypes.APPLICATION_PGS; + break; + case CODEC_ID_DVBSUB: + mimeType = MimeTypes.APPLICATION_DVBSUBS; + // Init data: composition_page (2), ancillary_page (2) + initializationData = Collections.singletonList(new byte[] {codecPrivate[0], + codecPrivate[1], codecPrivate[2], codecPrivate[3]}); + break; + default: + throw new ParserException("Unrecognized codec identifier."); + } + + int type; + Format format; + @C.SelectionFlags int selectionFlags = 0; + selectionFlags |= flagDefault ? C.SELECTION_FLAG_DEFAULT : 0; + selectionFlags |= flagForced ? C.SELECTION_FLAG_FORCED : 0; + // TODO: Consider reading the name elements of the tracks and, if present, incorporating them + // into the trackId passed when creating the formats. + if (MimeTypes.isAudio(mimeType)) { + type = C.TRACK_TYPE_AUDIO; + format = Format.createAudioSampleFormat(Integer.toString(trackId), mimeType, null, + Format.NO_VALUE, maxInputSize, channelCount, sampleRate, pcmEncoding, + initializationData, drmInitData, selectionFlags, language); + } else if (MimeTypes.isVideo(mimeType)) { + type = C.TRACK_TYPE_VIDEO; + if (displayUnit == Track.DISPLAY_UNIT_PIXELS) { + displayWidth = displayWidth == Format.NO_VALUE ? width : displayWidth; + displayHeight = displayHeight == Format.NO_VALUE ? height : displayHeight; + } + float pixelWidthHeightRatio = Format.NO_VALUE; + if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { + pixelWidthHeightRatio = ((float) (height * displayWidth)) / (width * displayHeight); + } + ColorInfo colorInfo = null; + if (hasColorInfo) { + byte[] hdrStaticInfo = getHdrStaticInfo(); + colorInfo = new ColorInfo(colorSpace, colorRange, colorTransfer, hdrStaticInfo); + } + int rotationDegrees = Format.NO_VALUE; + // Some HTC devices signal rotation in track names. + if ("htc_video_rotA-000".equals(name)) { + rotationDegrees = 0; + } else if ("htc_video_rotA-090".equals(name)) { + rotationDegrees = 90; + } else if ("htc_video_rotA-180".equals(name)) { + rotationDegrees = 180; + } else if ("htc_video_rotA-270".equals(name)) { + rotationDegrees = 270; + } + if (projectionType == C.PROJECTION_RECTANGULAR + && Float.compare(projectionPoseYaw, 0f) == 0 + && Float.compare(projectionPosePitch, 0f) == 0) { + // The range of projectionPoseRoll is [-180, 180]. + if (Float.compare(projectionPoseRoll, 0f) == 0) { + rotationDegrees = 0; + } else if (Float.compare(projectionPosePitch, 90f) == 0) { + rotationDegrees = 90; + } else if (Float.compare(projectionPosePitch, -180f) == 0 + || Float.compare(projectionPosePitch, 180f) == 0) { + rotationDegrees = 180; + } else if (Float.compare(projectionPosePitch, -90f) == 0) { + rotationDegrees = 270; + } + } + format = + Format.createVideoSampleFormat( + Integer.toString(trackId), + mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + maxInputSize, + width, + height, + /* frameRate= */ Format.NO_VALUE, + initializationData, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + colorInfo, + drmInitData); + } else if (MimeTypes.APPLICATION_SUBRIP.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + format = Format.createTextSampleFormat(Integer.toString(trackId), mimeType, selectionFlags, + language, drmInitData); + } else if (MimeTypes.TEXT_SSA.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + initializationData = new ArrayList<>(2); + initializationData.add(SSA_DIALOGUE_FORMAT); + initializationData.add(codecPrivate); + format = Format.createTextSampleFormat(Integer.toString(trackId), mimeType, null, + Format.NO_VALUE, selectionFlags, language, Format.NO_VALUE, drmInitData, + Format.OFFSET_SAMPLE_RELATIVE, initializationData); + } else if (MimeTypes.APPLICATION_VOBSUB.equals(mimeType) + || MimeTypes.APPLICATION_PGS.equals(mimeType) + || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType)) { + type = C.TRACK_TYPE_TEXT; + format = + Format.createImageSampleFormat( + Integer.toString(trackId), + mimeType, + null, + Format.NO_VALUE, + selectionFlags, + initializationData, + language, + drmInitData); + } else { + throw new ParserException("Unexpected MIME type."); + } + + this.output = output.track(number, type); + this.output.format(format); + } + + /** Forces any pending sample metadata to be flushed to the output. */ + public void outputPendingSampleMetadata() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker.outputPendingSampleMetadata(this); + } + } + + /** Resets any state stored in the track in response to a seek. */ + public void reset() { + if (trueHdSampleRechunker != null) { + trueHdSampleRechunker.reset(); + } + } + + /** Returns the HDR Static Info as defined in CTA-861.3. */ + @Nullable + private byte[] getHdrStaticInfo() { + // Are all fields present. + if (primaryRChromaticityX == Format.NO_VALUE || primaryRChromaticityY == Format.NO_VALUE + || primaryGChromaticityX == Format.NO_VALUE || primaryGChromaticityY == Format.NO_VALUE + || primaryBChromaticityX == Format.NO_VALUE || primaryBChromaticityY == Format.NO_VALUE + || whitePointChromaticityX == Format.NO_VALUE + || whitePointChromaticityY == Format.NO_VALUE || maxMasteringLuminance == Format.NO_VALUE + || minMasteringLuminance == Format.NO_VALUE) { + return null; + } + + byte[] hdrStaticInfoData = new byte[25]; + ByteBuffer hdrStaticInfo = ByteBuffer.wrap(hdrStaticInfoData).order(ByteOrder.LITTLE_ENDIAN); + hdrStaticInfo.put((byte) 0); // Type. + hdrStaticInfo.putShort((short) ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f)); + hdrStaticInfo.putShort((short) (maxMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) (minMasteringLuminance + 0.5f)); + hdrStaticInfo.putShort((short) maxContentLuminance); + hdrStaticInfo.putShort((short) maxFrameAverageLuminance); + return hdrStaticInfoData; + } + + /** + * Builds initialization data for a {@link Format} from FourCC codec private data. + * + * @return The codec mime type and initialization data. If the compression type is not supported + * then the mime type is set to {@link MimeTypes#VIDEO_UNKNOWN} and the initialization data + * is {@code null}. + * @throws ParserException If the initialization data could not be built. + */ + private static Pair<String, List<byte[]>> parseFourCcPrivate(ParsableByteArray buffer) + throws ParserException { + try { + buffer.skipBytes(16); // size(4), width(4), height(4), planes(2), bitcount(2). + long compression = buffer.readLittleEndianUnsignedInt(); + if (compression == FOURCC_COMPRESSION_DIVX) { + return new Pair<>(MimeTypes.VIDEO_DIVX, null); + } else if (compression == FOURCC_COMPRESSION_H263) { + return new Pair<>(MimeTypes.VIDEO_H263, null); + } else if (compression == FOURCC_COMPRESSION_VC1) { + // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 + // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). + int startOffset = buffer.getPosition() + 20; + byte[] bufferData = buffer.data; + for (int offset = startOffset; offset < bufferData.length - 4; offset++) { + if (bufferData[offset] == 0x00 + && bufferData[offset + 1] == 0x00 + && bufferData[offset + 2] == 0x01 + && bufferData[offset + 3] == 0x0F) { + // We've found the initialization data. + byte[] initializationData = Arrays.copyOfRange(bufferData, offset, bufferData.length); + return new Pair<>(MimeTypes.VIDEO_VC1, Collections.singletonList(initializationData)); + } + } + throw new ParserException("Failed to find FourCC VC1 initialization data"); + } + } catch (ArrayIndexOutOfBoundsException e) { + throw new ParserException("Error parsing FourCC private data"); + } + + Log.w(TAG, "Unknown FourCC. Setting mimeType to " + MimeTypes.VIDEO_UNKNOWN); + return new Pair<>(MimeTypes.VIDEO_UNKNOWN, null); + } + + /** + * Builds initialization data for a {@link Format} from Vorbis codec private data. + * + * @return The initialization data for the {@link Format}. + * @throws ParserException If the initialization data could not be built. + */ + private static List<byte[]> parseVorbisCodecPrivate(byte[] codecPrivate) + throws ParserException { + try { + if (codecPrivate[0] != 0x02) { + throw new ParserException("Error parsing vorbis codec private"); + } + int offset = 1; + int vorbisInfoLength = 0; + while (codecPrivate[offset] == (byte) 0xFF) { + vorbisInfoLength += 0xFF; + offset++; + } + vorbisInfoLength += codecPrivate[offset++]; + + int vorbisSkipLength = 0; + while (codecPrivate[offset] == (byte) 0xFF) { + vorbisSkipLength += 0xFF; + offset++; + } + vorbisSkipLength += codecPrivate[offset++]; + + if (codecPrivate[offset] != 0x01) { + throw new ParserException("Error parsing vorbis codec private"); + } + byte[] vorbisInfo = new byte[vorbisInfoLength]; + System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength); + offset += vorbisInfoLength; + if (codecPrivate[offset] != 0x03) { + throw new ParserException("Error parsing vorbis codec private"); + } + offset += vorbisSkipLength; + if (codecPrivate[offset] != 0x05) { + throw new ParserException("Error parsing vorbis codec private"); + } + byte[] vorbisBooks = new byte[codecPrivate.length - offset]; + System.arraycopy(codecPrivate, offset, vorbisBooks, 0, codecPrivate.length - offset); + List<byte[]> initializationData = new ArrayList<>(2); + initializationData.add(vorbisInfo); + initializationData.add(vorbisBooks); + return initializationData; + } catch (ArrayIndexOutOfBoundsException e) { + throw new ParserException("Error parsing vorbis codec private"); + } + } + + /** + * Parses an MS/ACM codec private, returning whether it indicates PCM audio. + * + * @return Whether the codec private indicates PCM audio. + * @throws ParserException If a parsing error occurs. + */ + private static boolean parseMsAcmCodecPrivate(ParsableByteArray buffer) throws ParserException { + try { + int formatTag = buffer.readLittleEndianUnsignedShort(); + if (formatTag == WAVE_FORMAT_PCM) { + return true; + } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { + buffer.setPosition(WAVE_FORMAT_SIZE + 6); // unionSamples(2), channelMask(4) + return buffer.readLong() == WAVE_SUBFORMAT_PCM.getMostSignificantBits() + && buffer.readLong() == WAVE_SUBFORMAT_PCM.getLeastSignificantBits(); + } else { + return false; + } + } catch (ArrayIndexOutOfBoundsException e) { + throw new ParserException("Error parsing MS/ACM codec private"); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/Sniffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/Sniffer.java new file mode 100644 index 0000000000..f84cd084a3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/Sniffer.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * Utility class that peeks from the input stream in order to determine whether it appears to be + * compatible input for this extractor. + */ +/* package */ final class Sniffer { + + /** + * The number of bytes to search for a valid header in {@link #sniff(ExtractorInput)}. + */ + private static final int SEARCH_LENGTH = 1024; + private static final int ID_EBML = 0x1A45DFA3; + + private final ParsableByteArray scratch; + private int peekLength; + + public Sniffer() { + scratch = new ParsableByteArray(8); + } + + /** + * @see com.google.android.exoplayer2.extractor.Extractor#sniff(ExtractorInput) + */ + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + long inputLength = input.getLength(); + int bytesToSearch = (int) (inputLength == C.LENGTH_UNSET || inputLength > SEARCH_LENGTH + ? SEARCH_LENGTH : inputLength); + // Find four bytes equal to ID_EBML near the start of the input. + input.peekFully(scratch.data, 0, 4); + long tag = scratch.readUnsignedInt(); + peekLength = 4; + while (tag != ID_EBML) { + if (++peekLength == bytesToSearch) { + return false; + } + input.peekFully(scratch.data, 0, 1); + tag = (tag << 8) & 0xFFFFFF00; + tag |= scratch.data[0] & 0xFF; + } + + // Read the size of the EBML header and make sure it is within the stream. + long headerSize = readUint(input); + long headerStart = peekLength; + if (headerSize == Long.MIN_VALUE + || (inputLength != C.LENGTH_UNSET && headerStart + headerSize >= inputLength)) { + return false; + } + + // Read the payload elements in the EBML header. + while (peekLength < headerStart + headerSize) { + long id = readUint(input); + if (id == Long.MIN_VALUE) { + return false; + } + long size = readUint(input); + if (size < 0 || size > Integer.MAX_VALUE) { + return false; + } + if (size != 0) { + int sizeInt = (int) size; + input.advancePeekPosition(sizeInt); + peekLength += sizeInt; + } + } + return peekLength == headerStart + headerSize; + } + + /** + * Peeks a variable-length unsigned EBML integer from the input. + */ + private long readUint(ExtractorInput input) throws IOException, InterruptedException { + input.peekFully(scratch.data, 0, 1); + int value = scratch.data[0] & 0xFF; + if (value == 0) { + return Long.MIN_VALUE; + } + int mask = 0x80; + int length = 0; + while ((value & mask) == 0) { + mask >>= 1; + length++; + } + value &= ~mask; + input.peekFully(scratch.data, 1, length); + for (int i = 0; i < length; i++) { + value <<= 8; + value += scratch.data[i + 1] & 0xFF; + } + peekLength += length + 1; + return value; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/VarintReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/VarintReader.java new file mode 100644 index 0000000000..8a8d572ea5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mkv/VarintReader.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mkv; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import java.io.EOFException; +import java.io.IOException; + +/** + * Reads EBML variable-length integers (varints) from an {@link ExtractorInput}. + */ +/* package */ final class VarintReader { + + private static final int STATE_BEGIN_READING = 0; + private static final int STATE_READ_CONTENTS = 1; + + /** + * The first byte of a variable-length integer (varint) will have one of these bit masks + * indicating the total length in bytes. + * + * <p>{@code 0x80} is a one-byte integer, {@code 0x40} is two bytes, and so on up to eight bytes. + */ + private static final long[] VARINT_LENGTH_MASKS = new long[] { + 0x80L, 0x40L, 0x20L, 0x10L, 0x08L, 0x04L, 0x02L, 0x01L + }; + + private final byte[] scratch; + + private int state; + private int length; + + public VarintReader() { + scratch = new byte[8]; + } + + /** + * Resets the reader to start reading a new variable-length integer. + */ + public void reset() { + state = STATE_BEGIN_READING; + length = 0; + } + + /** + * Reads an EBML variable-length integer (varint) from an {@link ExtractorInput} such that + * reading can be resumed later if an error occurs having read only some of it. + * <p> + * If an value is successfully read, then the reader will automatically reset itself ready to + * read another value. + * <p> + * If an {@link IOException} or {@link InterruptedException} is throw, the read can be resumed + * later by calling this method again, passing an {@link ExtractorInput} providing data starting + * where the previous one left off. + * + * @param input The {@link ExtractorInput} from which the integer should be read. + * @param allowEndOfInput True if encountering the end of the input having read no data is + * allowed, and should result in {@link C#RESULT_END_OF_INPUT} being returned. False if it + * should be considered an error, causing an {@link EOFException} to be thrown. + * @param removeLengthMask Removes the variable-length integer length mask from the value. + * @param maximumAllowedLength Maximum allowed length of the variable integer to be read. + * @return The read value, or {@link C#RESULT_END_OF_INPUT} if {@code allowEndOfStream} is true + * and the end of the input was encountered, or {@link C#RESULT_MAX_LENGTH_EXCEEDED} if the + * length of the varint exceeded maximumAllowedLength. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + public long readUnsignedVarint(ExtractorInput input, boolean allowEndOfInput, + boolean removeLengthMask, int maximumAllowedLength) throws IOException, InterruptedException { + if (state == STATE_BEGIN_READING) { + // Read the first byte to establish the length. + if (!input.readFully(scratch, 0, 1, allowEndOfInput)) { + return C.RESULT_END_OF_INPUT; + } + int firstByte = scratch[0] & 0xFF; + length = parseUnsignedVarintLength(firstByte); + if (length == C.LENGTH_UNSET) { + throw new IllegalStateException("No valid varint length mask found"); + } + state = STATE_READ_CONTENTS; + } + + if (length > maximumAllowedLength) { + state = STATE_BEGIN_READING; + return C.RESULT_MAX_LENGTH_EXCEEDED; + } + + if (length != 1) { + // Read the remaining bytes. + input.readFully(scratch, 1, length - 1); + } + + state = STATE_BEGIN_READING; + return assembleVarint(scratch, length, removeLengthMask); + } + + /** + * Returns the number of bytes occupied by the most recently parsed varint. + */ + public int getLastLength() { + return length; + } + + /** + * Parses and the length of the varint given the first byte. + * + * @param firstByte First byte of the varint. + * @return Length of the varint beginning with the given byte if it was valid, + * {@link C#LENGTH_UNSET} otherwise. + */ + public static int parseUnsignedVarintLength(int firstByte) { + int varIntLength = C.LENGTH_UNSET; + for (int i = 0; i < VARINT_LENGTH_MASKS.length; i++) { + if ((VARINT_LENGTH_MASKS[i] & firstByte) != 0) { + varIntLength = i + 1; + break; + } + } + return varIntLength; + } + + /** + * Assemble a varint from the given byte array. + * + * @param varintBytes Bytes that make up the varint. + * @param varintLength Length of the varint to assemble. + * @param removeLengthMask Removes the variable-length integer length mask from the value. + * @return Parsed and assembled varint. + */ + public static long assembleVarint(byte[] varintBytes, int varintLength, + boolean removeLengthMask) { + long varint = varintBytes[0] & 0xFFL; + if (removeLengthMask) { + varint &= ~VARINT_LENGTH_MASKS[varintLength - 1]; + } + for (int i = 1; i < varintLength; i++) { + varint = (varint << 8) | (varintBytes[i] & 0xFFL); + } + return varint; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java new file mode 100644 index 0000000000..1a442110e3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/ConstantBitrateSeeker.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ConstantBitrateSeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; + +/** + * MP3 seeker that doesn't rely on metadata and seeks assuming the source has a constant bitrate. + */ +/* package */ final class ConstantBitrateSeeker extends ConstantBitrateSeekMap implements Seeker { + + /** + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param firstFramePosition The position of the first frame in the stream. + * @param mpegAudioHeader The MPEG audio header associated with the first frame. + */ + public ConstantBitrateSeeker( + long inputLength, long firstFramePosition, MpegAudioHeader mpegAudioHeader) { + super(inputLength, firstFramePosition, mpegAudioHeader.bitrate, mpegAudioHeader.frameSize); + } + + @Override + public long getTimeUs(long position) { + return getTimeUsAtPosition(position); + } + + @Override + public long getDataEndPosition() { + return C.POSITION_UNSET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java new file mode 100644 index 0000000000..662ded4ec3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/MlltSeeker.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.MlltFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** MP3 seeker that uses metadata from an {@link MlltFrame}. */ +/* package */ final class MlltSeeker implements Seeker { + + /** + * Returns an {@link MlltSeeker} for seeking in the stream. + * + * @param firstFramePosition The position of the start of the first frame in the stream. + * @param mlltFrame The MLLT frame with seeking metadata. + * @return An {@link MlltSeeker} for seeking in the stream. + */ + public static MlltSeeker create(long firstFramePosition, MlltFrame mlltFrame) { + int referenceCount = mlltFrame.bytesDeviations.length; + long[] referencePositions = new long[1 + referenceCount]; + long[] referenceTimesMs = new long[1 + referenceCount]; + referencePositions[0] = firstFramePosition; + referenceTimesMs[0] = 0; + long position = firstFramePosition; + long timeMs = 0; + for (int i = 1; i <= referenceCount; i++) { + position += mlltFrame.bytesBetweenReference + mlltFrame.bytesDeviations[i - 1]; + timeMs += mlltFrame.millisecondsBetweenReference + mlltFrame.millisecondsDeviations[i - 1]; + referencePositions[i] = position; + referenceTimesMs[i] = timeMs; + } + return new MlltSeeker(referencePositions, referenceTimesMs); + } + + private final long[] referencePositions; + private final long[] referenceTimesMs; + private final long durationUs; + + private MlltSeeker(long[] referencePositions, long[] referenceTimesMs) { + this.referencePositions = referencePositions; + this.referenceTimesMs = referenceTimesMs; + // Use the last reference point as the duration, as extrapolating variable bitrate at the end of + // the stream may give a large error. + durationUs = C.msToUs(referenceTimesMs[referenceTimesMs.length - 1]); + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + timeUs = Util.constrainValue(timeUs, 0, durationUs); + Pair<Long, Long> timeMsAndPosition = + linearlyInterpolate(C.usToMs(timeUs), referenceTimesMs, referencePositions); + timeUs = C.msToUs(timeMsAndPosition.first); + long position = timeMsAndPosition.second; + return new SeekPoints(new SeekPoint(timeUs, position)); + } + + @Override + public long getTimeUs(long position) { + Pair<Long, Long> positionAndTimeMs = + linearlyInterpolate(position, referencePositions, referenceTimesMs); + return C.msToUs(positionAndTimeMs.second); + } + + @Override + public long getDurationUs() { + return durationUs; + } + + /** + * Given a set of reference points as coordinates in {@code xReferences} and {@code yReferences} + * and an x-axis value, linearly interpolates between corresponding reference points to give a + * y-axis value. + * + * @param x The x-axis value for which a y-axis value is needed. + * @param xReferences x coordinates of reference points. + * @param yReferences y coordinates of reference points. + * @return The linearly interpolated y-axis value. + */ + private static Pair<Long, Long> linearlyInterpolate( + long x, long[] xReferences, long[] yReferences) { + int previousReferenceIndex = + Util.binarySearchFloor(xReferences, x, /* inclusive= */ true, /* stayInBounds= */ true); + long xPreviousReference = xReferences[previousReferenceIndex]; + long yPreviousReference = yReferences[previousReferenceIndex]; + int nextReferenceIndex = previousReferenceIndex + 1; + if (nextReferenceIndex == xReferences.length) { + return Pair.create(xPreviousReference, yPreviousReference); + } else { + long xNextReference = xReferences[nextReferenceIndex]; + long yNextReference = yReferences[nextReferenceIndex]; + double proportion = + xNextReference == xPreviousReference + ? 0.0 + : ((double) x - xPreviousReference) / (xNextReference - xPreviousReference); + long y = (long) (proportion * (yNextReference - yPreviousReference)) + yPreviousReference; + return Pair.create(x, y); + } + } + + @Override + public long getDataEndPosition() { + return C.POSITION_UNSET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java new file mode 100644 index 0000000000..2829a1e519 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Mp3Extractor.java @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Id3Peeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Seeker.UnseekableSeeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.FramePredicate; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.MlltFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Extracts data from the MP3 container format. + */ +public final class Mp3Extractor implements Extractor { + + /** Factory for {@link Mp3Extractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new Mp3Extractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag values are {@link + * #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING} and {@link #FLAG_DISABLE_ID3_METADATA}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_ENABLE_CONSTANT_BITRATE_SEEKING, FLAG_DISABLE_ID3_METADATA}) + public @interface Flags {} + /** + * Flag to force enable seeking using a constant bitrate assumption in cases where seeking would + * otherwise not be possible. + */ + public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING = 1; + /** + * Flag to disable parsing of ID3 metadata. Can be set to save memory if ID3 metadata is not + * required. + */ + public static final int FLAG_DISABLE_ID3_METADATA = 2; + + /** Predicate that matches ID3 frames containing only required gapless/seeking metadata. */ + private static final FramePredicate REQUIRED_ID3_FRAME_PREDICATE = + (majorVersion, id0, id1, id2, id3) -> + ((id0 == 'C' && id1 == 'O' && id2 == 'M' && (id3 == 'M' || majorVersion == 2)) + || (id0 == 'M' && id1 == 'L' && id2 == 'L' && (id3 == 'T' || majorVersion == 2))); + + /** + * The maximum number of bytes to search when synchronizing, before giving up. + */ + private static final int MAX_SYNC_BYTES = 128 * 1024; + /** + * The maximum number of bytes to peek when sniffing, excluding the ID3 header, before giving up. + */ + private static final int MAX_SNIFF_BYTES = 16 * 1024; + /** + * Maximum length of data read into {@link #scratch}. + */ + private static final int SCRATCH_LENGTH = 10; + + /** + * Mask that includes the audio header values that must match between frames. + */ + private static final int MPEG_AUDIO_HEADER_MASK = 0xFFFE0C00; + + private static final int SEEK_HEADER_XING = 0x58696e67; + private static final int SEEK_HEADER_INFO = 0x496e666f; + private static final int SEEK_HEADER_VBRI = 0x56425249; + private static final int SEEK_HEADER_UNSET = 0; + + @Flags private final int flags; + private final long forcedFirstSampleTimestampUs; + private final ParsableByteArray scratch; + private final MpegAudioHeader synchronizedHeader; + private final GaplessInfoHolder gaplessInfoHolder; + private final Id3Peeker id3Peeker; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + private TrackOutput trackOutput; + + private int synchronizedHeaderData; + + private Metadata metadata; + @Nullable private Seeker seeker; + private boolean disableSeeking; + private long basisTimeUs; + private long samplesRead; + private long firstSamplePosition; + private int sampleBytesRemaining; + + public Mp3Extractor() { + this(0); + } + + /** + * @param flags Flags that control the extractor's behavior. + */ + public Mp3Extractor(@Flags int flags) { + this(flags, C.TIME_UNSET); + } + + /** + * @param flags Flags that control the extractor's behavior. + * @param forcedFirstSampleTimestampUs A timestamp to force for the first sample, or + * {@link C#TIME_UNSET} if forcing is not required. + */ + public Mp3Extractor(@Flags int flags, long forcedFirstSampleTimestampUs) { + this.flags = flags; + this.forcedFirstSampleTimestampUs = forcedFirstSampleTimestampUs; + scratch = new ParsableByteArray(SCRATCH_LENGTH); + synchronizedHeader = new MpegAudioHeader(); + gaplessInfoHolder = new GaplessInfoHolder(); + basisTimeUs = C.TIME_UNSET; + id3Peeker = new Id3Peeker(); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return synchronize(input, true); + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + trackOutput = extractorOutput.track(0, C.TRACK_TYPE_AUDIO); + extractorOutput.endTracks(); + } + + @Override + public void seek(long position, long timeUs) { + synchronizedHeaderData = 0; + basisTimeUs = C.TIME_UNSET; + samplesRead = 0; + sampleBytesRemaining = 0; + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + if (synchronizedHeaderData == 0) { + try { + synchronize(input, false); + } catch (EOFException e) { + return RESULT_END_OF_INPUT; + } + } + if (seeker == null) { + // Read past any seek frame and set the seeker based on metadata or a seek frame. Metadata + // takes priority as it can provide greater precision. + Seeker seekFrameSeeker = maybeReadSeekFrame(input); + Seeker metadataSeeker = maybeHandleSeekMetadata(metadata, input.getPosition()); + + if (disableSeeking) { + seeker = new UnseekableSeeker(); + } else { + if (metadataSeeker != null) { + seeker = metadataSeeker; + } else if (seekFrameSeeker != null) { + seeker = seekFrameSeeker; + } + if (seeker == null + || (!seeker.isSeekable() && (flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) != 0)) { + seeker = getConstantBitrateSeeker(input); + } + } + extractorOutput.seekMap(seeker); + trackOutput.format( + Format.createAudioSampleFormat( + /* id= */ null, + synchronizedHeader.mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + MpegAudioHeader.MAX_FRAME_SIZE_BYTES, + synchronizedHeader.channels, + synchronizedHeader.sampleRate, + /* pcmEncoding= */ Format.NO_VALUE, + gaplessInfoHolder.encoderDelay, + gaplessInfoHolder.encoderPadding, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null, + (flags & FLAG_DISABLE_ID3_METADATA) != 0 ? null : metadata)); + firstSamplePosition = input.getPosition(); + } else if (firstSamplePosition != 0) { + long inputPosition = input.getPosition(); + if (inputPosition < firstSamplePosition) { + // Skip past the seek frame. + input.skipFully((int) (firstSamplePosition - inputPosition)); + } + } + return readSample(input); + } + + /** + * Disables the extractor from being able to seek through the media. + * + * <p>Please note that this needs to be called before {@link #read}. + */ + public void disableSeeking() { + disableSeeking = true; + } + + // Internal methods. + + private int readSample(ExtractorInput extractorInput) throws IOException, InterruptedException { + if (sampleBytesRemaining == 0) { + extractorInput.resetPeekPosition(); + if (peekEndOfStreamOrHeader(extractorInput)) { + return RESULT_END_OF_INPUT; + } + scratch.setPosition(0); + int sampleHeaderData = scratch.readInt(); + if (!headersMatch(sampleHeaderData, synchronizedHeaderData) + || MpegAudioHeader.getFrameSize(sampleHeaderData) == C.LENGTH_UNSET) { + // We have lost synchronization, so attempt to resynchronize starting at the next byte. + extractorInput.skipFully(1); + synchronizedHeaderData = 0; + return RESULT_CONTINUE; + } + MpegAudioHeader.populateHeader(sampleHeaderData, synchronizedHeader); + if (basisTimeUs == C.TIME_UNSET) { + basisTimeUs = seeker.getTimeUs(extractorInput.getPosition()); + if (forcedFirstSampleTimestampUs != C.TIME_UNSET) { + long embeddedFirstSampleTimestampUs = seeker.getTimeUs(0); + basisTimeUs += forcedFirstSampleTimestampUs - embeddedFirstSampleTimestampUs; + } + } + sampleBytesRemaining = synchronizedHeader.frameSize; + } + int bytesAppended = trackOutput.sampleData(extractorInput, sampleBytesRemaining, true); + if (bytesAppended == C.RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + sampleBytesRemaining -= bytesAppended; + if (sampleBytesRemaining > 0) { + return RESULT_CONTINUE; + } + long timeUs = basisTimeUs + (samplesRead * C.MICROS_PER_SECOND / synchronizedHeader.sampleRate); + trackOutput.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, synchronizedHeader.frameSize, 0, + null); + samplesRead += synchronizedHeader.samplesPerFrame; + sampleBytesRemaining = 0; + return RESULT_CONTINUE; + } + + private boolean synchronize(ExtractorInput input, boolean sniffing) + throws IOException, InterruptedException { + int validFrameCount = 0; + int candidateSynchronizedHeaderData = 0; + int peekedId3Bytes = 0; + int searchedBytes = 0; + int searchLimitBytes = sniffing ? MAX_SNIFF_BYTES : MAX_SYNC_BYTES; + input.resetPeekPosition(); + if (input.getPosition() == 0) { + // We need to parse enough ID3 metadata to retrieve any gapless/seeking playback information + // even if ID3 metadata parsing is disabled. + boolean parseAllId3Frames = (flags & FLAG_DISABLE_ID3_METADATA) == 0; + Id3Decoder.FramePredicate id3FramePredicate = + parseAllId3Frames ? null : REQUIRED_ID3_FRAME_PREDICATE; + metadata = id3Peeker.peekId3Data(input, id3FramePredicate); + if (metadata != null) { + gaplessInfoHolder.setFromMetadata(metadata); + } + peekedId3Bytes = (int) input.getPeekPosition(); + if (!sniffing) { + input.skipFully(peekedId3Bytes); + } + } + while (true) { + if (peekEndOfStreamOrHeader(input)) { + if (validFrameCount > 0) { + // We reached the end of the stream but found at least one valid frame. + break; + } + throw new EOFException(); + } + scratch.setPosition(0); + int headerData = scratch.readInt(); + int frameSize; + if ((candidateSynchronizedHeaderData != 0 + && !headersMatch(headerData, candidateSynchronizedHeaderData)) + || (frameSize = MpegAudioHeader.getFrameSize(headerData)) == C.LENGTH_UNSET) { + // The header doesn't match the candidate header or is invalid. Try the next byte offset. + if (searchedBytes++ == searchLimitBytes) { + if (!sniffing) { + throw new ParserException("Searched too many bytes."); + } + return false; + } + validFrameCount = 0; + candidateSynchronizedHeaderData = 0; + if (sniffing) { + input.resetPeekPosition(); + input.advancePeekPosition(peekedId3Bytes + searchedBytes); + } else { + input.skipFully(1); + } + } else { + // The header matches the candidate header and/or is valid. + validFrameCount++; + if (validFrameCount == 1) { + MpegAudioHeader.populateHeader(headerData, synchronizedHeader); + candidateSynchronizedHeaderData = headerData; + } else if (validFrameCount == 4) { + break; + } + input.advancePeekPosition(frameSize - 4); + } + } + // Prepare to read the synchronized frame. + if (sniffing) { + input.skipFully(peekedId3Bytes + searchedBytes); + } else { + input.resetPeekPosition(); + } + synchronizedHeaderData = candidateSynchronizedHeaderData; + return true; + } + + /** + * Returns whether the extractor input is peeking the end of the stream. If {@code false}, + * populates the scratch buffer with the next four bytes. + */ + private boolean peekEndOfStreamOrHeader(ExtractorInput extractorInput) + throws IOException, InterruptedException { + if (seeker != null) { + long dataEndPosition = seeker.getDataEndPosition(); + if (dataEndPosition != C.POSITION_UNSET + && extractorInput.getPeekPosition() > dataEndPosition - 4) { + return true; + } + } + try { + return !extractorInput.peekFully( + scratch.data, /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true); + } catch (EOFException e) { + return true; + } + } + + /** + * Consumes the next frame from the {@code input} if it contains VBRI or Xing seeking metadata, + * returning a {@link Seeker} if the metadata was present and valid, or {@code null} otherwise. + * After this method returns, the input position is the start of the first frame of audio. + * + * @param input The {@link ExtractorInput} from which to read. + * @return A {@link Seeker} if seeking metadata was present and valid, or {@code null} otherwise. + * @throws IOException Thrown if there was an error reading from the stream. Not expected if the + * next two frames were already peeked during synchronization. + * @throws InterruptedException Thrown if reading from the stream was interrupted. Not expected if + * the next two frames were already peeked during synchronization. + */ + private Seeker maybeReadSeekFrame(ExtractorInput input) throws IOException, InterruptedException { + ParsableByteArray frame = new ParsableByteArray(synchronizedHeader.frameSize); + input.peekFully(frame.data, 0, synchronizedHeader.frameSize); + int xingBase = (synchronizedHeader.version & 1) != 0 + ? (synchronizedHeader.channels != 1 ? 36 : 21) // MPEG 1 + : (synchronizedHeader.channels != 1 ? 21 : 13); // MPEG 2 or 2.5 + int seekHeader = getSeekFrameHeader(frame, xingBase); + Seeker seeker; + if (seekHeader == SEEK_HEADER_XING || seekHeader == SEEK_HEADER_INFO) { + seeker = XingSeeker.create(input.getLength(), input.getPosition(), synchronizedHeader, frame); + if (seeker != null && !gaplessInfoHolder.hasGaplessInfo()) { + // If there is a Xing header, read gapless playback metadata at a fixed offset. + input.resetPeekPosition(); + input.advancePeekPosition(xingBase + 141); + input.peekFully(scratch.data, 0, 3); + scratch.setPosition(0); + gaplessInfoHolder.setFromXingHeaderValue(scratch.readUnsignedInt24()); + } + input.skipFully(synchronizedHeader.frameSize); + if (seeker != null && !seeker.isSeekable() && seekHeader == SEEK_HEADER_INFO) { + // Fall back to constant bitrate seeking for Info headers missing a table of contents. + return getConstantBitrateSeeker(input); + } + } else if (seekHeader == SEEK_HEADER_VBRI) { + seeker = VbriSeeker.create(input.getLength(), input.getPosition(), synchronizedHeader, frame); + input.skipFully(synchronizedHeader.frameSize); + } else { // seekerHeader == SEEK_HEADER_UNSET + // This frame doesn't contain seeking information, so reset the peek position. + seeker = null; + input.resetPeekPosition(); + } + return seeker; + } + + /** + * Peeks the next frame and returns a {@link ConstantBitrateSeeker} based on its bitrate. + */ + private Seeker getConstantBitrateSeeker(ExtractorInput input) + throws IOException, InterruptedException { + input.peekFully(scratch.data, 0, 4); + scratch.setPosition(0); + MpegAudioHeader.populateHeader(scratch.readInt(), synchronizedHeader); + return new ConstantBitrateSeeker(input.getLength(), input.getPosition(), synchronizedHeader); + } + + /** + * Returns whether the headers match in those bits masked by {@link #MPEG_AUDIO_HEADER_MASK}. + */ + private static boolean headersMatch(int headerA, long headerB) { + return (headerA & MPEG_AUDIO_HEADER_MASK) == (headerB & MPEG_AUDIO_HEADER_MASK); + } + + /** + * Returns {@link #SEEK_HEADER_XING}, {@link #SEEK_HEADER_INFO} or {@link #SEEK_HEADER_VBRI} if + * the provided {@code frame} may have seeking metadata, or {@link #SEEK_HEADER_UNSET} otherwise. + * If seeking metadata is present, {@code frame}'s position is advanced past the header. + */ + private static int getSeekFrameHeader(ParsableByteArray frame, int xingBase) { + if (frame.limit() >= xingBase + 4) { + frame.setPosition(xingBase); + int headerData = frame.readInt(); + if (headerData == SEEK_HEADER_XING || headerData == SEEK_HEADER_INFO) { + return headerData; + } + } + if (frame.limit() >= 40) { + frame.setPosition(36); // MPEG audio header (4 bytes) + 32 bytes. + if (frame.readInt() == SEEK_HEADER_VBRI) { + return SEEK_HEADER_VBRI; + } + } + return SEEK_HEADER_UNSET; + } + + @Nullable + private static MlltSeeker maybeHandleSeekMetadata(Metadata metadata, long firstFramePosition) { + if (metadata != null) { + int length = metadata.length(); + for (int i = 0; i < length; i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof MlltFrame) { + return MlltSeeker.create(firstFramePosition, (MlltFrame) entry); + } + } + } + return null; + } + + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java new file mode 100644 index 0000000000..da0306cc60 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/Seeker.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; + +/** + * {@link SeekMap} that provides the end position of audio data and also allows mapping from + * position (byte offset) back to time, which can be used to work out the new sample basis timestamp + * after seeking and resynchronization. + */ +/* package */ interface Seeker extends SeekMap { + + /** + * Maps a position (byte offset) to a corresponding sample timestamp. + * + * @param position A seek position (byte offset) relative to the start of the stream. + * @return The corresponding timestamp of the next sample to be read, in microseconds. + */ + long getTimeUs(long position); + + /** + * Returns the position (byte offset) in the stream that is immediately after audio data, or + * {@link C#POSITION_UNSET} if not known. + */ + long getDataEndPosition(); + + /** A {@link Seeker} that does not support seeking through audio data. */ + /* package */ class UnseekableSeeker extends SeekMap.Unseekable implements Seeker { + + public UnseekableSeeker() { + super(/* durationUs= */ C.TIME_UNSET); + } + + @Override + public long getTimeUs(long position) { + return 0; + } + + @Override + public long getDataEndPosition() { + // Position unset as we do not know the data end position. Note that returning 0 doesn't work. + return C.POSITION_UNSET; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java new file mode 100644 index 0000000000..8bb142f496 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/VbriSeeker.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** MP3 seeker that uses metadata from a VBRI header. */ +/* package */ final class VbriSeeker implements Seeker { + + private static final String TAG = "VbriSeeker"; + + /** + * Returns a {@link VbriSeeker} for seeking in the stream, if required information is present. + * Returns {@code null} if not. On returning, {@code frame}'s position is not specified so the + * caller should reset it. + * + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param position The position of the start of this frame in the stream. + * @param mpegAudioHeader The MPEG audio header associated with the frame. + * @param frame The data in this audio frame, with its position set to immediately after the + * 'VBRI' tag. + * @return A {@link VbriSeeker} for seeking in the stream, or {@code null} if the required + * information is not present. + */ + public static @Nullable VbriSeeker create( + long inputLength, long position, MpegAudioHeader mpegAudioHeader, ParsableByteArray frame) { + frame.skipBytes(10); + int numFrames = frame.readInt(); + if (numFrames <= 0) { + return null; + } + int sampleRate = mpegAudioHeader.sampleRate; + long durationUs = Util.scaleLargeTimestamp(numFrames, + C.MICROS_PER_SECOND * (sampleRate >= 32000 ? 1152 : 576), sampleRate); + int entryCount = frame.readUnsignedShort(); + int scale = frame.readUnsignedShort(); + int entrySize = frame.readUnsignedShort(); + frame.skipBytes(2); + + long minPosition = position + mpegAudioHeader.frameSize; + // Read table of contents entries. + long[] timesUs = new long[entryCount]; + long[] positions = new long[entryCount]; + for (int index = 0; index < entryCount; index++) { + timesUs[index] = (index * durationUs) / entryCount; + // Ensure positions do not fall within the frame containing the VBRI header. This constraint + // will normally only apply to the first entry in the table. + positions[index] = Math.max(position, minPosition); + int segmentSize; + switch (entrySize) { + case 1: + segmentSize = frame.readUnsignedByte(); + break; + case 2: + segmentSize = frame.readUnsignedShort(); + break; + case 3: + segmentSize = frame.readUnsignedInt24(); + break; + case 4: + segmentSize = frame.readUnsignedIntToInt(); + break; + default: + return null; + } + position += segmentSize * scale; + } + if (inputLength != C.LENGTH_UNSET && inputLength != position) { + Log.w(TAG, "VBRI data size mismatch: " + inputLength + ", " + position); + } + return new VbriSeeker(timesUs, positions, durationUs, /* dataEndPosition= */ position); + } + + private final long[] timesUs; + private final long[] positions; + private final long durationUs; + private final long dataEndPosition; + + private VbriSeeker(long[] timesUs, long[] positions, long durationUs, long dataEndPosition) { + this.timesUs = timesUs; + this.positions = positions; + this.durationUs = durationUs; + this.dataEndPosition = dataEndPosition; + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + int tableIndex = Util.binarySearchFloor(timesUs, timeUs, true, true); + SeekPoint seekPoint = new SeekPoint(timesUs[tableIndex], positions[tableIndex]); + if (seekPoint.timeUs >= timeUs || tableIndex == timesUs.length - 1) { + return new SeekPoints(seekPoint); + } else { + SeekPoint nextSeekPoint = new SeekPoint(timesUs[tableIndex + 1], positions[tableIndex + 1]); + return new SeekPoints(seekPoint, nextSeekPoint); + } + } + + @Override + public long getTimeUs(long position) { + return timesUs[Util.binarySearchFloor(positions, position, true, true)]; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public long getDataEndPosition() { + return dataEndPosition; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java new file mode 100644 index 0000000000..61568aac93 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp3/XingSeeker.java @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** MP3 seeker that uses metadata from a Xing header. */ +/* package */ final class XingSeeker implements Seeker { + + private static final String TAG = "XingSeeker"; + + /** + * Returns a {@link XingSeeker} for seeking in the stream, if required information is present. + * Returns {@code null} if not. On returning, {@code frame}'s position is not specified so the + * caller should reset it. + * + * @param inputLength The length of the stream in bytes, or {@link C#LENGTH_UNSET} if unknown. + * @param position The position of the start of this frame in the stream. + * @param mpegAudioHeader The MPEG audio header associated with the frame. + * @param frame The data in this audio frame, with its position set to immediately after the + * 'Xing' or 'Info' tag. + * @return A {@link XingSeeker} for seeking in the stream, or {@code null} if the required + * information is not present. + */ + public static @Nullable XingSeeker create( + long inputLength, long position, MpegAudioHeader mpegAudioHeader, ParsableByteArray frame) { + int samplesPerFrame = mpegAudioHeader.samplesPerFrame; + int sampleRate = mpegAudioHeader.sampleRate; + + int flags = frame.readInt(); + int frameCount; + if ((flags & 0x01) != 0x01 || (frameCount = frame.readUnsignedIntToInt()) == 0) { + // If the frame count is missing/invalid, the header can't be used to determine the duration. + return null; + } + long durationUs = Util.scaleLargeTimestamp(frameCount, samplesPerFrame * C.MICROS_PER_SECOND, + sampleRate); + if ((flags & 0x06) != 0x06) { + // If the size in bytes or table of contents is missing, the stream is not seekable. + return new XingSeeker(position, mpegAudioHeader.frameSize, durationUs); + } + + long dataSize = frame.readUnsignedIntToInt(); + long[] tableOfContents = new long[100]; + for (int i = 0; i < 100; i++) { + tableOfContents[i] = frame.readUnsignedByte(); + } + + // TODO: Handle encoder delay and padding in 3 bytes offset by xingBase + 213 bytes: + // delay = (frame.readUnsignedByte() << 4) + (frame.readUnsignedByte() >> 4); + // padding = ((frame.readUnsignedByte() & 0x0F) << 8) + frame.readUnsignedByte(); + + if (inputLength != C.LENGTH_UNSET && inputLength != position + dataSize) { + Log.w(TAG, "XING data size mismatch: " + inputLength + ", " + (position + dataSize)); + } + return new XingSeeker( + position, mpegAudioHeader.frameSize, durationUs, dataSize, tableOfContents); + } + + private final long dataStartPosition; + private final int xingFrameSize; + private final long durationUs; + /** Data size, including the XING frame. */ + private final long dataSize; + + private final long dataEndPosition; + /** + * Entries are in the range [0, 255], but are stored as long integers for convenience. Null if the + * table of contents was missing from the header, in which case seeking is not be supported. + */ + @Nullable private final long[] tableOfContents; + + private XingSeeker(long dataStartPosition, int xingFrameSize, long durationUs) { + this( + dataStartPosition, + xingFrameSize, + durationUs, + /* dataSize= */ C.LENGTH_UNSET, + /* tableOfContents= */ null); + } + + private XingSeeker( + long dataStartPosition, + int xingFrameSize, + long durationUs, + long dataSize, + @Nullable long[] tableOfContents) { + this.dataStartPosition = dataStartPosition; + this.xingFrameSize = xingFrameSize; + this.durationUs = durationUs; + this.tableOfContents = tableOfContents; + this.dataSize = dataSize; + dataEndPosition = dataSize == C.LENGTH_UNSET ? C.POSITION_UNSET : dataStartPosition + dataSize; + } + + @Override + public boolean isSeekable() { + return tableOfContents != null; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + if (!isSeekable()) { + return new SeekPoints(new SeekPoint(0, dataStartPosition + xingFrameSize)); + } + timeUs = Util.constrainValue(timeUs, 0, durationUs); + double percent = (timeUs * 100d) / durationUs; + double scaledPosition; + if (percent <= 0) { + scaledPosition = 0; + } else if (percent >= 100) { + scaledPosition = 256; + } else { + int prevTableIndex = (int) percent; + long[] tableOfContents = Assertions.checkNotNull(this.tableOfContents); + double prevScaledPosition = tableOfContents[prevTableIndex]; + double nextScaledPosition = prevTableIndex == 99 ? 256 : tableOfContents[prevTableIndex + 1]; + // Linearly interpolate between the two scaled positions. + double interpolateFraction = percent - prevTableIndex; + scaledPosition = prevScaledPosition + + (interpolateFraction * (nextScaledPosition - prevScaledPosition)); + } + long positionOffset = Math.round((scaledPosition / 256) * dataSize); + // Ensure returned positions skip the frame containing the XING header. + positionOffset = Util.constrainValue(positionOffset, xingFrameSize, dataSize - 1); + return new SeekPoints(new SeekPoint(timeUs, dataStartPosition + positionOffset)); + } + + @Override + public long getTimeUs(long position) { + long positionOffset = position - dataStartPosition; + if (!isSeekable() || positionOffset <= xingFrameSize) { + return 0L; + } + long[] tableOfContents = Assertions.checkNotNull(this.tableOfContents); + double scaledPosition = (positionOffset * 256d) / dataSize; + int prevTableIndex = Util.binarySearchFloor(tableOfContents, (long) scaledPosition, true, true); + long prevTimeUs = getTimeUsForTableIndex(prevTableIndex); + long prevScaledPosition = tableOfContents[prevTableIndex]; + long nextTimeUs = getTimeUsForTableIndex(prevTableIndex + 1); + long nextScaledPosition = prevTableIndex == 99 ? 256 : tableOfContents[prevTableIndex + 1]; + // Linearly interpolate between the two table entries. + double interpolateFraction = prevScaledPosition == nextScaledPosition ? 0 + : ((scaledPosition - prevScaledPosition) / (nextScaledPosition - prevScaledPosition)); + return prevTimeUs + Math.round(interpolateFraction * (nextTimeUs - prevTimeUs)); + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public long getDataEndPosition() { + return dataEndPosition; + } + + /** + * Returns the time in microseconds for a given table index. + * + * @param tableIndex A table index in the range [0, 100]. + * @return The corresponding time in microseconds. + */ + private long getTimeUsForTableIndex(int tableIndex) { + return (durationUs * tableIndex) / 100; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Atom.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Atom.java new file mode 100644 index 0000000000..56f0eab1cd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Atom.java @@ -0,0 +1,558 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@SuppressWarnings("ConstantField") +/* package */ abstract class Atom { + + /** + * Size of an atom header, in bytes. + */ + public static final int HEADER_SIZE = 8; + + /** + * Size of a full atom header, in bytes. + */ + public static final int FULL_HEADER_SIZE = 12; + + /** + * Size of a long atom header, in bytes. + */ + public static final int LONG_HEADER_SIZE = 16; + + /** + * Value for the size field in an atom that defines its size in the largesize field. + */ + public static final int DEFINES_LARGE_SIZE = 1; + + /** + * Value for the size field in an atom that extends to the end of the file. + */ + public static final int EXTENDS_TO_END_SIZE = 0; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ftyp = 0x66747970; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_avc1 = 0x61766331; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_avc3 = 0x61766333; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_avcC = 0x61766343; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_hvc1 = 0x68766331; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_hev1 = 0x68657631; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_hvcC = 0x68766343; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_vp08 = 0x76703038; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_vp09 = 0x76703039; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_vpcC = 0x76706343; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_av01 = 0x61763031; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_av1C = 0x61763143; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dvav = 0x64766176; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dva1 = 0x64766131; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dvhe = 0x64766865; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dvh1 = 0x64766831; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dvcC = 0x64766343; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dvvC = 0x64767643; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_s263 = 0x73323633; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_d263 = 0x64323633; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mdat = 0x6d646174; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mp4a = 0x6d703461; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE__mp3 = 0x2e6d7033; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_wave = 0x77617665; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_lpcm = 0x6c70636d; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sowt = 0x736f7774; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ac_3 = 0x61632d33; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dac3 = 0x64616333; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ec_3 = 0x65632d33; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dec3 = 0x64656333; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ac_4 = 0x61632d34; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dac4 = 0x64616334; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dtsc = 0x64747363; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dtsh = 0x64747368; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dtsl = 0x6474736c; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dtse = 0x64747365; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ddts = 0x64647473; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_tfdt = 0x74666474; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_tfhd = 0x74666864; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_trex = 0x74726578; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_trun = 0x7472756e; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sidx = 0x73696478; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_moov = 0x6d6f6f76; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mvhd = 0x6d766864; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_trak = 0x7472616b; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mdia = 0x6d646961; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_minf = 0x6d696e66; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stbl = 0x7374626c; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_esds = 0x65736473; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_moof = 0x6d6f6f66; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_traf = 0x74726166; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mvex = 0x6d766578; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mehd = 0x6d656864; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_tkhd = 0x746b6864; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_edts = 0x65647473; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_elst = 0x656c7374; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mdhd = 0x6d646864; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_hdlr = 0x68646c72; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stsd = 0x73747364; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_pssh = 0x70737368; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sinf = 0x73696e66; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_schm = 0x7363686d; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_schi = 0x73636869; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_tenc = 0x74656e63; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_encv = 0x656e6376; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_enca = 0x656e6361; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_frma = 0x66726d61; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_saiz = 0x7361697a; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_saio = 0x7361696f; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sbgp = 0x73626770; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sgpd = 0x73677064; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_uuid = 0x75756964; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_senc = 0x73656e63; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_pasp = 0x70617370; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_TTML = 0x54544d4c; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_vmhd = 0x766d6864; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mp4v = 0x6d703476; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stts = 0x73747473; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stss = 0x73747373; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ctts = 0x63747473; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stsc = 0x73747363; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stsz = 0x7374737a; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stz2 = 0x73747a32; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stco = 0x7374636f; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_co64 = 0x636f3634; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_tx3g = 0x74783367; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_wvtt = 0x77767474; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_stpp = 0x73747070; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_c608 = 0x63363038; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_samr = 0x73616d72; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sawb = 0x73617762; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_udta = 0x75647461; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_meta = 0x6d657461; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_keys = 0x6b657973; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ilst = 0x696c7374; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_mean = 0x6d65616e; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_name = 0x6e616d65; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_data = 0x64617461; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_emsg = 0x656d7367; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_st3d = 0x73743364; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_sv3d = 0x73763364; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_proj = 0x70726f6a; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_camm = 0x63616d6d; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_alac = 0x616c6163; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_alaw = 0x616c6177; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_ulaw = 0x756c6177; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_Opus = 0x4f707573; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dOps = 0x644f7073; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_fLaC = 0x664c6143; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_dfLa = 0x64664c61; + + @SuppressWarnings("ConstantCaseForConstants") + public static final int TYPE_twos = 0x74776f73; + + public final int type; + + public Atom(int type) { + this.type = type; + } + + @Override + public String toString() { + return getAtomTypeString(type); + } + + /** + * An MP4 atom that is a leaf. + */ + /* package */ static final class LeafAtom extends Atom { + + /** + * The atom data. + */ + public final ParsableByteArray data; + + /** + * @param type The type of the atom. + * @param data The atom data. + */ + public LeafAtom(int type, ParsableByteArray data) { + super(type); + this.data = data; + } + + } + + /** + * An MP4 atom that has child atoms. + */ + /* package */ static final class ContainerAtom extends Atom { + + public final long endPosition; + public final List<LeafAtom> leafChildren; + public final List<ContainerAtom> containerChildren; + + /** + * @param type The type of the atom. + * @param endPosition The position of the first byte after the end of the atom. + */ + public ContainerAtom(int type, long endPosition) { + super(type); + this.endPosition = endPosition; + leafChildren = new ArrayList<>(); + containerChildren = new ArrayList<>(); + } + + /** + * Adds a child leaf to this container. + * + * @param atom The child to add. + */ + public void add(LeafAtom atom) { + leafChildren.add(atom); + } + + /** + * Adds a child container to this container. + * + * @param atom The child to add. + */ + public void add(ContainerAtom atom) { + containerChildren.add(atom); + } + + /** + * Returns the child leaf of the given type. + * + * <p>If no child exists with the given type then null is returned. If multiple children exist + * with the given type then the first one to have been added is returned. + * + * @param type The leaf type. + * @return The child leaf of the given type, or null if no such child exists. + */ + @Nullable + public LeafAtom getLeafAtomOfType(int type) { + int childrenSize = leafChildren.size(); + for (int i = 0; i < childrenSize; i++) { + LeafAtom atom = leafChildren.get(i); + if (atom.type == type) { + return atom; + } + } + return null; + } + + /** + * Returns the child container of the given type. + * + * <p>If no child exists with the given type then null is returned. If multiple children exist + * with the given type then the first one to have been added is returned. + * + * @param type The container type. + * @return The child container of the given type, or null if no such child exists. + */ + @Nullable + public ContainerAtom getContainerAtomOfType(int type) { + int childrenSize = containerChildren.size(); + for (int i = 0; i < childrenSize; i++) { + ContainerAtom atom = containerChildren.get(i); + if (atom.type == type) { + return atom; + } + } + return null; + } + + /** + * Returns the total number of leaf/container children of this atom with the given type. + * + * @param type The type of child atoms to count. + * @return The total number of leaf/container children of this atom with the given type. + */ + public int getChildAtomOfTypeCount(int type) { + int count = 0; + int size = leafChildren.size(); + for (int i = 0; i < size; i++) { + LeafAtom atom = leafChildren.get(i); + if (atom.type == type) { + count++; + } + } + size = containerChildren.size(); + for (int i = 0; i < size; i++) { + ContainerAtom atom = containerChildren.get(i); + if (atom.type == type) { + count++; + } + } + return count; + } + + @Override + public String toString() { + return getAtomTypeString(type) + + " leaves: " + Arrays.toString(leafChildren.toArray()) + + " containers: " + Arrays.toString(containerChildren.toArray()); + } + + } + + /** + * Parses the version number out of the additional integer component of a full atom. + */ + public static int parseFullAtomVersion(int fullAtomInt) { + return 0x000000FF & (fullAtomInt >> 24); + } + + /** + * Parses the atom flags out of the additional integer component of a full atom. + */ + public static int parseFullAtomFlags(int fullAtomInt) { + return 0x00FFFFFF & fullAtomInt; + } + + /** + * Converts a numeric atom type to the corresponding four character string. + * + * @param type The numeric atom type. + * @return The corresponding four character string. + */ + public static String getAtomTypeString(int type) { + return "" + (char) ((type >> 24) & 0xFF) + + (char) ((type >> 16) & 0xFF) + + (char) ((type >> 8) & 0xFF) + + (char) (type & 0xFF); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java new file mode 100644 index 0000000000..93ee2d6810 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/AtomParsers.java @@ -0,0 +1,1607 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes.getMimeTypeFromMp4ObjectType; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac3Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.CodecSpecificDataUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.AvcConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.DolbyVisionConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.HevcConfig; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** Utility methods for parsing MP4 format atom payloads according to ISO 14496-12. */ +@SuppressWarnings({"ConstantField"}) +/* package */ final class AtomParsers { + + private static final String TAG = "AtomParsers"; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_vide = 0x76696465; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_soun = 0x736f756e; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_text = 0x74657874; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_sbtl = 0x7362746c; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_subt = 0x73756274; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_clcp = 0x636c6370; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_meta = 0x6d657461; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_mdta = 0x6d647461; + + /** + * The threshold number of samples to trim from the start/end of an audio track when applying an + * edit below which gapless info can be used (rather than removing samples from the sample table). + */ + private static final int MAX_GAPLESS_TRIM_SIZE_SAMPLES = 4; + + /** The magic signature for an Opus Identification header, as defined in RFC-7845. */ + private static final byte[] opusMagic = Util.getUtf8Bytes("OpusHead"); + + /** + * Parses a trak atom (defined in 14496-12). + * + * @param trak Atom to decode. + * @param mvhd Movie header atom, used to get the timescale. + * @param duration The duration in units of the timescale declared in the mvhd atom, or + * {@link C#TIME_UNSET} if the duration should be parsed from the tkhd atom. + * @param drmInitData {@link DrmInitData} to be included in the format. + * @param ignoreEditLists Whether to ignore any edit lists in the trak box. + * @param isQuickTime True for QuickTime media. False otherwise. + * @return A {@link Track} instance, or {@code null} if the track's type isn't supported. + */ + public static Track parseTrak(Atom.ContainerAtom trak, Atom.LeafAtom mvhd, long duration, + DrmInitData drmInitData, boolean ignoreEditLists, boolean isQuickTime) + throws ParserException { + Atom.ContainerAtom mdia = trak.getContainerAtomOfType(Atom.TYPE_mdia); + int trackType = getTrackTypeForHdlr(parseHdlr(mdia.getLeafAtomOfType(Atom.TYPE_hdlr).data)); + if (trackType == C.TRACK_TYPE_UNKNOWN) { + return null; + } + + TkhdData tkhdData = parseTkhd(trak.getLeafAtomOfType(Atom.TYPE_tkhd).data); + if (duration == C.TIME_UNSET) { + duration = tkhdData.duration; + } + long movieTimescale = parseMvhd(mvhd.data); + long durationUs; + if (duration == C.TIME_UNSET) { + durationUs = C.TIME_UNSET; + } else { + durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, movieTimescale); + } + Atom.ContainerAtom stbl = mdia.getContainerAtomOfType(Atom.TYPE_minf) + .getContainerAtomOfType(Atom.TYPE_stbl); + + Pair<Long, String> mdhdData = parseMdhd(mdia.getLeafAtomOfType(Atom.TYPE_mdhd).data); + StsdData stsdData = parseStsd(stbl.getLeafAtomOfType(Atom.TYPE_stsd).data, tkhdData.id, + tkhdData.rotationDegrees, mdhdData.second, drmInitData, isQuickTime); + long[] editListDurations = null; + long[] editListMediaTimes = null; + if (!ignoreEditLists) { + Pair<long[], long[]> edtsData = parseEdts(trak.getContainerAtomOfType(Atom.TYPE_edts)); + editListDurations = edtsData.first; + editListMediaTimes = edtsData.second; + } + return stsdData.format == null ? null + : new Track(tkhdData.id, trackType, mdhdData.first, movieTimescale, durationUs, + stsdData.format, stsdData.requiredSampleTransformation, stsdData.trackEncryptionBoxes, + stsdData.nalUnitLengthFieldLength, editListDurations, editListMediaTimes); + } + + /** + * Parses an stbl atom (defined in 14496-12). + * + * @param track Track to which this sample table corresponds. + * @param stblAtom stbl (sample table) atom to decode. + * @param gaplessInfoHolder Holder to populate with gapless playback information. + * @return Sample table described by the stbl atom. + * @throws ParserException Thrown if the stbl atom can't be parsed. + */ + public static TrackSampleTable parseStbl( + Track track, Atom.ContainerAtom stblAtom, GaplessInfoHolder gaplessInfoHolder) + throws ParserException { + SampleSizeBox sampleSizeBox; + Atom.LeafAtom stszAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stsz); + if (stszAtom != null) { + sampleSizeBox = new StszSampleSizeBox(stszAtom); + } else { + Atom.LeafAtom stz2Atom = stblAtom.getLeafAtomOfType(Atom.TYPE_stz2); + if (stz2Atom == null) { + throw new ParserException("Track has no sample table size information"); + } + sampleSizeBox = new Stz2SampleSizeBox(stz2Atom); + } + + int sampleCount = sampleSizeBox.getSampleCount(); + if (sampleCount == 0) { + return new TrackSampleTable( + track, + /* offsets= */ new long[0], + /* sizes= */ new int[0], + /* maximumSize= */ 0, + /* timestampsUs= */ new long[0], + /* flags= */ new int[0], + /* durationUs= */ C.TIME_UNSET); + } + + // Entries are byte offsets of chunks. + boolean chunkOffsetsAreLongs = false; + Atom.LeafAtom chunkOffsetsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stco); + if (chunkOffsetsAtom == null) { + chunkOffsetsAreLongs = true; + chunkOffsetsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_co64); + } + ParsableByteArray chunkOffsets = chunkOffsetsAtom.data; + // Entries are (chunk number, number of samples per chunk, sample description index). + ParsableByteArray stsc = stblAtom.getLeafAtomOfType(Atom.TYPE_stsc).data; + // Entries are (number of samples, timestamp delta between those samples). + ParsableByteArray stts = stblAtom.getLeafAtomOfType(Atom.TYPE_stts).data; + // Entries are the indices of samples that are synchronization samples. + Atom.LeafAtom stssAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_stss); + ParsableByteArray stss = stssAtom != null ? stssAtom.data : null; + // Entries are (number of samples, timestamp offset). + Atom.LeafAtom cttsAtom = stblAtom.getLeafAtomOfType(Atom.TYPE_ctts); + ParsableByteArray ctts = cttsAtom != null ? cttsAtom.data : null; + + // Prepare to read chunk information. + ChunkIterator chunkIterator = new ChunkIterator(stsc, chunkOffsets, chunkOffsetsAreLongs); + + // Prepare to read sample timestamps. + stts.setPosition(Atom.FULL_HEADER_SIZE); + int remainingTimestampDeltaChanges = stts.readUnsignedIntToInt() - 1; + int remainingSamplesAtTimestampDelta = stts.readUnsignedIntToInt(); + int timestampDeltaInTimeUnits = stts.readUnsignedIntToInt(); + + // Prepare to read sample timestamp offsets, if ctts is present. + int remainingSamplesAtTimestampOffset = 0; + int remainingTimestampOffsetChanges = 0; + int timestampOffset = 0; + if (ctts != null) { + ctts.setPosition(Atom.FULL_HEADER_SIZE); + remainingTimestampOffsetChanges = ctts.readUnsignedIntToInt(); + } + + int nextSynchronizationSampleIndex = C.INDEX_UNSET; + int remainingSynchronizationSamples = 0; + if (stss != null) { + stss.setPosition(Atom.FULL_HEADER_SIZE); + remainingSynchronizationSamples = stss.readUnsignedIntToInt(); + if (remainingSynchronizationSamples > 0) { + nextSynchronizationSampleIndex = stss.readUnsignedIntToInt() - 1; + } else { + // Ignore empty stss boxes, which causes all samples to be treated as sync samples. + stss = null; + } + } + + // Fixed sample size raw audio may need to be rechunked. + boolean isFixedSampleSizeRawAudio = + sampleSizeBox.isFixedSampleSize() + && MimeTypes.AUDIO_RAW.equals(track.format.sampleMimeType) + && remainingTimestampDeltaChanges == 0 + && remainingTimestampOffsetChanges == 0 + && remainingSynchronizationSamples == 0; + + long[] offsets; + int[] sizes; + int maximumSize = 0; + long[] timestamps; + int[] flags; + long timestampTimeUnits = 0; + long duration; + + if (!isFixedSampleSizeRawAudio) { + offsets = new long[sampleCount]; + sizes = new int[sampleCount]; + timestamps = new long[sampleCount]; + flags = new int[sampleCount]; + long offset = 0; + int remainingSamplesInChunk = 0; + + for (int i = 0; i < sampleCount; i++) { + // Advance to the next chunk if necessary. + boolean chunkDataComplete = true; + while (remainingSamplesInChunk == 0 && (chunkDataComplete = chunkIterator.moveNext())) { + offset = chunkIterator.offset; + remainingSamplesInChunk = chunkIterator.numSamples; + } + if (!chunkDataComplete) { + Log.w(TAG, "Unexpected end of chunk data"); + sampleCount = i; + offsets = Arrays.copyOf(offsets, sampleCount); + sizes = Arrays.copyOf(sizes, sampleCount); + timestamps = Arrays.copyOf(timestamps, sampleCount); + flags = Arrays.copyOf(flags, sampleCount); + break; + } + + // Add on the timestamp offset if ctts is present. + if (ctts != null) { + while (remainingSamplesAtTimestampOffset == 0 && remainingTimestampOffsetChanges > 0) { + remainingSamplesAtTimestampOffset = ctts.readUnsignedIntToInt(); + // The BMFF spec (ISO 14496-12) states that sample offsets should be unsigned integers + // in version 0 ctts boxes, however some streams violate the spec and use signed + // integers instead. It's safe to always decode sample offsets as signed integers here, + // because unsigned integers will still be parsed correctly (unless their top bit is + // set, which is never true in practice because sample offsets are always small). + timestampOffset = ctts.readInt(); + remainingTimestampOffsetChanges--; + } + remainingSamplesAtTimestampOffset--; + } + + offsets[i] = offset; + sizes[i] = sampleSizeBox.readNextSampleSize(); + if (sizes[i] > maximumSize) { + maximumSize = sizes[i]; + } + timestamps[i] = timestampTimeUnits + timestampOffset; + + // All samples are synchronization samples if the stss is not present. + flags[i] = stss == null ? C.BUFFER_FLAG_KEY_FRAME : 0; + if (i == nextSynchronizationSampleIndex) { + flags[i] = C.BUFFER_FLAG_KEY_FRAME; + remainingSynchronizationSamples--; + if (remainingSynchronizationSamples > 0) { + nextSynchronizationSampleIndex = stss.readUnsignedIntToInt() - 1; + } + } + + // Add on the duration of this sample. + timestampTimeUnits += timestampDeltaInTimeUnits; + remainingSamplesAtTimestampDelta--; + if (remainingSamplesAtTimestampDelta == 0 && remainingTimestampDeltaChanges > 0) { + remainingSamplesAtTimestampDelta = stts.readUnsignedIntToInt(); + // The BMFF spec (ISO 14496-12) states that sample deltas should be unsigned integers + // in stts boxes, however some streams violate the spec and use signed integers instead. + // See https://github.com/google/ExoPlayer/issues/3384. It's safe to always decode sample + // deltas as signed integers here, because unsigned integers will still be parsed + // correctly (unless their top bit is set, which is never true in practice because sample + // deltas are always small). + timestampDeltaInTimeUnits = stts.readInt(); + remainingTimestampDeltaChanges--; + } + + offset += sizes[i]; + remainingSamplesInChunk--; + } + duration = timestampTimeUnits + timestampOffset; + + // If the stbl's child boxes are not consistent the container is malformed, but the stream may + // still be playable. + boolean isCttsValid = true; + while (remainingTimestampOffsetChanges > 0) { + if (ctts.readUnsignedIntToInt() != 0) { + isCttsValid = false; + break; + } + ctts.readInt(); // Ignore offset. + remainingTimestampOffsetChanges--; + } + if (remainingSynchronizationSamples != 0 + || remainingSamplesAtTimestampDelta != 0 + || remainingSamplesInChunk != 0 + || remainingTimestampDeltaChanges != 0 + || remainingSamplesAtTimestampOffset != 0 + || !isCttsValid) { + Log.w( + TAG, + "Inconsistent stbl box for track " + + track.id + + ": remainingSynchronizationSamples " + + remainingSynchronizationSamples + + ", remainingSamplesAtTimestampDelta " + + remainingSamplesAtTimestampDelta + + ", remainingSamplesInChunk " + + remainingSamplesInChunk + + ", remainingTimestampDeltaChanges " + + remainingTimestampDeltaChanges + + ", remainingSamplesAtTimestampOffset " + + remainingSamplesAtTimestampOffset + + (!isCttsValid ? ", ctts invalid" : "")); + } + } else { + long[] chunkOffsetsBytes = new long[chunkIterator.length]; + int[] chunkSampleCounts = new int[chunkIterator.length]; + while (chunkIterator.moveNext()) { + chunkOffsetsBytes[chunkIterator.index] = chunkIterator.offset; + chunkSampleCounts[chunkIterator.index] = chunkIterator.numSamples; + } + int fixedSampleSize = + Util.getPcmFrameSize(track.format.pcmEncoding, track.format.channelCount); + FixedSampleSizeRechunker.Results rechunkedResults = FixedSampleSizeRechunker.rechunk( + fixedSampleSize, chunkOffsetsBytes, chunkSampleCounts, timestampDeltaInTimeUnits); + offsets = rechunkedResults.offsets; + sizes = rechunkedResults.sizes; + maximumSize = rechunkedResults.maximumSize; + timestamps = rechunkedResults.timestamps; + flags = rechunkedResults.flags; + duration = rechunkedResults.duration; + } + long durationUs = Util.scaleLargeTimestamp(duration, C.MICROS_PER_SECOND, track.timescale); + + if (track.editListDurations == null) { + Util.scaleLargeTimestampsInPlace(timestamps, C.MICROS_PER_SECOND, track.timescale); + return new TrackSampleTable( + track, offsets, sizes, maximumSize, timestamps, flags, durationUs); + } + + // See the BMFF spec (ISO 14496-12) subsection 8.6.6. Edit lists that require prerolling from a + // sync sample after reordering are not supported. Partial audio sample truncation is only + // supported in edit lists with one edit that removes less than MAX_GAPLESS_TRIM_SIZE_SAMPLES + // samples from the start/end of the track. This implementation handles simple + // discarding/delaying of samples. The extractor may place further restrictions on what edited + // streams are playable. + + if (track.editListDurations.length == 1 + && track.type == C.TRACK_TYPE_AUDIO + && timestamps.length >= 2) { + long editStartTime = track.editListMediaTimes[0]; + long editEndTime = editStartTime + Util.scaleLargeTimestamp(track.editListDurations[0], + track.timescale, track.movieTimescale); + if (canApplyEditWithGaplessInfo(timestamps, duration, editStartTime, editEndTime)) { + long paddingTimeUnits = duration - editEndTime; + long encoderDelay = Util.scaleLargeTimestamp(editStartTime - timestamps[0], + track.format.sampleRate, track.timescale); + long encoderPadding = Util.scaleLargeTimestamp(paddingTimeUnits, + track.format.sampleRate, track.timescale); + if ((encoderDelay != 0 || encoderPadding != 0) && encoderDelay <= Integer.MAX_VALUE + && encoderPadding <= Integer.MAX_VALUE) { + gaplessInfoHolder.encoderDelay = (int) encoderDelay; + gaplessInfoHolder.encoderPadding = (int) encoderPadding; + Util.scaleLargeTimestampsInPlace(timestamps, C.MICROS_PER_SECOND, track.timescale); + long editedDurationUs = + Util.scaleLargeTimestamp( + track.editListDurations[0], C.MICROS_PER_SECOND, track.movieTimescale); + return new TrackSampleTable( + track, offsets, sizes, maximumSize, timestamps, flags, editedDurationUs); + } + } + } + + if (track.editListDurations.length == 1 && track.editListDurations[0] == 0) { + // The current version of the spec leaves handling of an edit with zero segment_duration in + // unfragmented files open to interpretation. We handle this as a special case and include all + // samples in the edit. + long editStartTime = track.editListMediaTimes[0]; + for (int i = 0; i < timestamps.length; i++) { + timestamps[i] = + Util.scaleLargeTimestamp( + timestamps[i] - editStartTime, C.MICROS_PER_SECOND, track.timescale); + } + durationUs = + Util.scaleLargeTimestamp(duration - editStartTime, C.MICROS_PER_SECOND, track.timescale); + return new TrackSampleTable( + track, offsets, sizes, maximumSize, timestamps, flags, durationUs); + } + + // Omit any sample at the end point of an edit for audio tracks. + boolean omitClippedSample = track.type == C.TRACK_TYPE_AUDIO; + + // Count the number of samples after applying edits. + int editedSampleCount = 0; + int nextSampleIndex = 0; + boolean copyMetadata = false; + int[] startIndices = new int[track.editListDurations.length]; + int[] endIndices = new int[track.editListDurations.length]; + for (int i = 0; i < track.editListDurations.length; i++) { + long editMediaTime = track.editListMediaTimes[i]; + if (editMediaTime != -1) { + long editDuration = + Util.scaleLargeTimestamp( + track.editListDurations[i], track.timescale, track.movieTimescale); + startIndices[i] = + Util.binarySearchFloor( + timestamps, editMediaTime, /* inclusive= */ true, /* stayInBounds= */ true); + endIndices[i] = + Util.binarySearchCeil( + timestamps, + editMediaTime + editDuration, + /* inclusive= */ omitClippedSample, + /* stayInBounds= */ false); + while (startIndices[i] < endIndices[i] + && (flags[startIndices[i]] & C.BUFFER_FLAG_KEY_FRAME) == 0) { + // Applying the edit correctly would require prerolling from the previous sync sample. In + // the current implementation we advance to the next sync sample instead. Only other + // tracks (i.e. audio) will be rendered until the time of the first sync sample. + // See https://github.com/google/ExoPlayer/issues/1659. + startIndices[i]++; + } + editedSampleCount += endIndices[i] - startIndices[i]; + copyMetadata |= nextSampleIndex != startIndices[i]; + nextSampleIndex = endIndices[i]; + } + } + copyMetadata |= editedSampleCount != sampleCount; + + // Calculate edited sample timestamps and update the corresponding metadata arrays. + long[] editedOffsets = copyMetadata ? new long[editedSampleCount] : offsets; + int[] editedSizes = copyMetadata ? new int[editedSampleCount] : sizes; + int editedMaximumSize = copyMetadata ? 0 : maximumSize; + int[] editedFlags = copyMetadata ? new int[editedSampleCount] : flags; + long[] editedTimestamps = new long[editedSampleCount]; + long pts = 0; + int sampleIndex = 0; + for (int i = 0; i < track.editListDurations.length; i++) { + long editMediaTime = track.editListMediaTimes[i]; + int startIndex = startIndices[i]; + int endIndex = endIndices[i]; + if (copyMetadata) { + int count = endIndex - startIndex; + System.arraycopy(offsets, startIndex, editedOffsets, sampleIndex, count); + System.arraycopy(sizes, startIndex, editedSizes, sampleIndex, count); + System.arraycopy(flags, startIndex, editedFlags, sampleIndex, count); + } + for (int j = startIndex; j < endIndex; j++) { + long ptsUs = Util.scaleLargeTimestamp(pts, C.MICROS_PER_SECOND, track.movieTimescale); + long timeInSegmentUs = + Util.scaleLargeTimestamp( + Math.max(0, timestamps[j] - editMediaTime), C.MICROS_PER_SECOND, track.timescale); + editedTimestamps[sampleIndex] = ptsUs + timeInSegmentUs; + if (copyMetadata && editedSizes[sampleIndex] > editedMaximumSize) { + editedMaximumSize = sizes[j]; + } + sampleIndex++; + } + pts += track.editListDurations[i]; + } + long editedDurationUs = + Util.scaleLargeTimestamp(pts, C.MICROS_PER_SECOND, track.movieTimescale); + return new TrackSampleTable( + track, + editedOffsets, + editedSizes, + editedMaximumSize, + editedTimestamps, + editedFlags, + editedDurationUs); + } + + /** + * Parses a udta atom. + * + * @param udtaAtom The udta (user data) atom to decode. + * @param isQuickTime True for QuickTime media. False otherwise. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseUdta(Atom.LeafAtom udtaAtom, boolean isQuickTime) { + if (isQuickTime) { + // Meta boxes are regular boxes rather than full boxes in QuickTime. For now, don't try and + // decode one. + return null; + } + ParsableByteArray udtaData = udtaAtom.data; + udtaData.setPosition(Atom.HEADER_SIZE); + while (udtaData.bytesLeft() >= Atom.HEADER_SIZE) { + int atomPosition = udtaData.getPosition(); + int atomSize = udtaData.readInt(); + int atomType = udtaData.readInt(); + if (atomType == Atom.TYPE_meta) { + udtaData.setPosition(atomPosition); + return parseUdtaMeta(udtaData, atomPosition + atomSize); + } + udtaData.setPosition(atomPosition + atomSize); + } + return null; + } + + /** + * Parses a metadata meta atom if it contains metadata with handler 'mdta'. + * + * @param meta The metadata atom to decode. + * @return Parsed metadata, or null. + */ + @Nullable + public static Metadata parseMdtaFromMeta(Atom.ContainerAtom meta) { + Atom.LeafAtom hdlrAtom = meta.getLeafAtomOfType(Atom.TYPE_hdlr); + Atom.LeafAtom keysAtom = meta.getLeafAtomOfType(Atom.TYPE_keys); + Atom.LeafAtom ilstAtom = meta.getLeafAtomOfType(Atom.TYPE_ilst); + if (hdlrAtom == null + || keysAtom == null + || ilstAtom == null + || AtomParsers.parseHdlr(hdlrAtom.data) != TYPE_mdta) { + // There isn't enough information to parse the metadata, or the handler type is unexpected. + return null; + } + + // Parse metadata keys. + ParsableByteArray keys = keysAtom.data; + keys.setPosition(Atom.FULL_HEADER_SIZE); + int entryCount = keys.readInt(); + String[] keyNames = new String[entryCount]; + for (int i = 0; i < entryCount; i++) { + int entrySize = keys.readInt(); + keys.skipBytes(4); // keyNamespace + int keySize = entrySize - 8; + keyNames[i] = keys.readString(keySize); + } + + // Parse metadata items. + ParsableByteArray ilst = ilstAtom.data; + ilst.setPosition(Atom.HEADER_SIZE); + ArrayList<Metadata.Entry> entries = new ArrayList<>(); + while (ilst.bytesLeft() > Atom.HEADER_SIZE) { + int atomPosition = ilst.getPosition(); + int atomSize = ilst.readInt(); + int keyIndex = ilst.readInt() - 1; + if (keyIndex >= 0 && keyIndex < keyNames.length) { + String key = keyNames[keyIndex]; + Metadata.Entry entry = + MetadataUtil.parseMdtaMetadataEntryFromIlst(ilst, atomPosition + atomSize, key); + if (entry != null) { + entries.add(entry); + } + } else { + Log.w(TAG, "Skipped metadata with unknown key index: " + keyIndex); + } + ilst.setPosition(atomPosition + atomSize); + } + return entries.isEmpty() ? null : new Metadata(entries); + } + + @Nullable + private static Metadata parseUdtaMeta(ParsableByteArray meta, int limit) { + meta.skipBytes(Atom.FULL_HEADER_SIZE); + while (meta.getPosition() < limit) { + int atomPosition = meta.getPosition(); + int atomSize = meta.readInt(); + int atomType = meta.readInt(); + if (atomType == Atom.TYPE_ilst) { + meta.setPosition(atomPosition); + return parseIlst(meta, atomPosition + atomSize); + } + meta.setPosition(atomPosition + atomSize); + } + return null; + } + + @Nullable + private static Metadata parseIlst(ParsableByteArray ilst, int limit) { + ilst.skipBytes(Atom.HEADER_SIZE); + ArrayList<Metadata.Entry> entries = new ArrayList<>(); + while (ilst.getPosition() < limit) { + Metadata.Entry entry = MetadataUtil.parseIlstElement(ilst); + if (entry != null) { + entries.add(entry); + } + } + return entries.isEmpty() ? null : new Metadata(entries); + } + + /** + * Parses a mvhd atom (defined in 14496-12), returning the timescale for the movie. + * + * @param mvhd Contents of the mvhd atom to be parsed. + * @return Timescale for the movie. + */ + private static long parseMvhd(ParsableByteArray mvhd) { + mvhd.setPosition(Atom.HEADER_SIZE); + int fullAtom = mvhd.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + mvhd.skipBytes(version == 0 ? 8 : 16); + return mvhd.readUnsignedInt(); + } + + /** + * Parses a tkhd atom (defined in 14496-12). + * + * @return An object containing the parsed data. + */ + private static TkhdData parseTkhd(ParsableByteArray tkhd) { + tkhd.setPosition(Atom.HEADER_SIZE); + int fullAtom = tkhd.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + + tkhd.skipBytes(version == 0 ? 8 : 16); + int trackId = tkhd.readInt(); + + tkhd.skipBytes(4); + boolean durationUnknown = true; + int durationPosition = tkhd.getPosition(); + int durationByteCount = version == 0 ? 4 : 8; + for (int i = 0; i < durationByteCount; i++) { + if (tkhd.data[durationPosition + i] != -1) { + durationUnknown = false; + break; + } + } + long duration; + if (durationUnknown) { + tkhd.skipBytes(durationByteCount); + duration = C.TIME_UNSET; + } else { + duration = version == 0 ? tkhd.readUnsignedInt() : tkhd.readUnsignedLongToLong(); + if (duration == 0) { + // 0 duration normally indicates that the file is fully fragmented (i.e. all of the media + // samples are in fragments). Treat as unknown. + duration = C.TIME_UNSET; + } + } + + tkhd.skipBytes(16); + int a00 = tkhd.readInt(); + int a01 = tkhd.readInt(); + tkhd.skipBytes(4); + int a10 = tkhd.readInt(); + int a11 = tkhd.readInt(); + + int rotationDegrees; + int fixedOne = 65536; + if (a00 == 0 && a01 == fixedOne && a10 == -fixedOne && a11 == 0) { + rotationDegrees = 90; + } else if (a00 == 0 && a01 == -fixedOne && a10 == fixedOne && a11 == 0) { + rotationDegrees = 270; + } else if (a00 == -fixedOne && a01 == 0 && a10 == 0 && a11 == -fixedOne) { + rotationDegrees = 180; + } else { + // Only 0, 90, 180 and 270 are supported. Treat anything else as 0. + rotationDegrees = 0; + } + + return new TkhdData(trackId, duration, rotationDegrees); + } + + /** + * Parses an hdlr atom. + * + * @param hdlr The hdlr atom to decode. + * @return The handler value. + */ + private static int parseHdlr(ParsableByteArray hdlr) { + hdlr.setPosition(Atom.FULL_HEADER_SIZE + 4); + return hdlr.readInt(); + } + + /** Returns the track type for a given handler value. */ + private static int getTrackTypeForHdlr(int hdlr) { + if (hdlr == TYPE_soun) { + return C.TRACK_TYPE_AUDIO; + } else if (hdlr == TYPE_vide) { + return C.TRACK_TYPE_VIDEO; + } else if (hdlr == TYPE_text || hdlr == TYPE_sbtl || hdlr == TYPE_subt || hdlr == TYPE_clcp) { + return C.TRACK_TYPE_TEXT; + } else if (hdlr == TYPE_meta) { + return C.TRACK_TYPE_METADATA; + } else { + return C.TRACK_TYPE_UNKNOWN; + } + } + + /** + * Parses an mdhd atom (defined in 14496-12). + * + * @param mdhd The mdhd atom to decode. + * @return A pair consisting of the media timescale defined as the number of time units that pass + * in one second, and the language code. + */ + private static Pair<Long, String> parseMdhd(ParsableByteArray mdhd) { + mdhd.setPosition(Atom.HEADER_SIZE); + int fullAtom = mdhd.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + mdhd.skipBytes(version == 0 ? 8 : 16); + long timescale = mdhd.readUnsignedInt(); + mdhd.skipBytes(version == 0 ? 4 : 8); + int languageCode = mdhd.readUnsignedShort(); + String language = + "" + + (char) (((languageCode >> 10) & 0x1F) + 0x60) + + (char) (((languageCode >> 5) & 0x1F) + 0x60) + + (char) ((languageCode & 0x1F) + 0x60); + return Pair.create(timescale, language); + } + + /** + * Parses a stsd atom (defined in 14496-12). + * + * @param stsd The stsd atom to decode. + * @param trackId The track's identifier in its container. + * @param rotationDegrees The rotation of the track in degrees. + * @param language The language of the track. + * @param drmInitData {@link DrmInitData} to be included in the format. + * @param isQuickTime True for QuickTime media. False otherwise. + * @return An object containing the parsed data. + */ + private static StsdData parseStsd(ParsableByteArray stsd, int trackId, int rotationDegrees, + String language, DrmInitData drmInitData, boolean isQuickTime) throws ParserException { + stsd.setPosition(Atom.FULL_HEADER_SIZE); + int numberOfEntries = stsd.readInt(); + StsdData out = new StsdData(numberOfEntries); + for (int i = 0; i < numberOfEntries; i++) { + int childStartPosition = stsd.getPosition(); + int childAtomSize = stsd.readInt(); + Assertions.checkArgument(childAtomSize > 0, "childAtomSize should be positive"); + int childAtomType = stsd.readInt(); + if (childAtomType == Atom.TYPE_avc1 + || childAtomType == Atom.TYPE_avc3 + || childAtomType == Atom.TYPE_encv + || childAtomType == Atom.TYPE_mp4v + || childAtomType == Atom.TYPE_hvc1 + || childAtomType == Atom.TYPE_hev1 + || childAtomType == Atom.TYPE_s263 + || childAtomType == Atom.TYPE_vp08 + || childAtomType == Atom.TYPE_vp09 + || childAtomType == Atom.TYPE_av01 + || childAtomType == Atom.TYPE_dvav + || childAtomType == Atom.TYPE_dva1 + || childAtomType == Atom.TYPE_dvhe + || childAtomType == Atom.TYPE_dvh1) { + parseVideoSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize, trackId, + rotationDegrees, drmInitData, out, i); + } else if (childAtomType == Atom.TYPE_mp4a + || childAtomType == Atom.TYPE_enca + || childAtomType == Atom.TYPE_ac_3 + || childAtomType == Atom.TYPE_ec_3 + || childAtomType == Atom.TYPE_ac_4 + || childAtomType == Atom.TYPE_dtsc + || childAtomType == Atom.TYPE_dtse + || childAtomType == Atom.TYPE_dtsh + || childAtomType == Atom.TYPE_dtsl + || childAtomType == Atom.TYPE_samr + || childAtomType == Atom.TYPE_sawb + || childAtomType == Atom.TYPE_lpcm + || childAtomType == Atom.TYPE_sowt + || childAtomType == Atom.TYPE_twos + || childAtomType == Atom.TYPE__mp3 + || childAtomType == Atom.TYPE_alac + || childAtomType == Atom.TYPE_alaw + || childAtomType == Atom.TYPE_ulaw + || childAtomType == Atom.TYPE_Opus + || childAtomType == Atom.TYPE_fLaC) { + parseAudioSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize, trackId, + language, isQuickTime, drmInitData, out, i); + } else if (childAtomType == Atom.TYPE_TTML || childAtomType == Atom.TYPE_tx3g + || childAtomType == Atom.TYPE_wvtt || childAtomType == Atom.TYPE_stpp + || childAtomType == Atom.TYPE_c608) { + parseTextSampleEntry(stsd, childAtomType, childStartPosition, childAtomSize, trackId, + language, out); + } else if (childAtomType == Atom.TYPE_camm) { + out.format = Format.createSampleFormat(Integer.toString(trackId), + MimeTypes.APPLICATION_CAMERA_MOTION, null, Format.NO_VALUE, null); + } + stsd.setPosition(childStartPosition + childAtomSize); + } + return out; + } + + private static void parseTextSampleEntry(ParsableByteArray parent, int atomType, int position, + int atomSize, int trackId, String language, StsdData out) throws ParserException { + parent.setPosition(position + Atom.HEADER_SIZE + StsdData.STSD_HEADER_SIZE); + + // Default values. + List<byte[]> initializationData = null; + long subsampleOffsetUs = Format.OFFSET_SAMPLE_RELATIVE; + + String mimeType; + if (atomType == Atom.TYPE_TTML) { + mimeType = MimeTypes.APPLICATION_TTML; + } else if (atomType == Atom.TYPE_tx3g) { + mimeType = MimeTypes.APPLICATION_TX3G; + int sampleDescriptionLength = atomSize - Atom.HEADER_SIZE - 8; + byte[] sampleDescriptionData = new byte[sampleDescriptionLength]; + parent.readBytes(sampleDescriptionData, 0, sampleDescriptionLength); + initializationData = Collections.singletonList(sampleDescriptionData); + } else if (atomType == Atom.TYPE_wvtt) { + mimeType = MimeTypes.APPLICATION_MP4VTT; + } else if (atomType == Atom.TYPE_stpp) { + mimeType = MimeTypes.APPLICATION_TTML; + subsampleOffsetUs = 0; // Subsample timing is absolute. + } else if (atomType == Atom.TYPE_c608) { + // Defined by the QuickTime File Format specification. + mimeType = MimeTypes.APPLICATION_MP4CEA608; + out.requiredSampleTransformation = Track.TRANSFORMATION_CEA608_CDAT; + } else { + // Never happens. + throw new IllegalStateException(); + } + + out.format = + Format.createTextSampleFormat( + Integer.toString(trackId), + mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + language, + /* accessibilityChannel= */ Format.NO_VALUE, + /* drmInitData= */ null, + subsampleOffsetUs, + initializationData); + } + + private static void parseVideoSampleEntry(ParsableByteArray parent, int atomType, int position, + int size, int trackId, int rotationDegrees, DrmInitData drmInitData, StsdData out, + int entryIndex) throws ParserException { + parent.setPosition(position + Atom.HEADER_SIZE + StsdData.STSD_HEADER_SIZE); + + parent.skipBytes(16); + int width = parent.readUnsignedShort(); + int height = parent.readUnsignedShort(); + boolean pixelWidthHeightRatioFromPasp = false; + float pixelWidthHeightRatio = 1; + parent.skipBytes(50); + + int childPosition = parent.getPosition(); + if (atomType == Atom.TYPE_encv) { + Pair<Integer, TrackEncryptionBox> sampleEntryEncryptionData = parseSampleEntryEncryptionData( + parent, position, size); + if (sampleEntryEncryptionData != null) { + atomType = sampleEntryEncryptionData.first; + drmInitData = drmInitData == null ? null + : drmInitData.copyWithSchemeType(sampleEntryEncryptionData.second.schemeType); + out.trackEncryptionBoxes[entryIndex] = sampleEntryEncryptionData.second; + } + parent.setPosition(childPosition); + } + // TODO: Uncomment when [Internal: b/63092960] is fixed. + // else { + // drmInitData = null; + // } + + List<byte[]> initializationData = null; + String mimeType = null; + String codecs = null; + byte[] projectionData = null; + @C.StereoMode + int stereoMode = Format.NO_VALUE; + while (childPosition - position < size) { + parent.setPosition(childPosition); + int childStartPosition = parent.getPosition(); + int childAtomSize = parent.readInt(); + if (childAtomSize == 0 && parent.getPosition() - position == size) { + // Handle optional terminating four zero bytes in MOV files. + break; + } + Assertions.checkArgument(childAtomSize > 0, "childAtomSize should be positive"); + int childAtomType = parent.readInt(); + if (childAtomType == Atom.TYPE_avcC) { + Assertions.checkState(mimeType == null); + mimeType = MimeTypes.VIDEO_H264; + parent.setPosition(childStartPosition + Atom.HEADER_SIZE); + AvcConfig avcConfig = AvcConfig.parse(parent); + initializationData = avcConfig.initializationData; + out.nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength; + if (!pixelWidthHeightRatioFromPasp) { + pixelWidthHeightRatio = avcConfig.pixelWidthAspectRatio; + } + } else if (childAtomType == Atom.TYPE_hvcC) { + Assertions.checkState(mimeType == null); + mimeType = MimeTypes.VIDEO_H265; + parent.setPosition(childStartPosition + Atom.HEADER_SIZE); + HevcConfig hevcConfig = HevcConfig.parse(parent); + initializationData = hevcConfig.initializationData; + out.nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength; + } else if (childAtomType == Atom.TYPE_dvcC || childAtomType == Atom.TYPE_dvvC) { + DolbyVisionConfig dolbyVisionConfig = DolbyVisionConfig.parse(parent); + if (dolbyVisionConfig != null) { + codecs = dolbyVisionConfig.codecs; + mimeType = MimeTypes.VIDEO_DOLBY_VISION; + } + } else if (childAtomType == Atom.TYPE_vpcC) { + Assertions.checkState(mimeType == null); + mimeType = (atomType == Atom.TYPE_vp08) ? MimeTypes.VIDEO_VP8 : MimeTypes.VIDEO_VP9; + } else if (childAtomType == Atom.TYPE_av1C) { + Assertions.checkState(mimeType == null); + mimeType = MimeTypes.VIDEO_AV1; + } else if (childAtomType == Atom.TYPE_d263) { + Assertions.checkState(mimeType == null); + mimeType = MimeTypes.VIDEO_H263; + } else if (childAtomType == Atom.TYPE_esds) { + Assertions.checkState(mimeType == null); + Pair<String, byte[]> mimeTypeAndInitializationData = + parseEsdsFromParent(parent, childStartPosition); + mimeType = mimeTypeAndInitializationData.first; + initializationData = Collections.singletonList(mimeTypeAndInitializationData.second); + } else if (childAtomType == Atom.TYPE_pasp) { + pixelWidthHeightRatio = parsePaspFromParent(parent, childStartPosition); + pixelWidthHeightRatioFromPasp = true; + } else if (childAtomType == Atom.TYPE_sv3d) { + projectionData = parseProjFromParent(parent, childStartPosition, childAtomSize); + } else if (childAtomType == Atom.TYPE_st3d) { + int version = parent.readUnsignedByte(); + parent.skipBytes(3); // Flags. + if (version == 0) { + int layout = parent.readUnsignedByte(); + switch (layout) { + case 0: + stereoMode = C.STEREO_MODE_MONO; + break; + case 1: + stereoMode = C.STEREO_MODE_TOP_BOTTOM; + break; + case 2: + stereoMode = C.STEREO_MODE_LEFT_RIGHT; + break; + case 3: + stereoMode = C.STEREO_MODE_STEREO_MESH; + break; + default: + break; + } + } + } + childPosition += childAtomSize; + } + + // If the media type was not recognized, ignore the track. + if (mimeType == null) { + return; + } + + out.format = + Format.createVideoSampleFormat( + Integer.toString(trackId), + mimeType, + codecs, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + width, + height, + /* frameRate= */ Format.NO_VALUE, + initializationData, + rotationDegrees, + pixelWidthHeightRatio, + projectionData, + stereoMode, + /* colorInfo= */ null, + drmInitData); + } + + /** + * Parses the edts atom (defined in 14496-12 subsection 8.6.5). + * + * @param edtsAtom edts (edit box) atom to decode. + * @return Pair of edit list durations and edit list media times, or a pair of nulls if they are + * not present. + */ + private static Pair<long[], long[]> parseEdts(Atom.ContainerAtom edtsAtom) { + Atom.LeafAtom elst; + if (edtsAtom == null || (elst = edtsAtom.getLeafAtomOfType(Atom.TYPE_elst)) == null) { + return Pair.create(null, null); + } + ParsableByteArray elstData = elst.data; + elstData.setPosition(Atom.HEADER_SIZE); + int fullAtom = elstData.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + int entryCount = elstData.readUnsignedIntToInt(); + long[] editListDurations = new long[entryCount]; + long[] editListMediaTimes = new long[entryCount]; + for (int i = 0; i < entryCount; i++) { + editListDurations[i] = + version == 1 ? elstData.readUnsignedLongToLong() : elstData.readUnsignedInt(); + editListMediaTimes[i] = version == 1 ? elstData.readLong() : elstData.readInt(); + int mediaRateInteger = elstData.readShort(); + if (mediaRateInteger != 1) { + // The extractor does not handle dwell edits (mediaRateInteger == 0). + throw new IllegalArgumentException("Unsupported media rate."); + } + elstData.skipBytes(2); + } + return Pair.create(editListDurations, editListMediaTimes); + } + + private static float parsePaspFromParent(ParsableByteArray parent, int position) { + parent.setPosition(position + Atom.HEADER_SIZE); + int hSpacing = parent.readUnsignedIntToInt(); + int vSpacing = parent.readUnsignedIntToInt(); + return (float) hSpacing / vSpacing; + } + + private static void parseAudioSampleEntry(ParsableByteArray parent, int atomType, int position, + int size, int trackId, String language, boolean isQuickTime, DrmInitData drmInitData, + StsdData out, int entryIndex) throws ParserException { + parent.setPosition(position + Atom.HEADER_SIZE + StsdData.STSD_HEADER_SIZE); + + int quickTimeSoundDescriptionVersion = 0; + if (isQuickTime) { + quickTimeSoundDescriptionVersion = parent.readUnsignedShort(); + parent.skipBytes(6); + } else { + parent.skipBytes(8); + } + + int channelCount; + int sampleRate; + @C.PcmEncoding int pcmEncoding = Format.NO_VALUE; + + if (quickTimeSoundDescriptionVersion == 0 || quickTimeSoundDescriptionVersion == 1) { + channelCount = parent.readUnsignedShort(); + parent.skipBytes(6); // sampleSize, compressionId, packetSize. + sampleRate = parent.readUnsignedFixedPoint1616(); + + if (quickTimeSoundDescriptionVersion == 1) { + parent.skipBytes(16); + } + } else if (quickTimeSoundDescriptionVersion == 2) { + parent.skipBytes(16); // always[3,16,Minus2,0,65536], sizeOfStructOnly + + sampleRate = (int) Math.round(parent.readDouble()); + channelCount = parent.readUnsignedIntToInt(); + + // Skip always7F000000, sampleSize, formatSpecificFlags, constBytesPerAudioPacket, + // constLPCMFramesPerAudioPacket. + parent.skipBytes(20); + } else { + // Unsupported version. + return; + } + + int childPosition = parent.getPosition(); + if (atomType == Atom.TYPE_enca) { + Pair<Integer, TrackEncryptionBox> sampleEntryEncryptionData = parseSampleEntryEncryptionData( + parent, position, size); + if (sampleEntryEncryptionData != null) { + atomType = sampleEntryEncryptionData.first; + drmInitData = drmInitData == null ? null + : drmInitData.copyWithSchemeType(sampleEntryEncryptionData.second.schemeType); + out.trackEncryptionBoxes[entryIndex] = sampleEntryEncryptionData.second; + } + parent.setPosition(childPosition); + } + // TODO: Uncomment when [Internal: b/63092960] is fixed. + // else { + // drmInitData = null; + // } + + // If the atom type determines a MIME type, set it immediately. + String mimeType = null; + if (atomType == Atom.TYPE_ac_3) { + mimeType = MimeTypes.AUDIO_AC3; + } else if (atomType == Atom.TYPE_ec_3) { + mimeType = MimeTypes.AUDIO_E_AC3; + } else if (atomType == Atom.TYPE_ac_4) { + mimeType = MimeTypes.AUDIO_AC4; + } else if (atomType == Atom.TYPE_dtsc) { + mimeType = MimeTypes.AUDIO_DTS; + } else if (atomType == Atom.TYPE_dtsh || atomType == Atom.TYPE_dtsl) { + mimeType = MimeTypes.AUDIO_DTS_HD; + } else if (atomType == Atom.TYPE_dtse) { + mimeType = MimeTypes.AUDIO_DTS_EXPRESS; + } else if (atomType == Atom.TYPE_samr) { + mimeType = MimeTypes.AUDIO_AMR_NB; + } else if (atomType == Atom.TYPE_sawb) { + mimeType = MimeTypes.AUDIO_AMR_WB; + } else if (atomType == Atom.TYPE_lpcm || atomType == Atom.TYPE_sowt) { + mimeType = MimeTypes.AUDIO_RAW; + pcmEncoding = C.ENCODING_PCM_16BIT; + } else if (atomType == Atom.TYPE_twos) { + mimeType = MimeTypes.AUDIO_RAW; + pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN; + } else if (atomType == Atom.TYPE__mp3) { + mimeType = MimeTypes.AUDIO_MPEG; + } else if (atomType == Atom.TYPE_alac) { + mimeType = MimeTypes.AUDIO_ALAC; + } else if (atomType == Atom.TYPE_alaw) { + mimeType = MimeTypes.AUDIO_ALAW; + } else if (atomType == Atom.TYPE_ulaw) { + mimeType = MimeTypes.AUDIO_MLAW; + } else if (atomType == Atom.TYPE_Opus) { + mimeType = MimeTypes.AUDIO_OPUS; + } else if (atomType == Atom.TYPE_fLaC) { + mimeType = MimeTypes.AUDIO_FLAC; + } + + byte[] initializationData = null; + while (childPosition - position < size) { + parent.setPosition(childPosition); + int childAtomSize = parent.readInt(); + Assertions.checkArgument(childAtomSize > 0, "childAtomSize should be positive"); + int childAtomType = parent.readInt(); + if (childAtomType == Atom.TYPE_esds || (isQuickTime && childAtomType == Atom.TYPE_wave)) { + int esdsAtomPosition = childAtomType == Atom.TYPE_esds ? childPosition + : findEsdsPosition(parent, childPosition, childAtomSize); + if (esdsAtomPosition != C.POSITION_UNSET) { + Pair<String, byte[]> mimeTypeAndInitializationData = + parseEsdsFromParent(parent, esdsAtomPosition); + mimeType = mimeTypeAndInitializationData.first; + initializationData = mimeTypeAndInitializationData.second; + if (MimeTypes.AUDIO_AAC.equals(mimeType)) { + // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, + // which is more reliable. See [Internal: b/10903778]. + Pair<Integer, Integer> audioSpecificConfig = + CodecSpecificDataUtil.parseAacAudioSpecificConfig(initializationData); + sampleRate = audioSpecificConfig.first; + channelCount = audioSpecificConfig.second; + } + } + } else if (childAtomType == Atom.TYPE_dac3) { + parent.setPosition(Atom.HEADER_SIZE + childPosition); + out.format = Ac3Util.parseAc3AnnexFFormat(parent, Integer.toString(trackId), language, + drmInitData); + } else if (childAtomType == Atom.TYPE_dec3) { + parent.setPosition(Atom.HEADER_SIZE + childPosition); + out.format = Ac3Util.parseEAc3AnnexFFormat(parent, Integer.toString(trackId), language, + drmInitData); + } else if (childAtomType == Atom.TYPE_dac4) { + parent.setPosition(Atom.HEADER_SIZE + childPosition); + out.format = + Ac4Util.parseAc4AnnexEFormat(parent, Integer.toString(trackId), language, drmInitData); + } else if (childAtomType == Atom.TYPE_ddts) { + out.format = Format.createAudioSampleFormat(Integer.toString(trackId), mimeType, null, + Format.NO_VALUE, Format.NO_VALUE, channelCount, sampleRate, null, drmInitData, 0, + language); + } else if (childAtomType == Atom.TYPE_dOps) { + // Build an Opus Identification Header (defined in RFC-7845) by concatenating the Opus Magic + // Signature and the body of the dOps atom. + int childAtomBodySize = childAtomSize - Atom.HEADER_SIZE; + initializationData = new byte[opusMagic.length + childAtomBodySize]; + System.arraycopy(opusMagic, 0, initializationData, 0, opusMagic.length); + parent.setPosition(childPosition + Atom.HEADER_SIZE); + parent.readBytes(initializationData, opusMagic.length, childAtomBodySize); + } else if (childAtomType == Atom.TYPE_dfLa) { + int childAtomBodySize = childAtomSize - Atom.FULL_HEADER_SIZE; + initializationData = new byte[4 + childAtomBodySize]; + initializationData[0] = 0x66; // f + initializationData[1] = 0x4C; // L + initializationData[2] = 0x61; // a + initializationData[3] = 0x43; // C + parent.setPosition(childPosition + Atom.FULL_HEADER_SIZE); + parent.readBytes(initializationData, /* offset= */ 4, childAtomBodySize); + } else if (childAtomType == Atom.TYPE_alac) { + int childAtomBodySize = childAtomSize - Atom.FULL_HEADER_SIZE; + initializationData = new byte[childAtomBodySize]; + parent.setPosition(childPosition + Atom.FULL_HEADER_SIZE); + parent.readBytes(initializationData, /* offset= */ 0, childAtomBodySize); + // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, + // which is more reliable. See https://github.com/google/ExoPlayer/pull/6629. + Pair<Integer, Integer> audioSpecificConfig = + CodecSpecificDataUtil.parseAlacAudioSpecificConfig(initializationData); + sampleRate = audioSpecificConfig.first; + channelCount = audioSpecificConfig.second; + } + childPosition += childAtomSize; + } + + if (out.format == null && mimeType != null) { + out.format = Format.createAudioSampleFormat(Integer.toString(trackId), mimeType, null, + Format.NO_VALUE, Format.NO_VALUE, channelCount, sampleRate, pcmEncoding, + initializationData == null ? null : Collections.singletonList(initializationData), + drmInitData, 0, language); + } + } + + /** + * Returns the position of the esds box within a parent, or {@link C#POSITION_UNSET} if no esds + * box is found + */ + private static int findEsdsPosition(ParsableByteArray parent, int position, int size) { + int childAtomPosition = parent.getPosition(); + while (childAtomPosition - position < size) { + parent.setPosition(childAtomPosition); + int childAtomSize = parent.readInt(); + Assertions.checkArgument(childAtomSize > 0, "childAtomSize should be positive"); + int childType = parent.readInt(); + if (childType == Atom.TYPE_esds) { + return childAtomPosition; + } + childAtomPosition += childAtomSize; + } + return C.POSITION_UNSET; + } + + /** + * Returns codec-specific initialization data contained in an esds box. + */ + private static Pair<String, byte[]> parseEsdsFromParent(ParsableByteArray parent, int position) { + parent.setPosition(position + Atom.HEADER_SIZE + 4); + // Start of the ES_Descriptor (defined in 14496-1) + parent.skipBytes(1); // ES_Descriptor tag + parseExpandableClassSize(parent); + parent.skipBytes(2); // ES_ID + + int flags = parent.readUnsignedByte(); + if ((flags & 0x80 /* streamDependenceFlag */) != 0) { + parent.skipBytes(2); + } + if ((flags & 0x40 /* URL_Flag */) != 0) { + parent.skipBytes(parent.readUnsignedShort()); + } + if ((flags & 0x20 /* OCRstreamFlag */) != 0) { + parent.skipBytes(2); + } + + // Start of the DecoderConfigDescriptor (defined in 14496-1) + parent.skipBytes(1); // DecoderConfigDescriptor tag + parseExpandableClassSize(parent); + + // Set the MIME type based on the object type indication (14496-1 table 5). + int objectTypeIndication = parent.readUnsignedByte(); + String mimeType = getMimeTypeFromMp4ObjectType(objectTypeIndication); + if (MimeTypes.AUDIO_MPEG.equals(mimeType) + || MimeTypes.AUDIO_DTS.equals(mimeType) + || MimeTypes.AUDIO_DTS_HD.equals(mimeType)) { + return Pair.create(mimeType, null); + } + + parent.skipBytes(12); + + // Start of the DecoderSpecificInfo. + parent.skipBytes(1); // DecoderSpecificInfo tag + int initializationDataSize = parseExpandableClassSize(parent); + byte[] initializationData = new byte[initializationDataSize]; + parent.readBytes(initializationData, 0, initializationDataSize); + return Pair.create(mimeType, initializationData); + } + + /** + * Parses encryption data from an audio/video sample entry, returning a pair consisting of the + * unencrypted atom type and a {@link TrackEncryptionBox}. Null is returned if no common + * encryption sinf atom was present. + */ + private static Pair<Integer, TrackEncryptionBox> parseSampleEntryEncryptionData( + ParsableByteArray parent, int position, int size) { + int childPosition = parent.getPosition(); + while (childPosition - position < size) { + parent.setPosition(childPosition); + int childAtomSize = parent.readInt(); + Assertions.checkArgument(childAtomSize > 0, "childAtomSize should be positive"); + int childAtomType = parent.readInt(); + if (childAtomType == Atom.TYPE_sinf) { + Pair<Integer, TrackEncryptionBox> result = parseCommonEncryptionSinfFromParent(parent, + childPosition, childAtomSize); + if (result != null) { + return result; + } + } + childPosition += childAtomSize; + } + return null; + } + + /* package */ static Pair<Integer, TrackEncryptionBox> parseCommonEncryptionSinfFromParent( + ParsableByteArray parent, int position, int size) { + int childPosition = position + Atom.HEADER_SIZE; + int schemeInformationBoxPosition = C.POSITION_UNSET; + int schemeInformationBoxSize = 0; + String schemeType = null; + Integer dataFormat = null; + while (childPosition - position < size) { + parent.setPosition(childPosition); + int childAtomSize = parent.readInt(); + int childAtomType = parent.readInt(); + if (childAtomType == Atom.TYPE_frma) { + dataFormat = parent.readInt(); + } else if (childAtomType == Atom.TYPE_schm) { + parent.skipBytes(4); + // Common encryption scheme_type values are defined in ISO/IEC 23001-7:2016, section 4.1. + schemeType = parent.readString(4); + } else if (childAtomType == Atom.TYPE_schi) { + schemeInformationBoxPosition = childPosition; + schemeInformationBoxSize = childAtomSize; + } + childPosition += childAtomSize; + } + + if (C.CENC_TYPE_cenc.equals(schemeType) || C.CENC_TYPE_cbc1.equals(schemeType) + || C.CENC_TYPE_cens.equals(schemeType) || C.CENC_TYPE_cbcs.equals(schemeType)) { + Assertions.checkArgument(dataFormat != null, "frma atom is mandatory"); + Assertions.checkArgument(schemeInformationBoxPosition != C.POSITION_UNSET, + "schi atom is mandatory"); + TrackEncryptionBox encryptionBox = parseSchiFromParent(parent, schemeInformationBoxPosition, + schemeInformationBoxSize, schemeType); + Assertions.checkArgument(encryptionBox != null, "tenc atom is mandatory"); + return Pair.create(dataFormat, encryptionBox); + } else { + return null; + } + } + + private static TrackEncryptionBox parseSchiFromParent(ParsableByteArray parent, int position, + int size, String schemeType) { + int childPosition = position + Atom.HEADER_SIZE; + while (childPosition - position < size) { + parent.setPosition(childPosition); + int childAtomSize = parent.readInt(); + int childAtomType = parent.readInt(); + if (childAtomType == Atom.TYPE_tenc) { + int fullAtom = parent.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + parent.skipBytes(1); // reserved = 0. + int defaultCryptByteBlock = 0; + int defaultSkipByteBlock = 0; + if (version == 0) { + parent.skipBytes(1); // reserved = 0. + } else /* version 1 or greater */ { + int patternByte = parent.readUnsignedByte(); + defaultCryptByteBlock = (patternByte & 0xF0) >> 4; + defaultSkipByteBlock = patternByte & 0x0F; + } + boolean defaultIsProtected = parent.readUnsignedByte() == 1; + int defaultPerSampleIvSize = parent.readUnsignedByte(); + byte[] defaultKeyId = new byte[16]; + parent.readBytes(defaultKeyId, 0, defaultKeyId.length); + byte[] constantIv = null; + if (defaultIsProtected && defaultPerSampleIvSize == 0) { + int constantIvSize = parent.readUnsignedByte(); + constantIv = new byte[constantIvSize]; + parent.readBytes(constantIv, 0, constantIvSize); + } + return new TrackEncryptionBox(defaultIsProtected, schemeType, defaultPerSampleIvSize, + defaultKeyId, defaultCryptByteBlock, defaultSkipByteBlock, constantIv); + } + childPosition += childAtomSize; + } + return null; + } + + /** + * Parses the proj box from sv3d box, as specified by https://github.com/google/spatial-media. + */ + private static byte[] parseProjFromParent(ParsableByteArray parent, int position, int size) { + int childPosition = position + Atom.HEADER_SIZE; + while (childPosition - position < size) { + parent.setPosition(childPosition); + int childAtomSize = parent.readInt(); + int childAtomType = parent.readInt(); + if (childAtomType == Atom.TYPE_proj) { + return Arrays.copyOfRange(parent.data, childPosition, childPosition + childAtomSize); + } + childPosition += childAtomSize; + } + return null; + } + + /** + * Parses the size of an expandable class, as specified by ISO 14496-1 subsection 8.3.3. + */ + private static int parseExpandableClassSize(ParsableByteArray data) { + int currentByte = data.readUnsignedByte(); + int size = currentByte & 0x7F; + while ((currentByte & 0x80) == 0x80) { + currentByte = data.readUnsignedByte(); + size = (size << 7) | (currentByte & 0x7F); + } + return size; + } + + /** Returns whether it's possible to apply the specified edit using gapless playback info. */ + private static boolean canApplyEditWithGaplessInfo( + long[] timestamps, long duration, long editStartTime, long editEndTime) { + int lastIndex = timestamps.length - 1; + int latestDelayIndex = Util.constrainValue(MAX_GAPLESS_TRIM_SIZE_SAMPLES, 0, lastIndex); + int earliestPaddingIndex = + Util.constrainValue(timestamps.length - MAX_GAPLESS_TRIM_SIZE_SAMPLES, 0, lastIndex); + return timestamps[0] <= editStartTime + && editStartTime < timestamps[latestDelayIndex] + && timestamps[earliestPaddingIndex] < editEndTime + && editEndTime <= duration; + } + + private AtomParsers() { + // Prevent instantiation. + } + + private static final class ChunkIterator { + + public final int length; + + public int index; + public int numSamples; + public long offset; + + private final boolean chunkOffsetsAreLongs; + private final ParsableByteArray chunkOffsets; + private final ParsableByteArray stsc; + + private int nextSamplesPerChunkChangeIndex; + private int remainingSamplesPerChunkChanges; + + public ChunkIterator(ParsableByteArray stsc, ParsableByteArray chunkOffsets, + boolean chunkOffsetsAreLongs) { + this.stsc = stsc; + this.chunkOffsets = chunkOffsets; + this.chunkOffsetsAreLongs = chunkOffsetsAreLongs; + chunkOffsets.setPosition(Atom.FULL_HEADER_SIZE); + length = chunkOffsets.readUnsignedIntToInt(); + stsc.setPosition(Atom.FULL_HEADER_SIZE); + remainingSamplesPerChunkChanges = stsc.readUnsignedIntToInt(); + Assertions.checkState(stsc.readInt() == 1, "first_chunk must be 1"); + index = -1; + } + + public boolean moveNext() { + if (++index == length) { + return false; + } + offset = chunkOffsetsAreLongs ? chunkOffsets.readUnsignedLongToLong() + : chunkOffsets.readUnsignedInt(); + if (index == nextSamplesPerChunkChangeIndex) { + numSamples = stsc.readUnsignedIntToInt(); + stsc.skipBytes(4); // Skip sample_description_index + nextSamplesPerChunkChangeIndex = --remainingSamplesPerChunkChanges > 0 + ? (stsc.readUnsignedIntToInt() - 1) : C.INDEX_UNSET; + } + return true; + } + + } + + /** + * Holds data parsed from a tkhd atom. + */ + private static final class TkhdData { + + private final int id; + private final long duration; + private final int rotationDegrees; + + public TkhdData(int id, long duration, int rotationDegrees) { + this.id = id; + this.duration = duration; + this.rotationDegrees = rotationDegrees; + } + + } + + /** + * Holds data parsed from an stsd atom and its children. + */ + private static final class StsdData { + + public static final int STSD_HEADER_SIZE = 8; + + public final TrackEncryptionBox[] trackEncryptionBoxes; + + public Format format; + public int nalUnitLengthFieldLength; + @Track.Transformation + public int requiredSampleTransformation; + + public StsdData(int numberOfEntries) { + trackEncryptionBoxes = new TrackEncryptionBox[numberOfEntries]; + requiredSampleTransformation = Track.TRANSFORMATION_NONE; + } + + } + + /** + * A box containing sample sizes (e.g. stsz, stz2). + */ + private interface SampleSizeBox { + + /** + * Returns the number of samples. + */ + int getSampleCount(); + + /** + * Returns the size for the next sample. + */ + int readNextSampleSize(); + + /** + * Returns whether samples have a fixed size. + */ + boolean isFixedSampleSize(); + + } + + /** + * An stsz sample size box. + */ + /* package */ static final class StszSampleSizeBox implements SampleSizeBox { + + private final int fixedSampleSize; + private final int sampleCount; + private final ParsableByteArray data; + + public StszSampleSizeBox(Atom.LeafAtom stszAtom) { + data = stszAtom.data; + data.setPosition(Atom.FULL_HEADER_SIZE); + fixedSampleSize = data.readUnsignedIntToInt(); + sampleCount = data.readUnsignedIntToInt(); + } + + @Override + public int getSampleCount() { + return sampleCount; + } + + @Override + public int readNextSampleSize() { + return fixedSampleSize == 0 ? data.readUnsignedIntToInt() : fixedSampleSize; + } + + @Override + public boolean isFixedSampleSize() { + return fixedSampleSize != 0; + } + + } + + /** + * An stz2 sample size box. + */ + /* package */ static final class Stz2SampleSizeBox implements SampleSizeBox { + + private final ParsableByteArray data; + private final int sampleCount; + private final int fieldSize; // Can be 4, 8, or 16. + + // Used only if fieldSize == 4. + private int sampleIndex; + private int currentByte; + + public Stz2SampleSizeBox(Atom.LeafAtom stz2Atom) { + data = stz2Atom.data; + data.setPosition(Atom.FULL_HEADER_SIZE); + fieldSize = data.readUnsignedIntToInt() & 0x000000FF; + sampleCount = data.readUnsignedIntToInt(); + } + + @Override + public int getSampleCount() { + return sampleCount; + } + + @Override + public int readNextSampleSize() { + if (fieldSize == 8) { + return data.readUnsignedByte(); + } else if (fieldSize == 16) { + return data.readUnsignedShort(); + } else { + // fieldSize == 4. + if ((sampleIndex++ % 2) == 0) { + // Read the next byte into our cached byte when we are reading the upper bits. + currentByte = data.readUnsignedByte(); + // Read the upper bits from the byte and shift them to the lower 4 bits. + return (currentByte & 0xF0) >> 4; + } else { + // Mask out the upper 4 bits of the last byte we read. + return currentByte & 0x0F; + } + } + } + + @Override + public boolean isFixedSampleSize() { + return false; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/DefaultSampleValues.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/DefaultSampleValues.java new file mode 100644 index 0000000000..0942673435 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/DefaultSampleValues.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +/* package */ final class DefaultSampleValues { + + public final int sampleDescriptionIndex; + public final int duration; + public final int size; + public final int flags; + + public DefaultSampleValues(int sampleDescriptionIndex, int duration, int size, int flags) { + this.sampleDescriptionIndex = sampleDescriptionIndex; + this.duration = duration; + this.size = size; + this.flags = flags; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java new file mode 100644 index 0000000000..78d30ba582 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/FixedSampleSizeRechunker.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Rechunks fixed sample size media in which every sample is a key frame (e.g. uncompressed audio). + */ +/* package */ final class FixedSampleSizeRechunker { + + /** + * The result of a rechunking operation. + */ + public static final class Results { + + public final long[] offsets; + public final int[] sizes; + public final int maximumSize; + public final long[] timestamps; + public final int[] flags; + public final long duration; + + private Results( + long[] offsets, + int[] sizes, + int maximumSize, + long[] timestamps, + int[] flags, + long duration) { + this.offsets = offsets; + this.sizes = sizes; + this.maximumSize = maximumSize; + this.timestamps = timestamps; + this.flags = flags; + this.duration = duration; + } + + } + + /** + * Maximum number of bytes for each buffer in rechunked output. + */ + private static final int MAX_SAMPLE_SIZE = 8 * 1024; + + /** + * Rechunk the given fixed sample size input to produce a new sequence of samples. + * + * @param fixedSampleSize Size in bytes of each sample. + * @param chunkOffsets Chunk offsets in the MP4 stream to rechunk. + * @param chunkSampleCounts Sample counts for each of the MP4 stream's chunks. + * @param timestampDeltaInTimeUnits Timestamp delta between each sample in time units. + */ + public static Results rechunk(int fixedSampleSize, long[] chunkOffsets, int[] chunkSampleCounts, + long timestampDeltaInTimeUnits) { + int maxSampleCount = MAX_SAMPLE_SIZE / fixedSampleSize; + + // Count the number of new, rechunked buffers. + int rechunkedSampleCount = 0; + for (int chunkSampleCount : chunkSampleCounts) { + rechunkedSampleCount += Util.ceilDivide(chunkSampleCount, maxSampleCount); + } + + long[] offsets = new long[rechunkedSampleCount]; + int[] sizes = new int[rechunkedSampleCount]; + int maximumSize = 0; + long[] timestamps = new long[rechunkedSampleCount]; + int[] flags = new int[rechunkedSampleCount]; + + int originalSampleIndex = 0; + int newSampleIndex = 0; + for (int chunkIndex = 0; chunkIndex < chunkSampleCounts.length; chunkIndex++) { + int chunkSamplesRemaining = chunkSampleCounts[chunkIndex]; + long sampleOffset = chunkOffsets[chunkIndex]; + + while (chunkSamplesRemaining > 0) { + int bufferSampleCount = Math.min(maxSampleCount, chunkSamplesRemaining); + + offsets[newSampleIndex] = sampleOffset; + sizes[newSampleIndex] = fixedSampleSize * bufferSampleCount; + maximumSize = Math.max(maximumSize, sizes[newSampleIndex]); + timestamps[newSampleIndex] = (timestampDeltaInTimeUnits * originalSampleIndex); + flags[newSampleIndex] = C.BUFFER_FLAG_KEY_FRAME; + + sampleOffset += sizes[newSampleIndex]; + originalSampleIndex += bufferSampleCount; + + chunkSamplesRemaining -= bufferSampleCount; + newSampleIndex++; + } + } + long duration = timestampDeltaInTimeUnits * originalSampleIndex; + + return new Results(offsets, sizes, maximumSize, timestamps, flags, duration); + } + + private FixedSampleSizeRechunker() { + // Prevent instantiation. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java new file mode 100644 index 0000000000..291a9ade27 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/FragmentedMp4Extractor.java @@ -0,0 +1,1660 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import android.util.Pair; +import android.util.SparseArray; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ChunkIndex; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.Atom.LeafAtom; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessage; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessageEncoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.CeaUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** Extracts data from the FMP4 container format. */ +@SuppressWarnings("ConstantField") +public class FragmentedMp4Extractor implements Extractor { + + /** Factory for {@link FragmentedMp4Extractor} instances. */ + public static final ExtractorsFactory FACTORY = + () -> new Extractor[] {new FragmentedMp4Extractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag values are {@link + * #FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME}, {@link #FLAG_WORKAROUND_IGNORE_TFDT_BOX}, + * {@link #FLAG_ENABLE_EMSG_TRACK}, {@link #FLAG_SIDELOADED} and {@link + * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME, + FLAG_WORKAROUND_IGNORE_TFDT_BOX, + FLAG_ENABLE_EMSG_TRACK, + FLAG_SIDELOADED, + FLAG_WORKAROUND_IGNORE_EDIT_LISTS + }) + public @interface Flags {} + /** + * Flag to work around an issue in some video streams where every frame is marked as a sync frame. + * The workaround overrides the sync frame flags in the stream, forcing them to false except for + * the first sample in each segment. + * <p> + * This flag does nothing if the stream is not a video stream. + */ + public static final int FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME = 1; + /** Flag to ignore any tfdt boxes in the stream. */ + public static final int FLAG_WORKAROUND_IGNORE_TFDT_BOX = 1 << 1; // 2 + /** + * Flag to indicate that the extractor should output an event message metadata track. Any event + * messages in the stream will be delivered as samples to this track. + */ + public static final int FLAG_ENABLE_EMSG_TRACK = 1 << 2; // 4 + /** + * Flag to indicate that the {@link Track} was sideloaded, instead of being declared by the MP4 + * container. + */ + private static final int FLAG_SIDELOADED = 1 << 3; // 8 + /** Flag to ignore any edit lists in the stream. */ + public static final int FLAG_WORKAROUND_IGNORE_EDIT_LISTS = 1 << 4; // 16 + + private static final String TAG = "FragmentedMp4Extractor"; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int SAMPLE_GROUP_TYPE_seig = 0x73656967; + + private static final byte[] PIFF_SAMPLE_ENCRYPTION_BOX_EXTENDED_TYPE = + new byte[] {-94, 57, 79, 82, 90, -101, 79, 20, -94, 68, 108, 66, 124, 100, -115, -12}; + private static final Format EMSG_FORMAT = + Format.createSampleFormat(null, MimeTypes.APPLICATION_EMSG, Format.OFFSET_SAMPLE_RELATIVE); + + // Parser states. + private static final int STATE_READING_ATOM_HEADER = 0; + private static final int STATE_READING_ATOM_PAYLOAD = 1; + private static final int STATE_READING_ENCRYPTION_DATA = 2; + private static final int STATE_READING_SAMPLE_START = 3; + private static final int STATE_READING_SAMPLE_CONTINUE = 4; + + // Workarounds. + @Flags private final int flags; + @Nullable private final Track sideloadedTrack; + + // Sideloaded data. + private final List<Format> closedCaptionFormats; + + // Track-linked data bundle, accessible as a whole through trackID. + private final SparseArray<TrackBundle> trackBundles; + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalPrefix; + private final ParsableByteArray nalBuffer; + private final byte[] scratchBytes; + private final ParsableByteArray scratch; + + // Adjusts sample timestamps. + @Nullable private final TimestampAdjuster timestampAdjuster; + + private final EventMessageEncoder eventMessageEncoder; + + // Parser state. + private final ParsableByteArray atomHeader; + private final ArrayDeque<ContainerAtom> containerAtoms; + private final ArrayDeque<MetadataSampleInfo> pendingMetadataSampleInfos; + @Nullable private final TrackOutput additionalEmsgTrackOutput; + + private int parserState; + private int atomType; + private long atomSize; + private int atomHeaderBytesRead; + private ParsableByteArray atomData; + private long endOfMdatPosition; + private int pendingMetadataSampleBytes; + private long pendingSeekTimeUs; + + private long durationUs; + private long segmentIndexEarliestPresentationTimeUs; + private TrackBundle currentTrackBundle; + private int sampleSize; + private int sampleBytesWritten; + private int sampleCurrentNalBytesRemaining; + private boolean processSeiNalUnitPayload; + + // Extractor output. + private ExtractorOutput extractorOutput; + private TrackOutput[] emsgTrackOutputs; + private TrackOutput[] cea608TrackOutputs; + + // Whether extractorOutput.seekMap has been called. + private boolean haveOutputSeekMap; + + public FragmentedMp4Extractor() { + this(0); + } + + /** + * @param flags Flags that control the extractor's behavior. + */ + public FragmentedMp4Extractor(@Flags int flags) { + this(flags, /* timestampAdjuster= */ null); + } + + /** + * @param flags Flags that control the extractor's behavior. + * @param timestampAdjuster Adjusts sample timestamps. May be null if no adjustment is needed. + */ + public FragmentedMp4Extractor(@Flags int flags, @Nullable TimestampAdjuster timestampAdjuster) { + this(flags, timestampAdjuster, /* sideloadedTrack= */ null, Collections.emptyList()); + } + + /** + * @param flags Flags that control the extractor's behavior. + * @param timestampAdjuster Adjusts sample timestamps. May be null if no adjustment is needed. + * @param sideloadedTrack Sideloaded track information, in the case that the extractor will not + * receive a moov box in the input data. Null if a moov box is expected. + */ + public FragmentedMp4Extractor( + @Flags int flags, + @Nullable TimestampAdjuster timestampAdjuster, + @Nullable Track sideloadedTrack) { + this(flags, timestampAdjuster, sideloadedTrack, Collections.emptyList()); + } + + /** + * @param flags Flags that control the extractor's behavior. + * @param timestampAdjuster Adjusts sample timestamps. May be null if no adjustment is needed. + * @param sideloadedTrack Sideloaded track information, in the case that the extractor will not + * receive a moov box in the input data. Null if a moov box is expected. + * @param closedCaptionFormats For tracks that contain SEI messages, the formats of the closed + * caption channels to expose. + */ + public FragmentedMp4Extractor( + @Flags int flags, + @Nullable TimestampAdjuster timestampAdjuster, + @Nullable Track sideloadedTrack, + List<Format> closedCaptionFormats) { + this( + flags, + timestampAdjuster, + sideloadedTrack, + closedCaptionFormats, + /* additionalEmsgTrackOutput= */ null); + } + + /** + * @param flags Flags that control the extractor's behavior. + * @param timestampAdjuster Adjusts sample timestamps. May be null if no adjustment is needed. + * @param sideloadedTrack Sideloaded track information, in the case that the extractor will not + * receive a moov box in the input data. Null if a moov box is expected. + * @param closedCaptionFormats For tracks that contain SEI messages, the formats of the closed + * caption channels to expose. + * @param additionalEmsgTrackOutput An extra track output that will receive all emsg messages + * targeting the player, even if {@link #FLAG_ENABLE_EMSG_TRACK} is not set. Null if special + * handling of emsg messages for players is not required. + */ + public FragmentedMp4Extractor( + @Flags int flags, + @Nullable TimestampAdjuster timestampAdjuster, + @Nullable Track sideloadedTrack, + List<Format> closedCaptionFormats, + @Nullable TrackOutput additionalEmsgTrackOutput) { + this.flags = flags | (sideloadedTrack != null ? FLAG_SIDELOADED : 0); + this.timestampAdjuster = timestampAdjuster; + this.sideloadedTrack = sideloadedTrack; + this.closedCaptionFormats = Collections.unmodifiableList(closedCaptionFormats); + this.additionalEmsgTrackOutput = additionalEmsgTrackOutput; + eventMessageEncoder = new EventMessageEncoder(); + atomHeader = new ParsableByteArray(Atom.LONG_HEADER_SIZE); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalPrefix = new ParsableByteArray(5); + nalBuffer = new ParsableByteArray(); + scratchBytes = new byte[16]; + scratch = new ParsableByteArray(scratchBytes); + containerAtoms = new ArrayDeque<>(); + pendingMetadataSampleInfos = new ArrayDeque<>(); + trackBundles = new SparseArray<>(); + durationUs = C.TIME_UNSET; + pendingSeekTimeUs = C.TIME_UNSET; + segmentIndexEarliestPresentationTimeUs = C.TIME_UNSET; + enterReadingAtomHeaderState(); + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return Sniffer.sniffFragmented(input); + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + if (sideloadedTrack != null) { + TrackBundle bundle = new TrackBundle(output.track(0, sideloadedTrack.type)); + bundle.init(sideloadedTrack, new DefaultSampleValues(0, 0, 0, 0)); + trackBundles.put(0, bundle); + maybeInitExtraTracks(); + extractorOutput.endTracks(); + } + } + + @Override + public void seek(long position, long timeUs) { + int trackCount = trackBundles.size(); + for (int i = 0; i < trackCount; i++) { + trackBundles.valueAt(i).reset(); + } + pendingMetadataSampleInfos.clear(); + pendingMetadataSampleBytes = 0; + pendingSeekTimeUs = timeUs; + containerAtoms.clear(); + enterReadingAtomHeaderState(); + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + while (true) { + switch (parserState) { + case STATE_READING_ATOM_HEADER: + if (!readAtomHeader(input)) { + return Extractor.RESULT_END_OF_INPUT; + } + break; + case STATE_READING_ATOM_PAYLOAD: + readAtomPayload(input); + break; + case STATE_READING_ENCRYPTION_DATA: + readEncryptionData(input); + break; + default: + if (readSample(input)) { + return RESULT_CONTINUE; + } + } + } + } + + private void enterReadingAtomHeaderState() { + parserState = STATE_READING_ATOM_HEADER; + atomHeaderBytesRead = 0; + } + + private boolean readAtomHeader(ExtractorInput input) throws IOException, InterruptedException { + if (atomHeaderBytesRead == 0) { + // Read the standard length atom header. + if (!input.readFully(atomHeader.data, 0, Atom.HEADER_SIZE, true)) { + return false; + } + atomHeaderBytesRead = Atom.HEADER_SIZE; + atomHeader.setPosition(0); + atomSize = atomHeader.readUnsignedInt(); + atomType = atomHeader.readInt(); + } + + if (atomSize == Atom.DEFINES_LARGE_SIZE) { + // Read the large size. + int headerBytesRemaining = Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE; + input.readFully(atomHeader.data, Atom.HEADER_SIZE, headerBytesRemaining); + atomHeaderBytesRead += headerBytesRemaining; + atomSize = atomHeader.readUnsignedLongToLong(); + } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { + // The atom extends to the end of the file. Note that if the atom is within a container we can + // work out its size even if the input length is unknown. + long endPosition = input.getLength(); + if (endPosition == C.LENGTH_UNSET && !containerAtoms.isEmpty()) { + endPosition = containerAtoms.peek().endPosition; + } + if (endPosition != C.LENGTH_UNSET) { + atomSize = endPosition - input.getPosition() + atomHeaderBytesRead; + } + } + + if (atomSize < atomHeaderBytesRead) { + throw new ParserException("Atom size less than header length (unsupported)."); + } + + long atomPosition = input.getPosition() - atomHeaderBytesRead; + if (atomType == Atom.TYPE_moof) { + // The data positions may be updated when parsing the tfhd/trun. + int trackCount = trackBundles.size(); + for (int i = 0; i < trackCount; i++) { + TrackFragment fragment = trackBundles.valueAt(i).fragment; + fragment.atomPosition = atomPosition; + fragment.auxiliaryDataPosition = atomPosition; + fragment.dataPosition = atomPosition; + } + } + + if (atomType == Atom.TYPE_mdat) { + currentTrackBundle = null; + endOfMdatPosition = atomPosition + atomSize; + if (!haveOutputSeekMap) { + // This must be the first mdat in the stream. + extractorOutput.seekMap(new SeekMap.Unseekable(durationUs, atomPosition)); + haveOutputSeekMap = true; + } + parserState = STATE_READING_ENCRYPTION_DATA; + return true; + } + + if (shouldParseContainerAtom(atomType)) { + long endPosition = input.getPosition() + atomSize - Atom.HEADER_SIZE; + containerAtoms.push(new ContainerAtom(atomType, endPosition)); + if (atomSize == atomHeaderBytesRead) { + processAtomEnded(endPosition); + } else { + // Start reading the first child atom. + enterReadingAtomHeaderState(); + } + } else if (shouldParseLeafAtom(atomType)) { + if (atomHeaderBytesRead != Atom.HEADER_SIZE) { + throw new ParserException("Leaf atom defines extended atom size (unsupported)."); + } + if (atomSize > Integer.MAX_VALUE) { + throw new ParserException("Leaf atom with length > 2147483647 (unsupported)."); + } + atomData = new ParsableByteArray((int) atomSize); + System.arraycopy(atomHeader.data, 0, atomData.data, 0, Atom.HEADER_SIZE); + parserState = STATE_READING_ATOM_PAYLOAD; + } else { + if (atomSize > Integer.MAX_VALUE) { + throw new ParserException("Skipping atom with length > 2147483647 (unsupported)."); + } + atomData = null; + parserState = STATE_READING_ATOM_PAYLOAD; + } + + return true; + } + + private void readAtomPayload(ExtractorInput input) throws IOException, InterruptedException { + int atomPayloadSize = (int) atomSize - atomHeaderBytesRead; + if (atomData != null) { + input.readFully(atomData.data, Atom.HEADER_SIZE, atomPayloadSize); + onLeafAtomRead(new LeafAtom(atomType, atomData), input.getPosition()); + } else { + input.skipFully(atomPayloadSize); + } + processAtomEnded(input.getPosition()); + } + + private void processAtomEnded(long atomEndPosition) throws ParserException { + while (!containerAtoms.isEmpty() && containerAtoms.peek().endPosition == atomEndPosition) { + onContainerAtomRead(containerAtoms.pop()); + } + enterReadingAtomHeaderState(); + } + + private void onLeafAtomRead(LeafAtom leaf, long inputPosition) throws ParserException { + if (!containerAtoms.isEmpty()) { + containerAtoms.peek().add(leaf); + } else if (leaf.type == Atom.TYPE_sidx) { + Pair<Long, ChunkIndex> result = parseSidx(leaf.data, inputPosition); + segmentIndexEarliestPresentationTimeUs = result.first; + extractorOutput.seekMap(result.second); + haveOutputSeekMap = true; + } else if (leaf.type == Atom.TYPE_emsg) { + onEmsgLeafAtomRead(leaf.data); + } + } + + private void onContainerAtomRead(ContainerAtom container) throws ParserException { + if (container.type == Atom.TYPE_moov) { + onMoovContainerAtomRead(container); + } else if (container.type == Atom.TYPE_moof) { + onMoofContainerAtomRead(container); + } else if (!containerAtoms.isEmpty()) { + containerAtoms.peek().add(container); + } + } + + private void onMoovContainerAtomRead(ContainerAtom moov) throws ParserException { + Assertions.checkState(sideloadedTrack == null, "Unexpected moov box."); + + @Nullable DrmInitData drmInitData = getDrmInitDataFromAtoms(moov.leafChildren); + + // Read declaration of track fragments in the Moov box. + ContainerAtom mvex = moov.getContainerAtomOfType(Atom.TYPE_mvex); + SparseArray<DefaultSampleValues> defaultSampleValuesArray = new SparseArray<>(); + long duration = C.TIME_UNSET; + int mvexChildrenSize = mvex.leafChildren.size(); + for (int i = 0; i < mvexChildrenSize; i++) { + Atom.LeafAtom atom = mvex.leafChildren.get(i); + if (atom.type == Atom.TYPE_trex) { + Pair<Integer, DefaultSampleValues> trexData = parseTrex(atom.data); + defaultSampleValuesArray.put(trexData.first, trexData.second); + } else if (atom.type == Atom.TYPE_mehd) { + duration = parseMehd(atom.data); + } + } + + // Construction of tracks. + SparseArray<Track> tracks = new SparseArray<>(); + int moovContainerChildrenSize = moov.containerChildren.size(); + for (int i = 0; i < moovContainerChildrenSize; i++) { + Atom.ContainerAtom atom = moov.containerChildren.get(i); + if (atom.type == Atom.TYPE_trak) { + Track track = + modifyTrack( + AtomParsers.parseTrak( + atom, + moov.getLeafAtomOfType(Atom.TYPE_mvhd), + duration, + drmInitData, + (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0, + false)); + if (track != null) { + tracks.put(track.id, track); + } + } + } + + int trackCount = tracks.size(); + if (trackBundles.size() == 0) { + // We need to create the track bundles. + for (int i = 0; i < trackCount; i++) { + Track track = tracks.valueAt(i); + TrackBundle trackBundle = new TrackBundle(extractorOutput.track(i, track.type)); + trackBundle.init(track, getDefaultSampleValues(defaultSampleValuesArray, track.id)); + trackBundles.put(track.id, trackBundle); + durationUs = Math.max(durationUs, track.durationUs); + } + maybeInitExtraTracks(); + extractorOutput.endTracks(); + } else { + Assertions.checkState(trackBundles.size() == trackCount); + for (int i = 0; i < trackCount; i++) { + Track track = tracks.valueAt(i); + trackBundles + .get(track.id) + .init(track, getDefaultSampleValues(defaultSampleValuesArray, track.id)); + } + } + } + + @Nullable + protected Track modifyTrack(@Nullable Track track) { + return track; + } + + private DefaultSampleValues getDefaultSampleValues( + SparseArray<DefaultSampleValues> defaultSampleValuesArray, int trackId) { + if (defaultSampleValuesArray.size() == 1) { + // Ignore track id if there is only one track to cope with non-matching track indices. + // See https://github.com/google/ExoPlayer/issues/4477. + return defaultSampleValuesArray.valueAt(/* index= */ 0); + } + return Assertions.checkNotNull(defaultSampleValuesArray.get(trackId)); + } + + private void onMoofContainerAtomRead(ContainerAtom moof) throws ParserException { + parseMoof(moof, trackBundles, flags, scratchBytes); + + @Nullable DrmInitData drmInitData = getDrmInitDataFromAtoms(moof.leafChildren); + if (drmInitData != null) { + int trackCount = trackBundles.size(); + for (int i = 0; i < trackCount; i++) { + trackBundles.valueAt(i).updateDrmInitData(drmInitData); + } + } + // If we have a pending seek, advance tracks to their preceding sync frames. + if (pendingSeekTimeUs != C.TIME_UNSET) { + int trackCount = trackBundles.size(); + for (int i = 0; i < trackCount; i++) { + trackBundles.valueAt(i).seek(pendingSeekTimeUs); + } + pendingSeekTimeUs = C.TIME_UNSET; + } + } + + private void maybeInitExtraTracks() { + if (emsgTrackOutputs == null) { + emsgTrackOutputs = new TrackOutput[2]; + int emsgTrackOutputCount = 0; + if (additionalEmsgTrackOutput != null) { + emsgTrackOutputs[emsgTrackOutputCount++] = additionalEmsgTrackOutput; + } + if ((flags & FLAG_ENABLE_EMSG_TRACK) != 0) { + emsgTrackOutputs[emsgTrackOutputCount++] = + extractorOutput.track(trackBundles.size(), C.TRACK_TYPE_METADATA); + } + emsgTrackOutputs = Arrays.copyOf(emsgTrackOutputs, emsgTrackOutputCount); + + for (TrackOutput eventMessageTrackOutput : emsgTrackOutputs) { + eventMessageTrackOutput.format(EMSG_FORMAT); + } + } + if (cea608TrackOutputs == null) { + cea608TrackOutputs = new TrackOutput[closedCaptionFormats.size()]; + for (int i = 0; i < cea608TrackOutputs.length; i++) { + TrackOutput output = extractorOutput.track(trackBundles.size() + 1 + i, C.TRACK_TYPE_TEXT); + output.format(closedCaptionFormats.get(i)); + cea608TrackOutputs[i] = output; + } + } + } + + /** Handles an emsg atom (defined in 23009-1). */ + private void onEmsgLeafAtomRead(ParsableByteArray atom) { + if (emsgTrackOutputs == null || emsgTrackOutputs.length == 0) { + return; + } + atom.setPosition(Atom.HEADER_SIZE); + int fullAtom = atom.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + String schemeIdUri; + String value; + long timescale; + long presentationTimeDeltaUs = C.TIME_UNSET; // Only set if version == 0 + long sampleTimeUs = C.TIME_UNSET; + long durationMs; + long id; + switch (version) { + case 0: + schemeIdUri = Assertions.checkNotNull(atom.readNullTerminatedString()); + value = Assertions.checkNotNull(atom.readNullTerminatedString()); + timescale = atom.readUnsignedInt(); + presentationTimeDeltaUs = + Util.scaleLargeTimestamp(atom.readUnsignedInt(), C.MICROS_PER_SECOND, timescale); + if (segmentIndexEarliestPresentationTimeUs != C.TIME_UNSET) { + sampleTimeUs = segmentIndexEarliestPresentationTimeUs + presentationTimeDeltaUs; + } + durationMs = + Util.scaleLargeTimestamp(atom.readUnsignedInt(), C.MILLIS_PER_SECOND, timescale); + id = atom.readUnsignedInt(); + break; + case 1: + timescale = atom.readUnsignedInt(); + sampleTimeUs = + Util.scaleLargeTimestamp(atom.readUnsignedLongToLong(), C.MICROS_PER_SECOND, timescale); + durationMs = + Util.scaleLargeTimestamp(atom.readUnsignedInt(), C.MILLIS_PER_SECOND, timescale); + id = atom.readUnsignedInt(); + schemeIdUri = Assertions.checkNotNull(atom.readNullTerminatedString()); + value = Assertions.checkNotNull(atom.readNullTerminatedString()); + break; + default: + Log.w(TAG, "Skipping unsupported emsg version: " + version); + return; + } + + byte[] messageData = new byte[atom.bytesLeft()]; + atom.readBytes(messageData, /*offset=*/ 0, atom.bytesLeft()); + EventMessage eventMessage = new EventMessage(schemeIdUri, value, durationMs, id, messageData); + ParsableByteArray encodedEventMessage = + new ParsableByteArray(eventMessageEncoder.encode(eventMessage)); + int sampleSize = encodedEventMessage.bytesLeft(); + + // Output the sample data. + for (TrackOutput emsgTrackOutput : emsgTrackOutputs) { + encodedEventMessage.setPosition(0); + emsgTrackOutput.sampleData(encodedEventMessage, sampleSize); + } + + // Output the sample metadata. This is made a little complicated because emsg-v0 atoms + // have presentation time *delta* while v1 atoms have absolute presentation time. + if (sampleTimeUs == C.TIME_UNSET) { + // We need the first sample timestamp in the segment before we can output the metadata. + pendingMetadataSampleInfos.addLast( + new MetadataSampleInfo(presentationTimeDeltaUs, sampleSize)); + pendingMetadataSampleBytes += sampleSize; + } else { + if (timestampAdjuster != null) { + sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); + } + for (TrackOutput emsgTrackOutput : emsgTrackOutputs) { + emsgTrackOutput.sampleMetadata( + sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, /* offset= */ 0, null); + } + } + } + + /** Parses a trex atom (defined in 14496-12). */ + private static Pair<Integer, DefaultSampleValues> parseTrex(ParsableByteArray trex) { + trex.setPosition(Atom.FULL_HEADER_SIZE); + int trackId = trex.readInt(); + int defaultSampleDescriptionIndex = trex.readUnsignedIntToInt() - 1; + int defaultSampleDuration = trex.readUnsignedIntToInt(); + int defaultSampleSize = trex.readUnsignedIntToInt(); + int defaultSampleFlags = trex.readInt(); + + return Pair.create(trackId, new DefaultSampleValues(defaultSampleDescriptionIndex, + defaultSampleDuration, defaultSampleSize, defaultSampleFlags)); + } + + /** + * Parses an mehd atom (defined in 14496-12). + */ + private static long parseMehd(ParsableByteArray mehd) { + mehd.setPosition(Atom.HEADER_SIZE); + int fullAtom = mehd.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + return version == 0 ? mehd.readUnsignedInt() : mehd.readUnsignedLongToLong(); + } + + private static void parseMoof(ContainerAtom moof, SparseArray<TrackBundle> trackBundleArray, + @Flags int flags, byte[] extendedTypeScratch) throws ParserException { + int moofContainerChildrenSize = moof.containerChildren.size(); + for (int i = 0; i < moofContainerChildrenSize; i++) { + Atom.ContainerAtom child = moof.containerChildren.get(i); + // TODO: Support multiple traf boxes per track in a single moof. + if (child.type == Atom.TYPE_traf) { + parseTraf(child, trackBundleArray, flags, extendedTypeScratch); + } + } + } + + /** + * Parses a traf atom (defined in 14496-12). + */ + private static void parseTraf(ContainerAtom traf, SparseArray<TrackBundle> trackBundleArray, + @Flags int flags, byte[] extendedTypeScratch) throws ParserException { + LeafAtom tfhd = traf.getLeafAtomOfType(Atom.TYPE_tfhd); + TrackBundle trackBundle = parseTfhd(tfhd.data, trackBundleArray); + if (trackBundle == null) { + return; + } + + TrackFragment fragment = trackBundle.fragment; + long decodeTime = fragment.nextFragmentDecodeTime; + trackBundle.reset(); + + LeafAtom tfdtAtom = traf.getLeafAtomOfType(Atom.TYPE_tfdt); + if (tfdtAtom != null && (flags & FLAG_WORKAROUND_IGNORE_TFDT_BOX) == 0) { + decodeTime = parseTfdt(traf.getLeafAtomOfType(Atom.TYPE_tfdt).data); + } + + parseTruns(traf, trackBundle, decodeTime, flags); + + TrackEncryptionBox encryptionBox = trackBundle.track + .getSampleDescriptionEncryptionBox(fragment.header.sampleDescriptionIndex); + + LeafAtom saiz = traf.getLeafAtomOfType(Atom.TYPE_saiz); + if (saiz != null) { + parseSaiz(encryptionBox, saiz.data, fragment); + } + + LeafAtom saio = traf.getLeafAtomOfType(Atom.TYPE_saio); + if (saio != null) { + parseSaio(saio.data, fragment); + } + + LeafAtom senc = traf.getLeafAtomOfType(Atom.TYPE_senc); + if (senc != null) { + parseSenc(senc.data, fragment); + } + + LeafAtom sbgp = traf.getLeafAtomOfType(Atom.TYPE_sbgp); + LeafAtom sgpd = traf.getLeafAtomOfType(Atom.TYPE_sgpd); + if (sbgp != null && sgpd != null) { + parseSgpd(sbgp.data, sgpd.data, encryptionBox != null ? encryptionBox.schemeType : null, + fragment); + } + + int leafChildrenSize = traf.leafChildren.size(); + for (int i = 0; i < leafChildrenSize; i++) { + LeafAtom atom = traf.leafChildren.get(i); + if (atom.type == Atom.TYPE_uuid) { + parseUuid(atom.data, fragment, extendedTypeScratch); + } + } + } + + private static void parseTruns(ContainerAtom traf, TrackBundle trackBundle, long decodeTime, + @Flags int flags) { + int trunCount = 0; + int totalSampleCount = 0; + List<LeafAtom> leafChildren = traf.leafChildren; + int leafChildrenSize = leafChildren.size(); + for (int i = 0; i < leafChildrenSize; i++) { + LeafAtom atom = leafChildren.get(i); + if (atom.type == Atom.TYPE_trun) { + ParsableByteArray trunData = atom.data; + trunData.setPosition(Atom.FULL_HEADER_SIZE); + int trunSampleCount = trunData.readUnsignedIntToInt(); + if (trunSampleCount > 0) { + totalSampleCount += trunSampleCount; + trunCount++; + } + } + } + trackBundle.currentTrackRunIndex = 0; + trackBundle.currentSampleInTrackRun = 0; + trackBundle.currentSampleIndex = 0; + trackBundle.fragment.initTables(trunCount, totalSampleCount); + + int trunIndex = 0; + int trunStartPosition = 0; + for (int i = 0; i < leafChildrenSize; i++) { + LeafAtom trun = leafChildren.get(i); + if (trun.type == Atom.TYPE_trun) { + trunStartPosition = parseTrun(trackBundle, trunIndex++, decodeTime, flags, trun.data, + trunStartPosition); + } + } + } + + private static void parseSaiz(TrackEncryptionBox encryptionBox, ParsableByteArray saiz, + TrackFragment out) throws ParserException { + int vectorSize = encryptionBox.perSampleIvSize; + saiz.setPosition(Atom.HEADER_SIZE); + int fullAtom = saiz.readInt(); + int flags = Atom.parseFullAtomFlags(fullAtom); + if ((flags & 0x01) == 1) { + saiz.skipBytes(8); + } + int defaultSampleInfoSize = saiz.readUnsignedByte(); + + int sampleCount = saiz.readUnsignedIntToInt(); + if (sampleCount != out.sampleCount) { + throw new ParserException("Length mismatch: " + sampleCount + ", " + out.sampleCount); + } + + int totalSize = 0; + if (defaultSampleInfoSize == 0) { + boolean[] sampleHasSubsampleEncryptionTable = out.sampleHasSubsampleEncryptionTable; + for (int i = 0; i < sampleCount; i++) { + int sampleInfoSize = saiz.readUnsignedByte(); + totalSize += sampleInfoSize; + sampleHasSubsampleEncryptionTable[i] = sampleInfoSize > vectorSize; + } + } else { + boolean subsampleEncryption = defaultSampleInfoSize > vectorSize; + totalSize += defaultSampleInfoSize * sampleCount; + Arrays.fill(out.sampleHasSubsampleEncryptionTable, 0, sampleCount, subsampleEncryption); + } + out.initEncryptionData(totalSize); + } + + /** + * Parses a saio atom (defined in 14496-12). + * + * @param saio The saio atom to decode. + * @param out The {@link TrackFragment} to populate with data from the saio atom. + */ + private static void parseSaio(ParsableByteArray saio, TrackFragment out) throws ParserException { + saio.setPosition(Atom.HEADER_SIZE); + int fullAtom = saio.readInt(); + int flags = Atom.parseFullAtomFlags(fullAtom); + if ((flags & 0x01) == 1) { + saio.skipBytes(8); + } + + int entryCount = saio.readUnsignedIntToInt(); + if (entryCount != 1) { + // We only support one trun element currently, so always expect one entry. + throw new ParserException("Unexpected saio entry count: " + entryCount); + } + + int version = Atom.parseFullAtomVersion(fullAtom); + out.auxiliaryDataPosition += + version == 0 ? saio.readUnsignedInt() : saio.readUnsignedLongToLong(); + } + + /** + * Parses a tfhd atom (defined in 14496-12), updates the corresponding {@link TrackFragment} and + * returns the {@link TrackBundle} of the corresponding {@link Track}. If the tfhd does not refer + * to any {@link TrackBundle}, {@code null} is returned and no changes are made. + * + * @param tfhd The tfhd atom to decode. + * @param trackBundles The track bundles, one of which corresponds to the tfhd atom being parsed. + * @return The {@link TrackBundle} to which the {@link TrackFragment} belongs, or null if the tfhd + * does not refer to any {@link TrackBundle}. + */ + private static TrackBundle parseTfhd( + ParsableByteArray tfhd, SparseArray<TrackBundle> trackBundles) { + tfhd.setPosition(Atom.HEADER_SIZE); + int fullAtom = tfhd.readInt(); + int atomFlags = Atom.parseFullAtomFlags(fullAtom); + int trackId = tfhd.readInt(); + TrackBundle trackBundle = getTrackBundle(trackBundles, trackId); + if (trackBundle == null) { + return null; + } + if ((atomFlags & 0x01 /* base_data_offset_present */) != 0) { + long baseDataPosition = tfhd.readUnsignedLongToLong(); + trackBundle.fragment.dataPosition = baseDataPosition; + trackBundle.fragment.auxiliaryDataPosition = baseDataPosition; + } + + DefaultSampleValues defaultSampleValues = trackBundle.defaultSampleValues; + int defaultSampleDescriptionIndex = + ((atomFlags & 0x02 /* default_sample_description_index_present */) != 0) + ? tfhd.readUnsignedIntToInt() - 1 : defaultSampleValues.sampleDescriptionIndex; + int defaultSampleDuration = ((atomFlags & 0x08 /* default_sample_duration_present */) != 0) + ? tfhd.readUnsignedIntToInt() : defaultSampleValues.duration; + int defaultSampleSize = ((atomFlags & 0x10 /* default_sample_size_present */) != 0) + ? tfhd.readUnsignedIntToInt() : defaultSampleValues.size; + int defaultSampleFlags = ((atomFlags & 0x20 /* default_sample_flags_present */) != 0) + ? tfhd.readUnsignedIntToInt() : defaultSampleValues.flags; + trackBundle.fragment.header = new DefaultSampleValues(defaultSampleDescriptionIndex, + defaultSampleDuration, defaultSampleSize, defaultSampleFlags); + return trackBundle; + } + + private static @Nullable TrackBundle getTrackBundle( + SparseArray<TrackBundle> trackBundles, int trackId) { + if (trackBundles.size() == 1) { + // Ignore track id if there is only one track. This is either because we have a side-loaded + // track (flag FLAG_SIDELOADED) or to cope with non-matching track indices (see + // https://github.com/google/ExoPlayer/issues/4083). + return trackBundles.valueAt(/* index= */ 0); + } + return trackBundles.get(trackId); + } + + /** + * Parses a tfdt atom (defined in 14496-12). + * + * @return baseMediaDecodeTime The sum of the decode durations of all earlier samples in the + * media, expressed in the media's timescale. + */ + private static long parseTfdt(ParsableByteArray tfdt) { + tfdt.setPosition(Atom.HEADER_SIZE); + int fullAtom = tfdt.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + return version == 1 ? tfdt.readUnsignedLongToLong() : tfdt.readUnsignedInt(); + } + + /** + * Parses a trun atom (defined in 14496-12). + * + * @param trackBundle The {@link TrackBundle} that contains the {@link TrackFragment} into + * which parsed data should be placed. + * @param index Index of the track run in the fragment. + * @param decodeTime The decode time of the first sample in the fragment run. + * @param flags Flags to allow any required workaround to be executed. + * @param trun The trun atom to decode. + * @return The starting position of samples for the next run. + */ + private static int parseTrun(TrackBundle trackBundle, int index, long decodeTime, + @Flags int flags, ParsableByteArray trun, int trackRunStart) { + trun.setPosition(Atom.HEADER_SIZE); + int fullAtom = trun.readInt(); + int atomFlags = Atom.parseFullAtomFlags(fullAtom); + + Track track = trackBundle.track; + TrackFragment fragment = trackBundle.fragment; + DefaultSampleValues defaultSampleValues = fragment.header; + + fragment.trunLength[index] = trun.readUnsignedIntToInt(); + fragment.trunDataPosition[index] = fragment.dataPosition; + if ((atomFlags & 0x01 /* data_offset_present */) != 0) { + fragment.trunDataPosition[index] += trun.readInt(); + } + + boolean firstSampleFlagsPresent = (atomFlags & 0x04 /* first_sample_flags_present */) != 0; + int firstSampleFlags = defaultSampleValues.flags; + if (firstSampleFlagsPresent) { + firstSampleFlags = trun.readUnsignedIntToInt(); + } + + boolean sampleDurationsPresent = (atomFlags & 0x100 /* sample_duration_present */) != 0; + boolean sampleSizesPresent = (atomFlags & 0x200 /* sample_size_present */) != 0; + boolean sampleFlagsPresent = (atomFlags & 0x400 /* sample_flags_present */) != 0; + boolean sampleCompositionTimeOffsetsPresent = + (atomFlags & 0x800 /* sample_composition_time_offsets_present */) != 0; + + // Offset to the entire video timeline. In the presence of B-frames this is usually used to + // ensure that the first frame's presentation timestamp is zero. + long edtsOffset = 0; + + // Currently we only support a single edit that moves the entire media timeline (indicated by + // duration == 0). Other uses of edit lists are uncommon and unsupported. + if (track.editListDurations != null && track.editListDurations.length == 1 + && track.editListDurations[0] == 0) { + edtsOffset = + Util.scaleLargeTimestamp( + track.editListMediaTimes[0], C.MILLIS_PER_SECOND, track.timescale); + } + + int[] sampleSizeTable = fragment.sampleSizeTable; + int[] sampleCompositionTimeOffsetTable = fragment.sampleCompositionTimeOffsetTable; + long[] sampleDecodingTimeTable = fragment.sampleDecodingTimeTable; + boolean[] sampleIsSyncFrameTable = fragment.sampleIsSyncFrameTable; + + boolean workaroundEveryVideoFrameIsSyncFrame = track.type == C.TRACK_TYPE_VIDEO + && (flags & FLAG_WORKAROUND_EVERY_VIDEO_FRAME_IS_SYNC_FRAME) != 0; + + int trackRunEnd = trackRunStart + fragment.trunLength[index]; + long timescale = track.timescale; + long cumulativeTime = index > 0 ? fragment.nextFragmentDecodeTime : decodeTime; + for (int i = trackRunStart; i < trackRunEnd; i++) { + // Use trun values if present, otherwise tfhd, otherwise trex. + int sampleDuration = sampleDurationsPresent ? trun.readUnsignedIntToInt() + : defaultSampleValues.duration; + int sampleSize = sampleSizesPresent ? trun.readUnsignedIntToInt() : defaultSampleValues.size; + int sampleFlags = (i == 0 && firstSampleFlagsPresent) ? firstSampleFlags + : sampleFlagsPresent ? trun.readInt() : defaultSampleValues.flags; + if (sampleCompositionTimeOffsetsPresent) { + // The BMFF spec (ISO 14496-12) states that sample offsets should be unsigned integers in + // version 0 trun boxes, however a significant number of streams violate the spec and use + // signed integers instead. It's safe to always decode sample offsets as signed integers + // here, because unsigned integers will still be parsed correctly (unless their top bit is + // set, which is never true in practice because sample offsets are always small). + int sampleOffset = trun.readInt(); + sampleCompositionTimeOffsetTable[i] = + (int) ((sampleOffset * C.MILLIS_PER_SECOND) / timescale); + } else { + sampleCompositionTimeOffsetTable[i] = 0; + } + sampleDecodingTimeTable[i] = + Util.scaleLargeTimestamp(cumulativeTime, C.MILLIS_PER_SECOND, timescale) - edtsOffset; + sampleSizeTable[i] = sampleSize; + sampleIsSyncFrameTable[i] = ((sampleFlags >> 16) & 0x1) == 0 + && (!workaroundEveryVideoFrameIsSyncFrame || i == 0); + cumulativeTime += sampleDuration; + } + fragment.nextFragmentDecodeTime = cumulativeTime; + return trackRunEnd; + } + + private static void parseUuid(ParsableByteArray uuid, TrackFragment out, + byte[] extendedTypeScratch) throws ParserException { + uuid.setPosition(Atom.HEADER_SIZE); + uuid.readBytes(extendedTypeScratch, 0, 16); + + // Currently this parser only supports Microsoft's PIFF SampleEncryptionBox. + if (!Arrays.equals(extendedTypeScratch, PIFF_SAMPLE_ENCRYPTION_BOX_EXTENDED_TYPE)) { + return; + } + + // Except for the extended type, this box is identical to a SENC box. See "Portable encoding of + // audio-video objects: The Protected Interoperable File Format (PIFF), John A. Bocharov et al, + // Section 5.3.2.1." + parseSenc(uuid, 16, out); + } + + private static void parseSenc(ParsableByteArray senc, TrackFragment out) throws ParserException { + parseSenc(senc, 0, out); + } + + private static void parseSenc(ParsableByteArray senc, int offset, TrackFragment out) + throws ParserException { + senc.setPosition(Atom.HEADER_SIZE + offset); + int fullAtom = senc.readInt(); + int flags = Atom.parseFullAtomFlags(fullAtom); + + if ((flags & 0x01 /* override_track_encryption_box_parameters */) != 0) { + // TODO: Implement this. + throw new ParserException("Overriding TrackEncryptionBox parameters is unsupported."); + } + + boolean subsampleEncryption = (flags & 0x02 /* use_subsample_encryption */) != 0; + int sampleCount = senc.readUnsignedIntToInt(); + if (sampleCount != out.sampleCount) { + throw new ParserException("Length mismatch: " + sampleCount + ", " + out.sampleCount); + } + + Arrays.fill(out.sampleHasSubsampleEncryptionTable, 0, sampleCount, subsampleEncryption); + out.initEncryptionData(senc.bytesLeft()); + out.fillEncryptionData(senc); + } + + private static void parseSgpd(ParsableByteArray sbgp, ParsableByteArray sgpd, String schemeType, + TrackFragment out) throws ParserException { + sbgp.setPosition(Atom.HEADER_SIZE); + int sbgpFullAtom = sbgp.readInt(); + if (sbgp.readInt() != SAMPLE_GROUP_TYPE_seig) { + // Only seig grouping type is supported. + return; + } + if (Atom.parseFullAtomVersion(sbgpFullAtom) == 1) { + sbgp.skipBytes(4); // default_length. + } + if (sbgp.readInt() != 1) { // entry_count. + throw new ParserException("Entry count in sbgp != 1 (unsupported)."); + } + + sgpd.setPosition(Atom.HEADER_SIZE); + int sgpdFullAtom = sgpd.readInt(); + if (sgpd.readInt() != SAMPLE_GROUP_TYPE_seig) { + // Only seig grouping type is supported. + return; + } + int sgpdVersion = Atom.parseFullAtomVersion(sgpdFullAtom); + if (sgpdVersion == 1) { + if (sgpd.readUnsignedInt() == 0) { + throw new ParserException("Variable length description in sgpd found (unsupported)"); + } + } else if (sgpdVersion >= 2) { + sgpd.skipBytes(4); // default_sample_description_index. + } + if (sgpd.readUnsignedInt() != 1) { // entry_count. + throw new ParserException("Entry count in sgpd != 1 (unsupported)."); + } + // CencSampleEncryptionInformationGroupEntry + sgpd.skipBytes(1); // reserved = 0. + int patternByte = sgpd.readUnsignedByte(); + int cryptByteBlock = (patternByte & 0xF0) >> 4; + int skipByteBlock = patternByte & 0x0F; + boolean isProtected = sgpd.readUnsignedByte() == 1; + if (!isProtected) { + return; + } + int perSampleIvSize = sgpd.readUnsignedByte(); + byte[] keyId = new byte[16]; + sgpd.readBytes(keyId, 0, keyId.length); + byte[] constantIv = null; + if (perSampleIvSize == 0) { + int constantIvSize = sgpd.readUnsignedByte(); + constantIv = new byte[constantIvSize]; + sgpd.readBytes(constantIv, 0, constantIvSize); + } + out.definesEncryptionData = true; + out.trackEncryptionBox = new TrackEncryptionBox(isProtected, schemeType, perSampleIvSize, keyId, + cryptByteBlock, skipByteBlock, constantIv); + } + + /** + * Parses a sidx atom (defined in 14496-12). + * + * @param atom The atom data. + * @param inputPosition The input position of the first byte after the atom. + * @return A pair consisting of the earliest presentation time in microseconds, and the parsed + * {@link ChunkIndex}. + */ + private static Pair<Long, ChunkIndex> parseSidx(ParsableByteArray atom, long inputPosition) + throws ParserException { + atom.setPosition(Atom.HEADER_SIZE); + int fullAtom = atom.readInt(); + int version = Atom.parseFullAtomVersion(fullAtom); + + atom.skipBytes(4); + long timescale = atom.readUnsignedInt(); + long earliestPresentationTime; + long offset = inputPosition; + if (version == 0) { + earliestPresentationTime = atom.readUnsignedInt(); + offset += atom.readUnsignedInt(); + } else { + earliestPresentationTime = atom.readUnsignedLongToLong(); + offset += atom.readUnsignedLongToLong(); + } + long earliestPresentationTimeUs = Util.scaleLargeTimestamp(earliestPresentationTime, + C.MICROS_PER_SECOND, timescale); + + atom.skipBytes(2); + + int referenceCount = atom.readUnsignedShort(); + int[] sizes = new int[referenceCount]; + long[] offsets = new long[referenceCount]; + long[] durationsUs = new long[referenceCount]; + long[] timesUs = new long[referenceCount]; + + long time = earliestPresentationTime; + long timeUs = earliestPresentationTimeUs; + for (int i = 0; i < referenceCount; i++) { + int firstInt = atom.readInt(); + + int type = 0x80000000 & firstInt; + if (type != 0) { + throw new ParserException("Unhandled indirect reference"); + } + long referenceDuration = atom.readUnsignedInt(); + + sizes[i] = 0x7FFFFFFF & firstInt; + offsets[i] = offset; + + // Calculate time and duration values such that any rounding errors are consistent. i.e. That + // timesUs[i] + durationsUs[i] == timesUs[i + 1]. + timesUs[i] = timeUs; + time += referenceDuration; + timeUs = Util.scaleLargeTimestamp(time, C.MICROS_PER_SECOND, timescale); + durationsUs[i] = timeUs - timesUs[i]; + + atom.skipBytes(4); + offset += sizes[i]; + } + + return Pair.create(earliestPresentationTimeUs, + new ChunkIndex(sizes, offsets, durationsUs, timesUs)); + } + + private void readEncryptionData(ExtractorInput input) throws IOException, InterruptedException { + TrackBundle nextTrackBundle = null; + long nextDataOffset = Long.MAX_VALUE; + int trackBundlesSize = trackBundles.size(); + for (int i = 0; i < trackBundlesSize; i++) { + TrackFragment trackFragment = trackBundles.valueAt(i).fragment; + if (trackFragment.sampleEncryptionDataNeedsFill + && trackFragment.auxiliaryDataPosition < nextDataOffset) { + nextDataOffset = trackFragment.auxiliaryDataPosition; + nextTrackBundle = trackBundles.valueAt(i); + } + } + if (nextTrackBundle == null) { + parserState = STATE_READING_SAMPLE_START; + return; + } + int bytesToSkip = (int) (nextDataOffset - input.getPosition()); + if (bytesToSkip < 0) { + throw new ParserException("Offset to encryption data was negative."); + } + input.skipFully(bytesToSkip); + nextTrackBundle.fragment.fillEncryptionData(input); + } + + /** + * Attempts to read the next sample in the current mdat atom. The read sample may be output or + * skipped. + * + * <p>If there are no more samples in the current mdat atom then the parser state is transitioned + * to {@link #STATE_READING_ATOM_HEADER} and {@code false} is returned. + * + * <p>It is possible for a sample to be partially read in the case that an exception is thrown. In + * this case the method can be called again to read the remainder of the sample. + * + * @param input The {@link ExtractorInput} from which to read data. + * @return Whether a sample was read. The read sample may have been output or skipped. False + * indicates that there are no samples left to read in the current mdat. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private boolean readSample(ExtractorInput input) throws IOException, InterruptedException { + if (parserState == STATE_READING_SAMPLE_START) { + if (currentTrackBundle == null) { + TrackBundle currentTrackBundle = getNextFragmentRun(trackBundles); + if (currentTrackBundle == null) { + // We've run out of samples in the current mdat. Discard any trailing data and prepare to + // read the header of the next atom. + int bytesToSkip = (int) (endOfMdatPosition - input.getPosition()); + if (bytesToSkip < 0) { + throw new ParserException("Offset to end of mdat was negative."); + } + input.skipFully(bytesToSkip); + enterReadingAtomHeaderState(); + return false; + } + + long nextDataPosition = currentTrackBundle.fragment + .trunDataPosition[currentTrackBundle.currentTrackRunIndex]; + // We skip bytes preceding the next sample to read. + int bytesToSkip = (int) (nextDataPosition - input.getPosition()); + if (bytesToSkip < 0) { + // Assume the sample data must be contiguous in the mdat with no preceding data. + Log.w(TAG, "Ignoring negative offset to sample data."); + bytesToSkip = 0; + } + input.skipFully(bytesToSkip); + this.currentTrackBundle = currentTrackBundle; + } + + sampleSize = currentTrackBundle.fragment + .sampleSizeTable[currentTrackBundle.currentSampleIndex]; + + if (currentTrackBundle.currentSampleIndex < currentTrackBundle.firstSampleToOutputIndex) { + input.skipFully(sampleSize); + currentTrackBundle.skipSampleEncryptionData(); + if (!currentTrackBundle.next()) { + currentTrackBundle = null; + } + parserState = STATE_READING_SAMPLE_START; + return true; + } + + if (currentTrackBundle.track.sampleTransformation == Track.TRANSFORMATION_CEA608_CDAT) { + sampleSize -= Atom.HEADER_SIZE; + input.skipFully(Atom.HEADER_SIZE); + } + + if (MimeTypes.AUDIO_AC4.equals(currentTrackBundle.track.format.sampleMimeType)) { + // AC4 samples need to be prefixed with a clear sample header. + sampleBytesWritten = + currentTrackBundle.outputSampleEncryptionData(sampleSize, Ac4Util.SAMPLE_HEADER_SIZE); + Ac4Util.getAc4SampleHeader(sampleSize, scratch); + currentTrackBundle.output.sampleData(scratch, Ac4Util.SAMPLE_HEADER_SIZE); + sampleBytesWritten += Ac4Util.SAMPLE_HEADER_SIZE; + } else { + sampleBytesWritten = + currentTrackBundle.outputSampleEncryptionData(sampleSize, /* clearHeaderSize= */ 0); + } + sampleSize += sampleBytesWritten; + parserState = STATE_READING_SAMPLE_CONTINUE; + sampleCurrentNalBytesRemaining = 0; + } + + TrackFragment fragment = currentTrackBundle.fragment; + Track track = currentTrackBundle.track; + TrackOutput output = currentTrackBundle.output; + int sampleIndex = currentTrackBundle.currentSampleIndex; + long sampleTimeUs = fragment.getSamplePresentationTime(sampleIndex) * 1000L; + if (timestampAdjuster != null) { + sampleTimeUs = timestampAdjuster.adjustSampleTimestamp(sampleTimeUs); + } + if (track.nalUnitLengthFieldLength != 0) { + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalPrefixData = nalPrefix.data; + nalPrefixData[0] = 0; + nalPrefixData[1] = 0; + nalPrefixData[2] = 0; + int nalUnitPrefixLength = track.nalUnitLengthFieldLength + 1; + int nalUnitLengthFieldLengthDiff = 4 - track.nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + while (sampleBytesWritten < sampleSize) { + if (sampleCurrentNalBytesRemaining == 0) { + // Read the NAL length so that we know where we find the next one, and its type. + input.readFully(nalPrefixData, nalUnitLengthFieldLengthDiff, nalUnitPrefixLength); + nalPrefix.setPosition(0); + int nalLengthInt = nalPrefix.readInt(); + if (nalLengthInt < 1) { + throw new ParserException("Invalid NAL length"); + } + sampleCurrentNalBytesRemaining = nalLengthInt - 1; + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + output.sampleData(nalStartCode, 4); + // Write the NAL unit type byte. + output.sampleData(nalPrefix, 1); + processSeiNalUnitPayload = cea608TrackOutputs.length > 0 + && NalUnitUtil.isNalUnitSei(track.format.sampleMimeType, nalPrefixData[4]); + sampleBytesWritten += 5; + sampleSize += nalUnitLengthFieldLengthDiff; + } else { + int writtenBytes; + if (processSeiNalUnitPayload) { + // Read and write the payload of the SEI NAL unit. + nalBuffer.reset(sampleCurrentNalBytesRemaining); + input.readFully(nalBuffer.data, 0, sampleCurrentNalBytesRemaining); + output.sampleData(nalBuffer, sampleCurrentNalBytesRemaining); + writtenBytes = sampleCurrentNalBytesRemaining; + // Unescape and process the SEI NAL unit. + int unescapedLength = NalUnitUtil.unescapeStream(nalBuffer.data, nalBuffer.limit()); + // If the format is H.265/HEVC the NAL unit header has two bytes so skip one more byte. + nalBuffer.setPosition(MimeTypes.VIDEO_H265.equals(track.format.sampleMimeType) ? 1 : 0); + nalBuffer.setLimit(unescapedLength); + CeaUtil.consume(sampleTimeUs, nalBuffer, cea608TrackOutputs); + } else { + // Write the payload of the NAL unit. + writtenBytes = output.sampleData(input, sampleCurrentNalBytesRemaining, false); + } + sampleBytesWritten += writtenBytes; + sampleCurrentNalBytesRemaining -= writtenBytes; + } + } + } else { + while (sampleBytesWritten < sampleSize) { + int writtenBytes = output.sampleData(input, sampleSize - sampleBytesWritten, false); + sampleBytesWritten += writtenBytes; + } + } + + @C.BufferFlags int sampleFlags = fragment.sampleIsSyncFrameTable[sampleIndex] + ? C.BUFFER_FLAG_KEY_FRAME : 0; + + // Encryption data. + TrackOutput.CryptoData cryptoData = null; + TrackEncryptionBox encryptionBox = currentTrackBundle.getEncryptionBoxIfEncrypted(); + if (encryptionBox != null) { + sampleFlags |= C.BUFFER_FLAG_ENCRYPTED; + cryptoData = encryptionBox.cryptoData; + } + + output.sampleMetadata(sampleTimeUs, sampleFlags, sampleSize, 0, cryptoData); + + // After we have the sampleTimeUs, we can commit all the pending metadata samples + outputPendingMetadataSamples(sampleTimeUs); + if (!currentTrackBundle.next()) { + currentTrackBundle = null; + } + parserState = STATE_READING_SAMPLE_START; + return true; + } + + private void outputPendingMetadataSamples(long sampleTimeUs) { + while (!pendingMetadataSampleInfos.isEmpty()) { + MetadataSampleInfo sampleInfo = pendingMetadataSampleInfos.removeFirst(); + pendingMetadataSampleBytes -= sampleInfo.size; + long metadataTimeUs = sampleTimeUs + sampleInfo.presentationTimeDeltaUs; + if (timestampAdjuster != null) { + metadataTimeUs = timestampAdjuster.adjustSampleTimestamp(metadataTimeUs); + } + for (TrackOutput emsgTrackOutput : emsgTrackOutputs) { + emsgTrackOutput.sampleMetadata( + metadataTimeUs, + C.BUFFER_FLAG_KEY_FRAME, + sampleInfo.size, + pendingMetadataSampleBytes, + null); + } + } + } + + /** + * Returns the {@link TrackBundle} whose fragment run has the earliest file position out of those + * yet to be consumed, or null if all have been consumed. + */ + private static TrackBundle getNextFragmentRun(SparseArray<TrackBundle> trackBundles) { + TrackBundle nextTrackBundle = null; + long nextTrackRunOffset = Long.MAX_VALUE; + + int trackBundlesSize = trackBundles.size(); + for (int i = 0; i < trackBundlesSize; i++) { + TrackBundle trackBundle = trackBundles.valueAt(i); + if (trackBundle.currentTrackRunIndex == trackBundle.fragment.trunCount) { + // This track fragment contains no more runs in the next mdat box. + } else { + long trunOffset = trackBundle.fragment.trunDataPosition[trackBundle.currentTrackRunIndex]; + if (trunOffset < nextTrackRunOffset) { + nextTrackBundle = trackBundle; + nextTrackRunOffset = trunOffset; + } + } + } + return nextTrackBundle; + } + + /** Returns DrmInitData from leaf atoms. */ + @Nullable + private static DrmInitData getDrmInitDataFromAtoms(List<Atom.LeafAtom> leafChildren) { + ArrayList<SchemeData> schemeDatas = null; + int leafChildrenSize = leafChildren.size(); + for (int i = 0; i < leafChildrenSize; i++) { + LeafAtom child = leafChildren.get(i); + if (child.type == Atom.TYPE_pssh) { + if (schemeDatas == null) { + schemeDatas = new ArrayList<>(); + } + byte[] psshData = child.data.data; + UUID uuid = PsshAtomUtil.parseUuid(psshData); + if (uuid == null) { + Log.w(TAG, "Skipped pssh atom (failed to extract uuid)"); + } else { + schemeDatas.add(new SchemeData(uuid, MimeTypes.VIDEO_MP4, psshData)); + } + } + } + return schemeDatas == null ? null : new DrmInitData(schemeDatas); + } + + /** Returns whether the extractor should decode a leaf atom with type {@code atom}. */ + private static boolean shouldParseLeafAtom(int atom) { + return atom == Atom.TYPE_hdlr || atom == Atom.TYPE_mdhd || atom == Atom.TYPE_mvhd + || atom == Atom.TYPE_sidx || atom == Atom.TYPE_stsd || atom == Atom.TYPE_tfdt + || atom == Atom.TYPE_tfhd || atom == Atom.TYPE_tkhd || atom == Atom.TYPE_trex + || atom == Atom.TYPE_trun || atom == Atom.TYPE_pssh || atom == Atom.TYPE_saiz + || atom == Atom.TYPE_saio || atom == Atom.TYPE_senc || atom == Atom.TYPE_uuid + || atom == Atom.TYPE_sbgp || atom == Atom.TYPE_sgpd || atom == Atom.TYPE_elst + || atom == Atom.TYPE_mehd || atom == Atom.TYPE_emsg; + } + + /** Returns whether the extractor should decode a container atom with type {@code atom}. */ + private static boolean shouldParseContainerAtom(int atom) { + return atom == Atom.TYPE_moov || atom == Atom.TYPE_trak || atom == Atom.TYPE_mdia + || atom == Atom.TYPE_minf || atom == Atom.TYPE_stbl || atom == Atom.TYPE_moof + || atom == Atom.TYPE_traf || atom == Atom.TYPE_mvex || atom == Atom.TYPE_edts; + } + + /** + * Holds data corresponding to a metadata sample. + */ + private static final class MetadataSampleInfo { + + public final long presentationTimeDeltaUs; + public final int size; + + public MetadataSampleInfo(long presentationTimeDeltaUs, int size) { + this.presentationTimeDeltaUs = presentationTimeDeltaUs; + this.size = size; + } + + } + + /** + * Holds data corresponding to a single track. + */ + private static final class TrackBundle { + + private static final int SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH = 8; + + public final TrackOutput output; + public final TrackFragment fragment; + public final ParsableByteArray scratch; + + public Track track; + public DefaultSampleValues defaultSampleValues; + public int currentSampleIndex; + public int currentSampleInTrackRun; + public int currentTrackRunIndex; + public int firstSampleToOutputIndex; + + private final ParsableByteArray encryptionSignalByte; + private final ParsableByteArray defaultInitializationVector; + + public TrackBundle(TrackOutput output) { + this.output = output; + fragment = new TrackFragment(); + scratch = new ParsableByteArray(); + encryptionSignalByte = new ParsableByteArray(1); + defaultInitializationVector = new ParsableByteArray(); + } + + public void init(Track track, DefaultSampleValues defaultSampleValues) { + this.track = Assertions.checkNotNull(track); + this.defaultSampleValues = Assertions.checkNotNull(defaultSampleValues); + output.format(track.format); + reset(); + } + + public void updateDrmInitData(DrmInitData drmInitData) { + TrackEncryptionBox encryptionBox = + track.getSampleDescriptionEncryptionBox(fragment.header.sampleDescriptionIndex); + String schemeType = encryptionBox != null ? encryptionBox.schemeType : null; + output.format(track.format.copyWithDrmInitData(drmInitData.copyWithSchemeType(schemeType))); + } + + /** Resets the current fragment and sample indices. */ + public void reset() { + fragment.reset(); + currentSampleIndex = 0; + currentTrackRunIndex = 0; + currentSampleInTrackRun = 0; + firstSampleToOutputIndex = 0; + } + + /** + * Advances {@link #firstSampleToOutputIndex} to point to the sync sample before the specified + * seek time in the current fragment. + * + * @param timeUs The seek time, in microseconds. + */ + public void seek(long timeUs) { + long timeMs = C.usToMs(timeUs); + int searchIndex = currentSampleIndex; + while (searchIndex < fragment.sampleCount + && fragment.getSamplePresentationTime(searchIndex) < timeMs) { + if (fragment.sampleIsSyncFrameTable[searchIndex]) { + firstSampleToOutputIndex = searchIndex; + } + searchIndex++; + } + } + + /** + * Advances the indices in the bundle to point to the next sample in the current fragment. If + * the current sample is the last one in the current fragment, then the advanced state will be + * {@code currentSampleIndex == fragment.sampleCount}, {@code currentTrackRunIndex == + * fragment.trunCount} and {@code #currentSampleInTrackRun == 0}. + * + * @return Whether the next sample is in the same track run as the previous one. + */ + public boolean next() { + currentSampleIndex++; + currentSampleInTrackRun++; + if (currentSampleInTrackRun == fragment.trunLength[currentTrackRunIndex]) { + currentTrackRunIndex++; + currentSampleInTrackRun = 0; + return false; + } + return true; + } + + /** + * Outputs the encryption data for the current sample. + * + * @param sampleSize The size of the current sample in bytes, excluding any additional clear + * header that will be prefixed to the sample by the extractor. + * @param clearHeaderSize The size of a clear header that will be prefixed to the sample by the + * extractor, or 0. + * @return The number of written bytes. + */ + public int outputSampleEncryptionData(int sampleSize, int clearHeaderSize) { + TrackEncryptionBox encryptionBox = getEncryptionBoxIfEncrypted(); + if (encryptionBox == null) { + return 0; + } + + ParsableByteArray initializationVectorData; + int vectorSize; + if (encryptionBox.perSampleIvSize != 0) { + initializationVectorData = fragment.sampleEncryptionData; + vectorSize = encryptionBox.perSampleIvSize; + } else { + // The default initialization vector should be used. + byte[] initVectorData = encryptionBox.defaultInitializationVector; + defaultInitializationVector.reset(initVectorData, initVectorData.length); + initializationVectorData = defaultInitializationVector; + vectorSize = initVectorData.length; + } + + boolean haveSubsampleEncryptionTable = + fragment.sampleHasSubsampleEncryptionTable(currentSampleIndex); + boolean writeSubsampleEncryptionData = haveSubsampleEncryptionTable || clearHeaderSize != 0; + + // Write the signal byte, containing the vector size and the subsample encryption flag. + encryptionSignalByte.data[0] = + (byte) (vectorSize | (writeSubsampleEncryptionData ? 0x80 : 0)); + encryptionSignalByte.setPosition(0); + output.sampleData(encryptionSignalByte, 1); + // Write the vector. + output.sampleData(initializationVectorData, vectorSize); + + if (!writeSubsampleEncryptionData) { + return 1 + vectorSize; + } + + if (!haveSubsampleEncryptionTable) { + // The sample is fully encrypted, except for the additional clear header that the extractor + // is going to prefix. We need to synthesize subsample encryption data that takes the header + // into account. + scratch.reset(SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH); + // subsampleCount = 1 (unsigned short) + scratch.data[0] = (byte) 0; + scratch.data[1] = (byte) 1; + // clearDataSize = clearHeaderSize (unsigned short) + scratch.data[2] = (byte) ((clearHeaderSize >> 8) & 0xFF); + scratch.data[3] = (byte) (clearHeaderSize & 0xFF); + // encryptedDataSize = sampleSize (unsigned short) + scratch.data[4] = (byte) ((sampleSize >> 24) & 0xFF); + scratch.data[5] = (byte) ((sampleSize >> 16) & 0xFF); + scratch.data[6] = (byte) ((sampleSize >> 8) & 0xFF); + scratch.data[7] = (byte) (sampleSize & 0xFF); + output.sampleData(scratch, SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH); + return 1 + vectorSize + SINGLE_SUBSAMPLE_ENCRYPTION_DATA_LENGTH; + } + + ParsableByteArray subsampleEncryptionData = fragment.sampleEncryptionData; + int subsampleCount = subsampleEncryptionData.readUnsignedShort(); + subsampleEncryptionData.skipBytes(-2); + int subsampleDataLength = 2 + 6 * subsampleCount; + + if (clearHeaderSize != 0) { + // We need to account for the additional clear header by adding clearHeaderSize to + // clearDataSize for the first subsample specified in the subsample encryption data. + scratch.reset(subsampleDataLength); + scratch.readBytes(subsampleEncryptionData.data, /* offset= */ 0, subsampleDataLength); + subsampleEncryptionData.skipBytes(subsampleDataLength); + + int clearDataSize = (scratch.data[2] & 0xFF) << 8 | (scratch.data[3] & 0xFF); + int adjustedClearDataSize = clearDataSize + clearHeaderSize; + scratch.data[2] = (byte) ((adjustedClearDataSize >> 8) & 0xFF); + scratch.data[3] = (byte) (adjustedClearDataSize & 0xFF); + subsampleEncryptionData = scratch; + } + + output.sampleData(subsampleEncryptionData, subsampleDataLength); + return 1 + vectorSize + subsampleDataLength; + } + + /** Skips the encryption data for the current sample. */ + private void skipSampleEncryptionData() { + TrackEncryptionBox encryptionBox = getEncryptionBoxIfEncrypted(); + if (encryptionBox == null) { + return; + } + + ParsableByteArray sampleEncryptionData = fragment.sampleEncryptionData; + if (encryptionBox.perSampleIvSize != 0) { + sampleEncryptionData.skipBytes(encryptionBox.perSampleIvSize); + } + if (fragment.sampleHasSubsampleEncryptionTable(currentSampleIndex)) { + sampleEncryptionData.skipBytes(6 * sampleEncryptionData.readUnsignedShort()); + } + } + + private TrackEncryptionBox getEncryptionBoxIfEncrypted() { + int sampleDescriptionIndex = fragment.header.sampleDescriptionIndex; + TrackEncryptionBox encryptionBox = + fragment.trackEncryptionBox != null + ? fragment.trackEncryptionBox + : track.getSampleDescriptionEncryptionBox(sampleDescriptionIndex); + return encryptionBox != null && encryptionBox.isEncrypted ? encryptionBox : null; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/MdtaMetadataEntry.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/MdtaMetadataEntry.java new file mode 100644 index 0000000000..7040df6425 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/MdtaMetadataEntry.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Stores extensible metadata with handler type 'mdta'. See also the QuickTime File Format + * Specification. + */ +public final class MdtaMetadataEntry implements Metadata.Entry { + + /** The metadata key name. */ + public final String key; + /** The payload. The interpretation of the value depends on {@link #typeIndicator}. */ + public final byte[] value; + /** The four byte locale indicator. */ + public final int localeIndicator; + /** The four byte type indicator. */ + public final int typeIndicator; + + /** Creates a new metadata entry for the specified metadata key/value. */ + public MdtaMetadataEntry(String key, byte[] value, int localeIndicator, int typeIndicator) { + this.key = key; + this.value = value; + this.localeIndicator = localeIndicator; + this.typeIndicator = typeIndicator; + } + + private MdtaMetadataEntry(Parcel in) { + key = Util.castNonNull(in.readString()); + value = new byte[in.readInt()]; + in.readByteArray(value); + localeIndicator = in.readInt(); + typeIndicator = in.readInt(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + MdtaMetadataEntry other = (MdtaMetadataEntry) obj; + return key.equals(other.key) + && Arrays.equals(value, other.value) + && localeIndicator == other.localeIndicator + && typeIndicator == other.typeIndicator; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + key.hashCode(); + result = 31 * result + Arrays.hashCode(value); + result = 31 * result + localeIndicator; + result = 31 * result + typeIndicator; + return result; + } + + @Override + public String toString() { + return "mdta: key=" + key; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(key); + dest.writeInt(value.length); + dest.writeByteArray(value); + dest.writeInt(localeIndicator); + dest.writeInt(typeIndicator); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator<MdtaMetadataEntry> CREATOR = + new Parcelable.Creator<MdtaMetadataEntry>() { + + @Override + public MdtaMetadataEntry createFromParcel(Parcel in) { + return new MdtaMetadataEntry(in); + } + + @Override + public MdtaMetadataEntry[] newArray(int size) { + return new MdtaMetadataEntry[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java new file mode 100644 index 0000000000..7d4de0e498 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/MetadataUtil.java @@ -0,0 +1,588 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.ApicFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.CommentFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Frame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.InternalFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.TextInformationFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; + +/** Utilities for handling metadata in MP4. */ +/* package */ final class MetadataUtil { + + private static final String TAG = "MetadataUtil"; + + // Codes that start with the copyright character (omitted) and have equivalent ID3 frames. + private static final int SHORT_TYPE_NAME_1 = 0x006e616d; + private static final int SHORT_TYPE_NAME_2 = 0x0074726b; + private static final int SHORT_TYPE_COMMENT = 0x00636d74; + private static final int SHORT_TYPE_YEAR = 0x00646179; + private static final int SHORT_TYPE_ARTIST = 0x00415254; + private static final int SHORT_TYPE_ENCODER = 0x00746f6f; + private static final int SHORT_TYPE_ALBUM = 0x00616c62; + private static final int SHORT_TYPE_COMPOSER_1 = 0x00636f6d; + private static final int SHORT_TYPE_COMPOSER_2 = 0x00777274; + private static final int SHORT_TYPE_LYRICS = 0x006c7972; + private static final int SHORT_TYPE_GENRE = 0x0067656e; + + // Codes that have equivalent ID3 frames. + private static final int TYPE_COVER_ART = 0x636f7672; + private static final int TYPE_GENRE = 0x676e7265; + private static final int TYPE_GROUPING = 0x00677270; + private static final int TYPE_DISK_NUMBER = 0x6469736b; + private static final int TYPE_TRACK_NUMBER = 0x74726b6e; + private static final int TYPE_TEMPO = 0x746d706f; + private static final int TYPE_COMPILATION = 0x6370696c; + private static final int TYPE_ALBUM_ARTIST = 0x61415254; + private static final int TYPE_SORT_TRACK_NAME = 0x736f6e6d; + private static final int TYPE_SORT_ALBUM = 0x736f616c; + private static final int TYPE_SORT_ARTIST = 0x736f6172; + private static final int TYPE_SORT_ALBUM_ARTIST = 0x736f6161; + private static final int TYPE_SORT_COMPOSER = 0x736f636f; + + // Types that do not have equivalent ID3 frames. + private static final int TYPE_RATING = 0x72746e67; + private static final int TYPE_GAPLESS_ALBUM = 0x70676170; + private static final int TYPE_TV_SORT_SHOW = 0x736f736e; + private static final int TYPE_TV_SHOW = 0x74767368; + + // Type for items that are intended for internal use by the player. + private static final int TYPE_INTERNAL = 0x2d2d2d2d; + + private static final int PICTURE_TYPE_FRONT_COVER = 3; + + // Standard genres. + @VisibleForTesting + /* package */ static final String[] STANDARD_GENRES = + new String[] { + // These are the official ID3v1 genres. + "Blues", + "Classic Rock", + "Country", + "Dance", + "Disco", + "Funk", + "Grunge", + "Hip-Hop", + "Jazz", + "Metal", + "New Age", + "Oldies", + "Other", + "Pop", + "R&B", + "Rap", + "Reggae", + "Rock", + "Techno", + "Industrial", + "Alternative", + "Ska", + "Death Metal", + "Pranks", + "Soundtrack", + "Euro-Techno", + "Ambient", + "Trip-Hop", + "Vocal", + "Jazz+Funk", + "Fusion", + "Trance", + "Classical", + "Instrumental", + "Acid", + "House", + "Game", + "Sound Clip", + "Gospel", + "Noise", + "AlternRock", + "Bass", + "Soul", + "Punk", + "Space", + "Meditative", + "Instrumental Pop", + "Instrumental Rock", + "Ethnic", + "Gothic", + "Darkwave", + "Techno-Industrial", + "Electronic", + "Pop-Folk", + "Eurodance", + "Dream", + "Southern Rock", + "Comedy", + "Cult", + "Gangsta", + "Top 40", + "Christian Rap", + "Pop/Funk", + "Jungle", + "Native American", + "Cabaret", + "New Wave", + "Psychadelic", + "Rave", + "Showtunes", + "Trailer", + "Lo-Fi", + "Tribal", + "Acid Punk", + "Acid Jazz", + "Polka", + "Retro", + "Musical", + "Rock & Roll", + "Hard Rock", + // Genres made up by the authors of Winamp (v1.91) and later added to the ID3 spec. + "Folk", + "Folk-Rock", + "National Folk", + "Swing", + "Fast Fusion", + "Bebob", + "Latin", + "Revival", + "Celtic", + "Bluegrass", + "Avantgarde", + "Gothic Rock", + "Progressive Rock", + "Psychedelic Rock", + "Symphonic Rock", + "Slow Rock", + "Big Band", + "Chorus", + "Easy Listening", + "Acoustic", + "Humour", + "Speech", + "Chanson", + "Opera", + "Chamber Music", + "Sonata", + "Symphony", + "Booty Bass", + "Primus", + "Porn Groove", + "Satire", + "Slow Jam", + "Club", + "Tango", + "Samba", + "Folklore", + "Ballad", + "Power Ballad", + "Rhythmic Soul", + "Freestyle", + "Duet", + "Punk Rock", + "Drum Solo", + "A capella", + "Euro-House", + "Dance Hall", + // Genres made up by the authors of Winamp (v1.91) but have not been added to the ID3 spec. + "Goa", + "Drum & Bass", + "Club-House", + "Hardcore", + "Terror", + "Indie", + "BritPop", + "Afro-Punk", + "Polsk Punk", + "Beat", + "Christian Gangsta Rap", + "Heavy Metal", + "Black Metal", + "Crossover", + "Contemporary Christian", + "Christian Rock", + "Merengue", + "Salsa", + "Thrash Metal", + "Anime", + "Jpop", + "Synthpop", + // Genres made up by the authors of Winamp (v5.6) but have not been added to the ID3 spec. + "Abstract", + "Art Rock", + "Baroque", + "Bhangra", + "Big beat", + "Breakbeat", + "Chillout", + "Downtempo", + "Dub", + "EBM", + "Eclectic", + "Electro", + "Electroclash", + "Emo", + "Experimental", + "Garage", + "Global", + "IDM", + "Illbient", + "Industro-Goth", + "Jam Band", + "Krautrock", + "Leftfield", + "Lounge", + "Math Rock", + "New Romantic", + "Nu-Breakz", + "Post-Punk", + "Post-Rock", + "Psytrance", + "Shoegaze", + "Space Rock", + "Trop Rock", + "World Music", + "Neoclassical", + "Audiobook", + "Audio theatre", + "Neue Deutsche Welle", + "Podcast", + "Indie-Rock", + "G-Funk", + "Dubstep", + "Garage Rock", + "Psybient" + }; + + private static final String LANGUAGE_UNDEFINED = "und"; + + private static final int TYPE_TOP_BYTE_COPYRIGHT = 0xA9; + private static final int TYPE_TOP_BYTE_REPLACEMENT = 0xFD; // Truncated value of \uFFFD. + + private static final String MDTA_KEY_ANDROID_CAPTURE_FPS = "com.android.capture.fps"; + private static final int MDTA_TYPE_INDICATOR_FLOAT = 23; + + private MetadataUtil() {} + + /** + * Returns a {@link Format} that is the same as the input format but includes information from the + * specified sources of metadata. + */ + public static Format getFormatWithMetadata( + int trackType, + Format format, + @Nullable Metadata udtaMetadata, + @Nullable Metadata mdtaMetadata, + GaplessInfoHolder gaplessInfoHolder) { + if (trackType == C.TRACK_TYPE_AUDIO) { + if (gaplessInfoHolder.hasGaplessInfo()) { + format = + format.copyWithGaplessInfo( + gaplessInfoHolder.encoderDelay, gaplessInfoHolder.encoderPadding); + } + // We assume all udta metadata is associated with the audio track. + if (udtaMetadata != null) { + format = format.copyWithMetadata(udtaMetadata); + } + } else if (trackType == C.TRACK_TYPE_VIDEO && mdtaMetadata != null) { + // Populate only metadata keys that are known to be specific to video. + for (int i = 0; i < mdtaMetadata.length(); i++) { + Metadata.Entry entry = mdtaMetadata.get(i); + if (entry instanceof MdtaMetadataEntry) { + MdtaMetadataEntry mdtaMetadataEntry = (MdtaMetadataEntry) entry; + if (MDTA_KEY_ANDROID_CAPTURE_FPS.equals(mdtaMetadataEntry.key) + && mdtaMetadataEntry.typeIndicator == MDTA_TYPE_INDICATOR_FLOAT) { + try { + float fps = ByteBuffer.wrap(mdtaMetadataEntry.value).asFloatBuffer().get(); + format = format.copyWithFrameRate(fps); + format = format.copyWithMetadata(new Metadata(mdtaMetadataEntry)); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring invalid framerate"); + } + } + } + } + } + return format; + } + + /** + * Parses a single userdata ilst element from a {@link ParsableByteArray}. The element is read + * starting from the current position of the {@link ParsableByteArray}, and the position is + * advanced by the size of the element. The position is advanced even if the element's type is + * unrecognized. + * + * @param ilst Holds the data to be parsed. + * @return The parsed element, or null if the element's type was not recognized. + */ + @Nullable + public static Metadata.Entry parseIlstElement(ParsableByteArray ilst) { + int position = ilst.getPosition(); + int endPosition = position + ilst.readInt(); + int type = ilst.readInt(); + int typeTopByte = (type >> 24) & 0xFF; + try { + if (typeTopByte == TYPE_TOP_BYTE_COPYRIGHT || typeTopByte == TYPE_TOP_BYTE_REPLACEMENT) { + int shortType = type & 0x00FFFFFF; + if (shortType == SHORT_TYPE_COMMENT) { + return parseCommentAttribute(type, ilst); + } else if (shortType == SHORT_TYPE_NAME_1 || shortType == SHORT_TYPE_NAME_2) { + return parseTextAttribute(type, "TIT2", ilst); + } else if (shortType == SHORT_TYPE_COMPOSER_1 || shortType == SHORT_TYPE_COMPOSER_2) { + return parseTextAttribute(type, "TCOM", ilst); + } else if (shortType == SHORT_TYPE_YEAR) { + return parseTextAttribute(type, "TDRC", ilst); + } else if (shortType == SHORT_TYPE_ARTIST) { + return parseTextAttribute(type, "TPE1", ilst); + } else if (shortType == SHORT_TYPE_ENCODER) { + return parseTextAttribute(type, "TSSE", ilst); + } else if (shortType == SHORT_TYPE_ALBUM) { + return parseTextAttribute(type, "TALB", ilst); + } else if (shortType == SHORT_TYPE_LYRICS) { + return parseTextAttribute(type, "USLT", ilst); + } else if (shortType == SHORT_TYPE_GENRE) { + return parseTextAttribute(type, "TCON", ilst); + } else if (shortType == TYPE_GROUPING) { + return parseTextAttribute(type, "TIT1", ilst); + } + } else if (type == TYPE_GENRE) { + return parseStandardGenreAttribute(ilst); + } else if (type == TYPE_DISK_NUMBER) { + return parseIndexAndCountAttribute(type, "TPOS", ilst); + } else if (type == TYPE_TRACK_NUMBER) { + return parseIndexAndCountAttribute(type, "TRCK", ilst); + } else if (type == TYPE_TEMPO) { + return parseUint8Attribute(type, "TBPM", ilst, true, false); + } else if (type == TYPE_COMPILATION) { + return parseUint8Attribute(type, "TCMP", ilst, true, true); + } else if (type == TYPE_COVER_ART) { + return parseCoverArt(ilst); + } else if (type == TYPE_ALBUM_ARTIST) { + return parseTextAttribute(type, "TPE2", ilst); + } else if (type == TYPE_SORT_TRACK_NAME) { + return parseTextAttribute(type, "TSOT", ilst); + } else if (type == TYPE_SORT_ALBUM) { + return parseTextAttribute(type, "TSO2", ilst); + } else if (type == TYPE_SORT_ARTIST) { + return parseTextAttribute(type, "TSOA", ilst); + } else if (type == TYPE_SORT_ALBUM_ARTIST) { + return parseTextAttribute(type, "TSOP", ilst); + } else if (type == TYPE_SORT_COMPOSER) { + return parseTextAttribute(type, "TSOC", ilst); + } else if (type == TYPE_RATING) { + return parseUint8Attribute(type, "ITUNESADVISORY", ilst, false, false); + } else if (type == TYPE_GAPLESS_ALBUM) { + return parseUint8Attribute(type, "ITUNESGAPLESS", ilst, false, true); + } else if (type == TYPE_TV_SORT_SHOW) { + return parseTextAttribute(type, "TVSHOWSORT", ilst); + } else if (type == TYPE_TV_SHOW) { + return parseTextAttribute(type, "TVSHOW", ilst); + } else if (type == TYPE_INTERNAL) { + return parseInternalAttribute(ilst, endPosition); + } + Log.d(TAG, "Skipped unknown metadata entry: " + Atom.getAtomTypeString(type)); + return null; + } finally { + ilst.setPosition(endPosition); + } + } + + /** + * Parses an 'mdta' metadata entry starting at the current position in an ilst box. + * + * @param ilst The ilst box. + * @param endPosition The end position of the entry in the ilst box. + * @param key The mdta metadata entry key for the entry. + * @return The parsed element, or null if the entry wasn't recognized. + */ + @Nullable + public static MdtaMetadataEntry parseMdtaMetadataEntryFromIlst( + ParsableByteArray ilst, int endPosition, String key) { + int atomPosition; + while ((atomPosition = ilst.getPosition()) < endPosition) { + int atomSize = ilst.readInt(); + int atomType = ilst.readInt(); + if (atomType == Atom.TYPE_data) { + int typeIndicator = ilst.readInt(); + int localeIndicator = ilst.readInt(); + int dataSize = atomSize - 16; + byte[] value = new byte[dataSize]; + ilst.readBytes(value, 0, dataSize); + return new MdtaMetadataEntry(key, value, localeIndicator, typeIndicator); + } + ilst.setPosition(atomPosition + atomSize); + } + return null; + } + + @Nullable + private static TextInformationFrame parseTextAttribute( + int type, String id, ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(atomSize - 16); + return new TextInformationFrame(id, /* description= */ null, value); + } + Log.w(TAG, "Failed to parse text attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + @Nullable + private static CommentFrame parseCommentAttribute(int type, ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(atomSize - 16); + return new CommentFrame(LANGUAGE_UNDEFINED, value, value); + } + Log.w(TAG, "Failed to parse comment attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + @Nullable + private static Id3Frame parseUint8Attribute( + int type, + String id, + ParsableByteArray data, + boolean isTextInformationFrame, + boolean isBoolean) { + int value = parseUint8AttributeValue(data); + if (isBoolean) { + value = Math.min(1, value); + } + if (value >= 0) { + return isTextInformationFrame + ? new TextInformationFrame(id, /* description= */ null, Integer.toString(value)) + : new CommentFrame(LANGUAGE_UNDEFINED, id, Integer.toString(value)); + } + Log.w(TAG, "Failed to parse uint8 attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + @Nullable + private static TextInformationFrame parseIndexAndCountAttribute( + int type, String attributeName, ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data && atomSize >= 22) { + data.skipBytes(10); // version (1), flags (3), empty (4), empty (2) + int index = data.readUnsignedShort(); + if (index > 0) { + String value = "" + index; + int count = data.readUnsignedShort(); + if (count > 0) { + value += "/" + count; + } + return new TextInformationFrame(attributeName, /* description= */ null, value); + } + } + Log.w(TAG, "Failed to parse index/count attribute: " + Atom.getAtomTypeString(type)); + return null; + } + + @Nullable + private static TextInformationFrame parseStandardGenreAttribute(ParsableByteArray data) { + int genreCode = parseUint8AttributeValue(data); + String genreString = (0 < genreCode && genreCode <= STANDARD_GENRES.length) + ? STANDARD_GENRES[genreCode - 1] : null; + if (genreString != null) { + return new TextInformationFrame("TCON", /* description= */ null, genreString); + } + Log.w(TAG, "Failed to parse standard genre code"); + return null; + } + + @Nullable + private static ApicFrame parseCoverArt(ParsableByteArray data) { + int atomSize = data.readInt(); + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + int fullVersionInt = data.readInt(); + int flags = Atom.parseFullAtomFlags(fullVersionInt); + String mimeType = flags == 13 ? "image/jpeg" : flags == 14 ? "image/png" : null; + if (mimeType == null) { + Log.w(TAG, "Unrecognized cover art flags: " + flags); + return null; + } + data.skipBytes(4); // empty (4) + byte[] pictureData = new byte[atomSize - 16]; + data.readBytes(pictureData, 0, pictureData.length); + return new ApicFrame( + mimeType, + /* description= */ null, + /* pictureType= */ PICTURE_TYPE_FRONT_COVER, + pictureData); + } + Log.w(TAG, "Failed to parse cover art attribute"); + return null; + } + + @Nullable + private static Id3Frame parseInternalAttribute(ParsableByteArray data, int endPosition) { + String domain = null; + String name = null; + int dataAtomPosition = -1; + int dataAtomSize = -1; + while (data.getPosition() < endPosition) { + int atomPosition = data.getPosition(); + int atomSize = data.readInt(); + int atomType = data.readInt(); + data.skipBytes(4); // version (1), flags (3) + if (atomType == Atom.TYPE_mean) { + domain = data.readNullTerminatedString(atomSize - 12); + } else if (atomType == Atom.TYPE_name) { + name = data.readNullTerminatedString(atomSize - 12); + } else { + if (atomType == Atom.TYPE_data) { + dataAtomPosition = atomPosition; + dataAtomSize = atomSize; + } + data.skipBytes(atomSize - 12); + } + } + if (domain == null || name == null || dataAtomPosition == -1) { + return null; + } + data.setPosition(dataAtomPosition); + data.skipBytes(16); // size (4), type (4), version (1), flags (3), empty (4) + String value = data.readNullTerminatedString(dataAtomSize - 16); + return new InternalFrame(domain, name, value); + } + + private static int parseUint8AttributeValue(ParsableByteArray data) { + data.skipBytes(4); // atomSize + int atomType = data.readInt(); + if (atomType == Atom.TYPE_data) { + data.skipBytes(8); // version (1), flags (3), empty (4) + return data.readUnsignedByte(); + } + Log.w(TAG, "Failed to parse uint8 attribute value"); + return -1; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java new file mode 100644 index 0000000000..254cad1eb1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Mp4Extractor.java @@ -0,0 +1,824 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.GaplessInfoHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.Atom.ContainerAtom; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; + +/** + * Extracts data from the MP4 container format. + */ +public final class Mp4Extractor implements Extractor, SeekMap { + + /** Factory for {@link Mp4Extractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new Mp4Extractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_WORKAROUND_IGNORE_EDIT_LISTS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_WORKAROUND_IGNORE_EDIT_LISTS}) + public @interface Flags {} + /** + * Flag to ignore any edit lists in the stream. + */ + public static final int FLAG_WORKAROUND_IGNORE_EDIT_LISTS = 1; + + /** Parser states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_READING_ATOM_HEADER, STATE_READING_ATOM_PAYLOAD, STATE_READING_SAMPLE}) + private @interface State {} + + private static final int STATE_READING_ATOM_HEADER = 0; + private static final int STATE_READING_ATOM_PAYLOAD = 1; + private static final int STATE_READING_SAMPLE = 2; + + /** Brand stored in the ftyp atom for QuickTime media. */ + private static final int BRAND_QUICKTIME = 0x71742020; + + /** + * When seeking within the source, if the offset is greater than or equal to this value (or the + * offset is negative), the source will be reloaded. + */ + private static final long RELOAD_MINIMUM_SEEK_DISTANCE = 256 * 1024; + + /** + * For poorly interleaved streams, the maximum byte difference one track is allowed to be read + * ahead before the source will be reloaded at a new position to read another track. + */ + private static final long MAXIMUM_READ_AHEAD_BYTES_STREAM = 10 * 1024 * 1024; + + private final @Flags int flags; + + // Temporary arrays. + private final ParsableByteArray nalStartCode; + private final ParsableByteArray nalLength; + private final ParsableByteArray scratch; + + private final ParsableByteArray atomHeader; + private final ArrayDeque<ContainerAtom> containerAtoms; + + @State private int parserState; + private int atomType; + private long atomSize; + private int atomHeaderBytesRead; + private ParsableByteArray atomData; + + private int sampleTrackIndex; + private int sampleBytesRead; + private int sampleBytesWritten; + private int sampleCurrentNalBytesRemaining; + + // Extractor outputs. + private ExtractorOutput extractorOutput; + private Mp4Track[] tracks; + private long[][] accumulatedSampleSizes; + private int firstVideoTrackIndex; + private long durationUs; + private boolean isQuickTime; + + /** + * Creates a new extractor for unfragmented MP4 streams. + */ + public Mp4Extractor() { + this(0); + } + + /** + * Creates a new extractor for unfragmented MP4 streams, using the specified flags to control the + * extractor's behavior. + * + * @param flags Flags that control the extractor's behavior. + */ + public Mp4Extractor(@Flags int flags) { + this.flags = flags; + atomHeader = new ParsableByteArray(Atom.LONG_HEADER_SIZE); + containerAtoms = new ArrayDeque<>(); + nalStartCode = new ParsableByteArray(NalUnitUtil.NAL_START_CODE); + nalLength = new ParsableByteArray(4); + scratch = new ParsableByteArray(); + sampleTrackIndex = C.INDEX_UNSET; + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return Sniffer.sniffUnfragmented(input); + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + } + + @Override + public void seek(long position, long timeUs) { + containerAtoms.clear(); + atomHeaderBytesRead = 0; + sampleTrackIndex = C.INDEX_UNSET; + sampleBytesRead = 0; + sampleBytesWritten = 0; + sampleCurrentNalBytesRemaining = 0; + if (position == 0) { + enterReadingAtomHeaderState(); + } else if (tracks != null) { + updateSampleIndices(timeUs); + } + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + while (true) { + switch (parserState) { + case STATE_READING_ATOM_HEADER: + if (!readAtomHeader(input)) { + return RESULT_END_OF_INPUT; + } + break; + case STATE_READING_ATOM_PAYLOAD: + if (readAtomPayload(input, seekPosition)) { + return RESULT_SEEK; + } + break; + case STATE_READING_SAMPLE: + return readSample(input, seekPosition); + default: + throw new IllegalStateException(); + } + } + } + + // SeekMap implementation. + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + if (tracks.length == 0) { + return new SeekPoints(SeekPoint.START); + } + + long firstTimeUs; + long firstOffset; + long secondTimeUs = C.TIME_UNSET; + long secondOffset = C.POSITION_UNSET; + + // If we have a video track, use it to establish one or two seek points. + if (firstVideoTrackIndex != C.INDEX_UNSET) { + TrackSampleTable sampleTable = tracks[firstVideoTrackIndex].sampleTable; + int sampleIndex = getSynchronizationSampleIndex(sampleTable, timeUs); + if (sampleIndex == C.INDEX_UNSET) { + return new SeekPoints(SeekPoint.START); + } + long sampleTimeUs = sampleTable.timestampsUs[sampleIndex]; + firstTimeUs = sampleTimeUs; + firstOffset = sampleTable.offsets[sampleIndex]; + if (sampleTimeUs < timeUs && sampleIndex < sampleTable.sampleCount - 1) { + int secondSampleIndex = sampleTable.getIndexOfLaterOrEqualSynchronizationSample(timeUs); + if (secondSampleIndex != C.INDEX_UNSET && secondSampleIndex != sampleIndex) { + secondTimeUs = sampleTable.timestampsUs[secondSampleIndex]; + secondOffset = sampleTable.offsets[secondSampleIndex]; + } + } + } else { + firstTimeUs = timeUs; + firstOffset = Long.MAX_VALUE; + } + + // Take into account other tracks. + for (int i = 0; i < tracks.length; i++) { + if (i != firstVideoTrackIndex) { + TrackSampleTable sampleTable = tracks[i].sampleTable; + firstOffset = maybeAdjustSeekOffset(sampleTable, firstTimeUs, firstOffset); + if (secondTimeUs != C.TIME_UNSET) { + secondOffset = maybeAdjustSeekOffset(sampleTable, secondTimeUs, secondOffset); + } + } + } + + SeekPoint firstSeekPoint = new SeekPoint(firstTimeUs, firstOffset); + if (secondTimeUs == C.TIME_UNSET) { + return new SeekPoints(firstSeekPoint); + } else { + SeekPoint secondSeekPoint = new SeekPoint(secondTimeUs, secondOffset); + return new SeekPoints(firstSeekPoint, secondSeekPoint); + } + } + + // Private methods. + + private void enterReadingAtomHeaderState() { + parserState = STATE_READING_ATOM_HEADER; + atomHeaderBytesRead = 0; + } + + private boolean readAtomHeader(ExtractorInput input) throws IOException, InterruptedException { + if (atomHeaderBytesRead == 0) { + // Read the standard length atom header. + if (!input.readFully(atomHeader.data, 0, Atom.HEADER_SIZE, true)) { + return false; + } + atomHeaderBytesRead = Atom.HEADER_SIZE; + atomHeader.setPosition(0); + atomSize = atomHeader.readUnsignedInt(); + atomType = atomHeader.readInt(); + } + + if (atomSize == Atom.DEFINES_LARGE_SIZE) { + // Read the large size. + int headerBytesRemaining = Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE; + input.readFully(atomHeader.data, Atom.HEADER_SIZE, headerBytesRemaining); + atomHeaderBytesRead += headerBytesRemaining; + atomSize = atomHeader.readUnsignedLongToLong(); + } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { + // The atom extends to the end of the file. Note that if the atom is within a container we can + // work out its size even if the input length is unknown. + long endPosition = input.getLength(); + if (endPosition == C.LENGTH_UNSET && !containerAtoms.isEmpty()) { + endPosition = containerAtoms.peek().endPosition; + } + if (endPosition != C.LENGTH_UNSET) { + atomSize = endPosition - input.getPosition() + atomHeaderBytesRead; + } + } + + if (atomSize < atomHeaderBytesRead) { + throw new ParserException("Atom size less than header length (unsupported)."); + } + + if (shouldParseContainerAtom(atomType)) { + long endPosition = input.getPosition() + atomSize - atomHeaderBytesRead; + if (atomSize != atomHeaderBytesRead && atomType == Atom.TYPE_meta) { + maybeSkipRemainingMetaAtomHeaderBytes(input); + } + containerAtoms.push(new ContainerAtom(atomType, endPosition)); + if (atomSize == atomHeaderBytesRead) { + processAtomEnded(endPosition); + } else { + // Start reading the first child atom. + enterReadingAtomHeaderState(); + } + } else if (shouldParseLeafAtom(atomType)) { + // We don't support parsing of leaf atoms that define extended atom sizes, or that have + // lengths greater than Integer.MAX_VALUE. + Assertions.checkState(atomHeaderBytesRead == Atom.HEADER_SIZE); + Assertions.checkState(atomSize <= Integer.MAX_VALUE); + atomData = new ParsableByteArray((int) atomSize); + System.arraycopy(atomHeader.data, 0, atomData.data, 0, Atom.HEADER_SIZE); + parserState = STATE_READING_ATOM_PAYLOAD; + } else { + atomData = null; + parserState = STATE_READING_ATOM_PAYLOAD; + } + + return true; + } + + /** + * Processes the atom payload. If {@link #atomData} is null and the size is at or above the + * threshold {@link #RELOAD_MINIMUM_SEEK_DISTANCE}, {@code true} is returned and the caller should + * restart loading at the position in {@code positionHolder}. Otherwise, the atom is read/skipped. + */ + private boolean readAtomPayload(ExtractorInput input, PositionHolder positionHolder) + throws IOException, InterruptedException { + long atomPayloadSize = atomSize - atomHeaderBytesRead; + long atomEndPosition = input.getPosition() + atomPayloadSize; + boolean seekRequired = false; + if (atomData != null) { + input.readFully(atomData.data, atomHeaderBytesRead, (int) atomPayloadSize); + if (atomType == Atom.TYPE_ftyp) { + isQuickTime = processFtypAtom(atomData); + } else if (!containerAtoms.isEmpty()) { + containerAtoms.peek().add(new Atom.LeafAtom(atomType, atomData)); + } + } else { + // We don't need the data. Skip or seek, depending on how large the atom is. + if (atomPayloadSize < RELOAD_MINIMUM_SEEK_DISTANCE) { + input.skipFully((int) atomPayloadSize); + } else { + positionHolder.position = input.getPosition() + atomPayloadSize; + seekRequired = true; + } + } + processAtomEnded(atomEndPosition); + return seekRequired && parserState != STATE_READING_SAMPLE; + } + + private void processAtomEnded(long atomEndPosition) throws ParserException { + while (!containerAtoms.isEmpty() && containerAtoms.peek().endPosition == atomEndPosition) { + Atom.ContainerAtom containerAtom = containerAtoms.pop(); + if (containerAtom.type == Atom.TYPE_moov) { + // We've reached the end of the moov atom. Process it and prepare to read samples. + processMoovAtom(containerAtom); + containerAtoms.clear(); + parserState = STATE_READING_SAMPLE; + } else if (!containerAtoms.isEmpty()) { + containerAtoms.peek().add(containerAtom); + } + } + if (parserState != STATE_READING_SAMPLE) { + enterReadingAtomHeaderState(); + } + } + + /** + * Updates the stored track metadata to reflect the contents of the specified moov atom. + */ + private void processMoovAtom(ContainerAtom moov) throws ParserException { + int firstVideoTrackIndex = C.INDEX_UNSET; + long durationUs = C.TIME_UNSET; + List<Mp4Track> tracks = new ArrayList<>(); + + // Process metadata. + Metadata udtaMetadata = null; + GaplessInfoHolder gaplessInfoHolder = new GaplessInfoHolder(); + Atom.LeafAtom udta = moov.getLeafAtomOfType(Atom.TYPE_udta); + if (udta != null) { + udtaMetadata = AtomParsers.parseUdta(udta, isQuickTime); + if (udtaMetadata != null) { + gaplessInfoHolder.setFromMetadata(udtaMetadata); + } + } + Metadata mdtaMetadata = null; + Atom.ContainerAtom meta = moov.getContainerAtomOfType(Atom.TYPE_meta); + if (meta != null) { + mdtaMetadata = AtomParsers.parseMdtaFromMeta(meta); + } + + boolean ignoreEditLists = (flags & FLAG_WORKAROUND_IGNORE_EDIT_LISTS) != 0; + ArrayList<TrackSampleTable> trackSampleTables = + getTrackSampleTables(moov, gaplessInfoHolder, ignoreEditLists); + + int trackCount = trackSampleTables.size(); + for (int i = 0; i < trackCount; i++) { + TrackSampleTable trackSampleTable = trackSampleTables.get(i); + Track track = trackSampleTable.track; + long trackDurationUs = + track.durationUs != C.TIME_UNSET ? track.durationUs : trackSampleTable.durationUs; + durationUs = Math.max(durationUs, trackDurationUs); + Mp4Track mp4Track = new Mp4Track(track, trackSampleTable, + extractorOutput.track(i, track.type)); + + // Each sample has up to three bytes of overhead for the start code that replaces its length. + // Allow ten source samples per output sample, like the platform extractor. + int maxInputSize = trackSampleTable.maximumSize + 3 * 10; + Format format = track.format.copyWithMaxInputSize(maxInputSize); + if (track.type == C.TRACK_TYPE_VIDEO + && trackDurationUs > 0 + && trackSampleTable.sampleCount > 1) { + float frameRate = trackSampleTable.sampleCount / (trackDurationUs / 1000000f); + format = format.copyWithFrameRate(frameRate); + } + format = + MetadataUtil.getFormatWithMetadata( + track.type, format, udtaMetadata, mdtaMetadata, gaplessInfoHolder); + mp4Track.trackOutput.format(format); + + if (track.type == C.TRACK_TYPE_VIDEO && firstVideoTrackIndex == C.INDEX_UNSET) { + firstVideoTrackIndex = tracks.size(); + } + tracks.add(mp4Track); + } + this.firstVideoTrackIndex = firstVideoTrackIndex; + this.durationUs = durationUs; + this.tracks = tracks.toArray(new Mp4Track[0]); + accumulatedSampleSizes = calculateAccumulatedSampleSizes(this.tracks); + + extractorOutput.endTracks(); + extractorOutput.seekMap(this); + } + + private ArrayList<TrackSampleTable> getTrackSampleTables( + ContainerAtom moov, GaplessInfoHolder gaplessInfoHolder, boolean ignoreEditLists) + throws ParserException { + ArrayList<TrackSampleTable> trackSampleTables = new ArrayList<>(); + for (int i = 0; i < moov.containerChildren.size(); i++) { + Atom.ContainerAtom atom = moov.containerChildren.get(i); + if (atom.type != Atom.TYPE_trak) { + continue; + } + Track track = + AtomParsers.parseTrak( + atom, + moov.getLeafAtomOfType(Atom.TYPE_mvhd), + /* duration= */ C.TIME_UNSET, + /* drmInitData= */ null, + ignoreEditLists, + isQuickTime); + if (track == null) { + continue; + } + Atom.ContainerAtom stblAtom = + atom.getContainerAtomOfType(Atom.TYPE_mdia) + .getContainerAtomOfType(Atom.TYPE_minf) + .getContainerAtomOfType(Atom.TYPE_stbl); + TrackSampleTable trackSampleTable = AtomParsers.parseStbl(track, stblAtom, gaplessInfoHolder); + if (trackSampleTable.sampleCount == 0) { + continue; + } + trackSampleTables.add(trackSampleTable); + } + return trackSampleTables; + } + + /** + * Attempts to extract the next sample in the current mdat atom for the specified track. + * <p> + * Returns {@link #RESULT_SEEK} if the source should be reloaded from the position in + * {@code positionHolder}. + * <p> + * Returns {@link #RESULT_END_OF_INPUT} if no samples are left. Otherwise, returns + * {@link #RESULT_CONTINUE}. + * + * @param input The {@link ExtractorInput} from which to read data. + * @param positionHolder If {@link #RESULT_SEEK} is returned, this holder is updated to hold the + * position of the required data. + * @return One of the {@code RESULT_*} flags in {@link Extractor}. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread is interrupted. + */ + private int readSample(ExtractorInput input, PositionHolder positionHolder) + throws IOException, InterruptedException { + long inputPosition = input.getPosition(); + if (sampleTrackIndex == C.INDEX_UNSET) { + sampleTrackIndex = getTrackIndexOfNextReadSample(inputPosition); + if (sampleTrackIndex == C.INDEX_UNSET) { + return RESULT_END_OF_INPUT; + } + } + Mp4Track track = tracks[sampleTrackIndex]; + TrackOutput trackOutput = track.trackOutput; + int sampleIndex = track.sampleIndex; + long position = track.sampleTable.offsets[sampleIndex]; + int sampleSize = track.sampleTable.sizes[sampleIndex]; + long skipAmount = position - inputPosition + sampleBytesRead; + if (skipAmount < 0 || skipAmount >= RELOAD_MINIMUM_SEEK_DISTANCE) { + positionHolder.position = position; + return RESULT_SEEK; + } + if (track.track.sampleTransformation == Track.TRANSFORMATION_CEA608_CDAT) { + // The sample information is contained in a cdat atom. The header must be discarded for + // committing. + skipAmount += Atom.HEADER_SIZE; + sampleSize -= Atom.HEADER_SIZE; + } + input.skipFully((int) skipAmount); + if (track.track.nalUnitLengthFieldLength != 0) { + // Zero the top three bytes of the array that we'll use to decode nal unit lengths, in case + // they're only 1 or 2 bytes long. + byte[] nalLengthData = nalLength.data; + nalLengthData[0] = 0; + nalLengthData[1] = 0; + nalLengthData[2] = 0; + int nalUnitLengthFieldLength = track.track.nalUnitLengthFieldLength; + int nalUnitLengthFieldLengthDiff = 4 - track.track.nalUnitLengthFieldLength; + // NAL units are length delimited, but the decoder requires start code delimited units. + // Loop until we've written the sample to the track output, replacing length delimiters with + // start codes as we encounter them. + while (sampleBytesWritten < sampleSize) { + if (sampleCurrentNalBytesRemaining == 0) { + // Read the NAL length so that we know where we find the next one. + input.readFully(nalLengthData, nalUnitLengthFieldLengthDiff, nalUnitLengthFieldLength); + sampleBytesRead += nalUnitLengthFieldLength; + nalLength.setPosition(0); + int nalLengthInt = nalLength.readInt(); + if (nalLengthInt < 0) { + throw new ParserException("Invalid NAL length"); + } + sampleCurrentNalBytesRemaining = nalLengthInt; + // Write a start code for the current NAL unit. + nalStartCode.setPosition(0); + trackOutput.sampleData(nalStartCode, 4); + sampleBytesWritten += 4; + sampleSize += nalUnitLengthFieldLengthDiff; + } else { + // Write the payload of the NAL unit. + int writtenBytes = trackOutput.sampleData(input, sampleCurrentNalBytesRemaining, false); + sampleBytesRead += writtenBytes; + sampleBytesWritten += writtenBytes; + sampleCurrentNalBytesRemaining -= writtenBytes; + } + } + } else { + if (MimeTypes.AUDIO_AC4.equals(track.track.format.sampleMimeType)) { + if (sampleBytesWritten == 0) { + Ac4Util.getAc4SampleHeader(sampleSize, scratch); + trackOutput.sampleData(scratch, Ac4Util.SAMPLE_HEADER_SIZE); + sampleBytesWritten += Ac4Util.SAMPLE_HEADER_SIZE; + } + sampleSize += Ac4Util.SAMPLE_HEADER_SIZE; + } + while (sampleBytesWritten < sampleSize) { + int writtenBytes = trackOutput.sampleData(input, sampleSize - sampleBytesWritten, false); + sampleBytesRead += writtenBytes; + sampleBytesWritten += writtenBytes; + sampleCurrentNalBytesRemaining -= writtenBytes; + } + } + trackOutput.sampleMetadata(track.sampleTable.timestampsUs[sampleIndex], + track.sampleTable.flags[sampleIndex], sampleSize, 0, null); + track.sampleIndex++; + sampleTrackIndex = C.INDEX_UNSET; + sampleBytesRead = 0; + sampleBytesWritten = 0; + sampleCurrentNalBytesRemaining = 0; + return RESULT_CONTINUE; + } + + /** + * Returns the index of the track that contains the next sample to be read, or {@link + * C#INDEX_UNSET} if no samples remain. + * + * <p>The preferred choice is the sample with the smallest offset not requiring a source reload, + * or if not available the sample with the smallest overall offset to avoid subsequent source + * reloads. + * + * <p>To deal with poor sample interleaving, we also check whether the required memory to catch up + * with the next logical sample (based on sample time) exceeds {@link + * #MAXIMUM_READ_AHEAD_BYTES_STREAM}. If this is the case, we continue with this sample even + * though it may require a source reload. + */ + private int getTrackIndexOfNextReadSample(long inputPosition) { + long preferredSkipAmount = Long.MAX_VALUE; + boolean preferredRequiresReload = true; + int preferredTrackIndex = C.INDEX_UNSET; + long preferredAccumulatedBytes = Long.MAX_VALUE; + long minAccumulatedBytes = Long.MAX_VALUE; + boolean minAccumulatedBytesRequiresReload = true; + int minAccumulatedBytesTrackIndex = C.INDEX_UNSET; + for (int trackIndex = 0; trackIndex < tracks.length; trackIndex++) { + Mp4Track track = tracks[trackIndex]; + int sampleIndex = track.sampleIndex; + if (sampleIndex == track.sampleTable.sampleCount) { + continue; + } + long sampleOffset = track.sampleTable.offsets[sampleIndex]; + long sampleAccumulatedBytes = accumulatedSampleSizes[trackIndex][sampleIndex]; + long skipAmount = sampleOffset - inputPosition; + boolean requiresReload = skipAmount < 0 || skipAmount >= RELOAD_MINIMUM_SEEK_DISTANCE; + if ((!requiresReload && preferredRequiresReload) + || (requiresReload == preferredRequiresReload && skipAmount < preferredSkipAmount)) { + preferredRequiresReload = requiresReload; + preferredSkipAmount = skipAmount; + preferredTrackIndex = trackIndex; + preferredAccumulatedBytes = sampleAccumulatedBytes; + } + if (sampleAccumulatedBytes < minAccumulatedBytes) { + minAccumulatedBytes = sampleAccumulatedBytes; + minAccumulatedBytesRequiresReload = requiresReload; + minAccumulatedBytesTrackIndex = trackIndex; + } + } + return minAccumulatedBytes == Long.MAX_VALUE + || !minAccumulatedBytesRequiresReload + || preferredAccumulatedBytes < minAccumulatedBytes + MAXIMUM_READ_AHEAD_BYTES_STREAM + ? preferredTrackIndex + : minAccumulatedBytesTrackIndex; + } + + /** + * Updates every track's sample index to point its latest sync sample before/at {@code timeUs}. + */ + private void updateSampleIndices(long timeUs) { + for (Mp4Track track : tracks) { + TrackSampleTable sampleTable = track.sampleTable; + int sampleIndex = sampleTable.getIndexOfEarlierOrEqualSynchronizationSample(timeUs); + if (sampleIndex == C.INDEX_UNSET) { + // Handle the case where the requested time is before the first synchronization sample. + sampleIndex = sampleTable.getIndexOfLaterOrEqualSynchronizationSample(timeUs); + } + track.sampleIndex = sampleIndex; + } + } + + /** + * Possibly skips the version and flags fields (1+3 byte) of a full meta atom of the {@code + * input}. + * + * <p>Atoms of type {@link Atom#TYPE_meta} are defined to be full atoms which have four additional + * bytes for a version and a flags field (see 4.2 'Object Structure' in ISO/IEC 14496-12:2005). + * QuickTime do not have such a full box structure. Since some of these files are encoded wrongly, + * we can't rely on the file type though. Instead we must check the 8 bytes after the common + * header bytes ourselves. + */ + private void maybeSkipRemainingMetaAtomHeaderBytes(ExtractorInput input) + throws IOException, InterruptedException { + scratch.reset(8); + // Peek the next 8 bytes which can be either + // (iso) [1 byte version + 3 bytes flags][4 byte size of next atom] + // (qt) [4 byte size of next atom ][4 byte hdlr atom type ] + // In case of (iso) we need to skip the next 4 bytes. + input.peekFully(scratch.data, 0, 8); + scratch.skipBytes(4); + if (scratch.readInt() == Atom.TYPE_hdlr) { + input.resetPeekPosition(); + } else { + input.skipFully(4); + } + } + + /** + * For each sample of each track, calculates accumulated size of all samples which need to be read + * before this sample can be used. + */ + private static long[][] calculateAccumulatedSampleSizes(Mp4Track[] tracks) { + long[][] accumulatedSampleSizes = new long[tracks.length][]; + int[] nextSampleIndex = new int[tracks.length]; + long[] nextSampleTimesUs = new long[tracks.length]; + boolean[] tracksFinished = new boolean[tracks.length]; + for (int i = 0; i < tracks.length; i++) { + accumulatedSampleSizes[i] = new long[tracks[i].sampleTable.sampleCount]; + nextSampleTimesUs[i] = tracks[i].sampleTable.timestampsUs[0]; + } + long accumulatedSampleSize = 0; + int finishedTracks = 0; + while (finishedTracks < tracks.length) { + long minTimeUs = Long.MAX_VALUE; + int minTimeTrackIndex = -1; + for (int i = 0; i < tracks.length; i++) { + if (!tracksFinished[i] && nextSampleTimesUs[i] <= minTimeUs) { + minTimeTrackIndex = i; + minTimeUs = nextSampleTimesUs[i]; + } + } + int trackSampleIndex = nextSampleIndex[minTimeTrackIndex]; + accumulatedSampleSizes[minTimeTrackIndex][trackSampleIndex] = accumulatedSampleSize; + accumulatedSampleSize += tracks[minTimeTrackIndex].sampleTable.sizes[trackSampleIndex]; + nextSampleIndex[minTimeTrackIndex] = ++trackSampleIndex; + if (trackSampleIndex < accumulatedSampleSizes[minTimeTrackIndex].length) { + nextSampleTimesUs[minTimeTrackIndex] = + tracks[minTimeTrackIndex].sampleTable.timestampsUs[trackSampleIndex]; + } else { + tracksFinished[minTimeTrackIndex] = true; + finishedTracks++; + } + } + return accumulatedSampleSizes; + } + + /** + * Adjusts a seek point offset to take into account the track with the given {@code sampleTable}, + * for a given {@code seekTimeUs}. + * + * @param sampleTable The sample table to use. + * @param seekTimeUs The seek time in microseconds. + * @param offset The current offset. + * @return The adjusted offset. + */ + private static long maybeAdjustSeekOffset( + TrackSampleTable sampleTable, long seekTimeUs, long offset) { + int sampleIndex = getSynchronizationSampleIndex(sampleTable, seekTimeUs); + if (sampleIndex == C.INDEX_UNSET) { + return offset; + } + long sampleOffset = sampleTable.offsets[sampleIndex]; + return Math.min(sampleOffset, offset); + } + + /** + * Returns the index of the synchronization sample before or at {@code timeUs}, or the index of + * the first synchronization sample if located after {@code timeUs}, or {@link C#INDEX_UNSET} if + * there are no synchronization samples in the table. + * + * @param sampleTable The sample table in which to locate a synchronization sample. + * @param timeUs A time in microseconds. + * @return The index of the synchronization sample before or at {@code timeUs}, or the index of + * the first synchronization sample if located after {@code timeUs}, or {@link C#INDEX_UNSET} + * if there are no synchronization samples in the table. + */ + private static int getSynchronizationSampleIndex(TrackSampleTable sampleTable, long timeUs) { + int sampleIndex = sampleTable.getIndexOfEarlierOrEqualSynchronizationSample(timeUs); + if (sampleIndex == C.INDEX_UNSET) { + // Handle the case where the requested time is before the first synchronization sample. + sampleIndex = sampleTable.getIndexOfLaterOrEqualSynchronizationSample(timeUs); + } + return sampleIndex; + } + + /** + * Process an ftyp atom to determine whether the media is QuickTime. + * + * @param atomData The ftyp atom data. + * @return Whether the media is QuickTime. + */ + private static boolean processFtypAtom(ParsableByteArray atomData) { + atomData.setPosition(Atom.HEADER_SIZE); + int majorBrand = atomData.readInt(); + if (majorBrand == BRAND_QUICKTIME) { + return true; + } + atomData.skipBytes(4); // minor_version + while (atomData.bytesLeft() > 0) { + if (atomData.readInt() == BRAND_QUICKTIME) { + return true; + } + } + return false; + } + + /** Returns whether the extractor should decode a leaf atom with type {@code atom}. */ + private static boolean shouldParseLeafAtom(int atom) { + return atom == Atom.TYPE_mdhd + || atom == Atom.TYPE_mvhd + || atom == Atom.TYPE_hdlr + || atom == Atom.TYPE_stsd + || atom == Atom.TYPE_stts + || atom == Atom.TYPE_stss + || atom == Atom.TYPE_ctts + || atom == Atom.TYPE_elst + || atom == Atom.TYPE_stsc + || atom == Atom.TYPE_stsz + || atom == Atom.TYPE_stz2 + || atom == Atom.TYPE_stco + || atom == Atom.TYPE_co64 + || atom == Atom.TYPE_tkhd + || atom == Atom.TYPE_ftyp + || atom == Atom.TYPE_udta + || atom == Atom.TYPE_keys + || atom == Atom.TYPE_ilst; + } + + /** Returns whether the extractor should decode a container atom with type {@code atom}. */ + private static boolean shouldParseContainerAtom(int atom) { + return atom == Atom.TYPE_moov + || atom == Atom.TYPE_trak + || atom == Atom.TYPE_mdia + || atom == Atom.TYPE_minf + || atom == Atom.TYPE_stbl + || atom == Atom.TYPE_edts + || atom == Atom.TYPE_meta; + } + + private static final class Mp4Track { + + public final Track track; + public final TrackSampleTable sampleTable; + public final TrackOutput trackOutput; + + public int sampleIndex; + + public Mp4Track(Track track, TrackSampleTable sampleTable, TrackOutput trackOutput) { + this.track = track; + this.sampleTable = sampleTable; + this.trackOutput = trackOutput; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/PsshAtomUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/PsshAtomUtil.java new file mode 100644 index 0000000000..ddb13aeb9c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/PsshAtomUtil.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; +import java.util.UUID; + +/** + * Utility methods for handling PSSH atoms. + */ +public final class PsshAtomUtil { + + private static final String TAG = "PsshAtomUtil"; + + private PsshAtomUtil() {} + + /** + * Builds a version 0 PSSH atom for a given system id, containing the given data. + * + * @param systemId The system id of the scheme. + * @param data The scheme specific data. + * @return The PSSH atom. + */ + public static byte[] buildPsshAtom(UUID systemId, @Nullable byte[] data) { + return buildPsshAtom(systemId, null, data); + } + + /** + * Builds a PSSH atom for the given system id, containing the given key ids and data. + * + * @param systemId The system id of the scheme. + * @param keyIds The key ids for a version 1 PSSH atom, or null for a version 0 PSSH atom. + * @param data The scheme specific data. + * @return The PSSH atom. + */ + // dereference of possibly-null reference keyId + @SuppressWarnings({"ParameterNotNullable", "nullness:dereference.of.nullable"}) + public static byte[] buildPsshAtom( + UUID systemId, @Nullable UUID[] keyIds, @Nullable byte[] data) { + int dataLength = data != null ? data.length : 0; + int psshBoxLength = Atom.FULL_HEADER_SIZE + 16 /* SystemId */ + 4 /* DataSize */ + dataLength; + if (keyIds != null) { + psshBoxLength += 4 /* KID_count */ + (keyIds.length * 16) /* KIDs */; + } + ByteBuffer psshBox = ByteBuffer.allocate(psshBoxLength); + psshBox.putInt(psshBoxLength); + psshBox.putInt(Atom.TYPE_pssh); + psshBox.putInt(keyIds != null ? 0x01000000 : 0 /* version=(buildV1Atom ? 1 : 0), flags=0 */); + psshBox.putLong(systemId.getMostSignificantBits()); + psshBox.putLong(systemId.getLeastSignificantBits()); + if (keyIds != null) { + psshBox.putInt(keyIds.length); + for (UUID keyId : keyIds) { + psshBox.putLong(keyId.getMostSignificantBits()); + psshBox.putLong(keyId.getLeastSignificantBits()); + } + } + if (data != null && data.length != 0) { + psshBox.putInt(data.length); + psshBox.put(data); + } // Else the last 4 bytes are a 0 DataSize. + return psshBox.array(); + } + + /** + * Returns whether the data is a valid PSSH atom. + * + * @param data The data to parse. + * @return Whether the data is a valid PSSH atom. + */ + public static boolean isPsshAtom(byte[] data) { + return parsePsshAtom(data) != null; + } + + /** + * Parses the UUID from a PSSH atom. Version 0 and 1 PSSH atoms are supported. + * + * <p>The UUID is only parsed if the data is a valid PSSH atom. + * + * @param atom The atom to parse. + * @return The parsed UUID. Null if the input is not a valid PSSH atom, or if the PSSH atom has an + * unsupported version. + */ + public static @Nullable UUID parseUuid(byte[] atom) { + PsshAtom parsedAtom = parsePsshAtom(atom); + if (parsedAtom == null) { + return null; + } + return parsedAtom.uuid; + } + + /** + * Parses the version from a PSSH atom. Version 0 and 1 PSSH atoms are supported. + * <p> + * The version is only parsed if the data is a valid PSSH atom. + * + * @param atom The atom to parse. + * @return The parsed version. -1 if the input is not a valid PSSH atom, or if the PSSH atom has + * an unsupported version. + */ + public static int parseVersion(byte[] atom) { + PsshAtom parsedAtom = parsePsshAtom(atom); + if (parsedAtom == null) { + return -1; + } + return parsedAtom.version; + } + + /** + * Parses the scheme specific data from a PSSH atom. Version 0 and 1 PSSH atoms are supported. + * + * <p>The scheme specific data is only parsed if the data is a valid PSSH atom matching the given + * UUID, or if the data is a valid PSSH atom of any type in the case that the passed UUID is null. + * + * @param atom The atom to parse. + * @param uuid The required UUID of the PSSH atom, or null to accept any UUID. + * @return The parsed scheme specific data. Null if the input is not a valid PSSH atom, or if the + * PSSH atom has an unsupported version, or if the PSSH atom does not match the passed UUID. + */ + public static @Nullable byte[] parseSchemeSpecificData(byte[] atom, UUID uuid) { + PsshAtom parsedAtom = parsePsshAtom(atom); + if (parsedAtom == null) { + return null; + } + if (uuid != null && !uuid.equals(parsedAtom.uuid)) { + Log.w(TAG, "UUID mismatch. Expected: " + uuid + ", got: " + parsedAtom.uuid + "."); + return null; + } + return parsedAtom.schemeData; + } + + /** + * Parses a PSSH atom. Version 0 and 1 PSSH atoms are supported. + * + * @param atom The atom to parse. + * @return The parsed PSSH atom. Null if the input is not a valid PSSH atom, or if the PSSH atom + * has an unsupported version. + */ + // TODO: Support parsing of the key ids for version 1 PSSH atoms. + private static @Nullable PsshAtom parsePsshAtom(byte[] atom) { + ParsableByteArray atomData = new ParsableByteArray(atom); + if (atomData.limit() < Atom.FULL_HEADER_SIZE + 16 /* UUID */ + 4 /* DataSize */) { + // Data too short. + return null; + } + atomData.setPosition(0); + int atomSize = atomData.readInt(); + if (atomSize != atomData.bytesLeft() + 4) { + // Not an atom, or incorrect atom size. + return null; + } + int atomType = atomData.readInt(); + if (atomType != Atom.TYPE_pssh) { + // Not an atom, or incorrect atom type. + return null; + } + int atomVersion = Atom.parseFullAtomVersion(atomData.readInt()); + if (atomVersion > 1) { + Log.w(TAG, "Unsupported pssh version: " + atomVersion); + return null; + } + UUID uuid = new UUID(atomData.readLong(), atomData.readLong()); + if (atomVersion == 1) { + int keyIdCount = atomData.readUnsignedIntToInt(); + atomData.skipBytes(16 * keyIdCount); + } + int dataSize = atomData.readUnsignedIntToInt(); + if (dataSize != atomData.bytesLeft()) { + // Incorrect dataSize. + return null; + } + byte[] data = new byte[dataSize]; + atomData.readBytes(data, 0, dataSize); + return new PsshAtom(uuid, atomVersion, data); + } + + // TODO: Consider exposing this and making parsePsshAtom public. + private static class PsshAtom { + + private final UUID uuid; + private final int version; + private final byte[] schemeData; + + public PsshAtom(UUID uuid, int version, byte[] schemeData) { + this.uuid = uuid; + this.version = version; + this.schemeData = schemeData; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Sniffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Sniffer.java new file mode 100644 index 0000000000..d58c2f06eb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Sniffer.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * Provides methods that peek data from an {@link ExtractorInput} and return whether the input + * appears to be in MP4 format. + */ +/* package */ final class Sniffer { + + /** The maximum number of bytes to peek when sniffing. */ + private static final int SEARCH_LENGTH = 4 * 1024; + + private static final int[] COMPATIBLE_BRANDS = + new int[] { + 0x69736f6d, // isom + 0x69736f32, // iso2 + 0x69736f33, // iso3 + 0x69736f34, // iso4 + 0x69736f35, // iso5 + 0x69736f36, // iso6 + 0x61766331, // avc1 + 0x68766331, // hvc1 + 0x68657631, // hev1 + 0x61763031, // av01 + 0x6d703431, // mp41 + 0x6d703432, // mp42 + 0x33673261, // 3g2a + 0x33673262, // 3g2b + 0x33677236, // 3gr6 + 0x33677336, // 3gs6 + 0x33676536, // 3ge6 + 0x33676736, // 3gg6 + 0x4d345620, // M4V[space] + 0x4d344120, // M4A[space] + 0x66347620, // f4v[space] + 0x6b646469, // kddi + 0x4d345650, // M4VP + 0x71742020, // qt[space][space], Apple QuickTime + 0x4d534e56, // MSNV, Sony PSP + 0x64627931, // dby1, Dolby Vision + }; + + /** + * Returns whether data peeked from the current position in {@code input} is consistent with the + * input being a fragmented MP4 file. + * + * @param input The extractor input from which to peek data. The peek position will be modified. + * @return Whether the input appears to be in the fragmented MP4 format. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + public static boolean sniffFragmented(ExtractorInput input) + throws IOException, InterruptedException { + return sniffInternal(input, true); + } + + /** + * Returns whether data peeked from the current position in {@code input} is consistent with the + * input being an unfragmented MP4 file. + * + * @param input The extractor input from which to peek data. The peek position will be modified. + * @return Whether the input appears to be in the unfragmented MP4 format. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + public static boolean sniffUnfragmented(ExtractorInput input) + throws IOException, InterruptedException { + return sniffInternal(input, false); + } + + private static boolean sniffInternal(ExtractorInput input, boolean fragmented) + throws IOException, InterruptedException { + long inputLength = input.getLength(); + int bytesToSearch = (int) (inputLength == C.LENGTH_UNSET || inputLength > SEARCH_LENGTH + ? SEARCH_LENGTH : inputLength); + + ParsableByteArray buffer = new ParsableByteArray(64); + int bytesSearched = 0; + boolean foundGoodFileType = false; + boolean isFragmented = false; + while (bytesSearched < bytesToSearch) { + // Read an atom header. + int headerSize = Atom.HEADER_SIZE; + buffer.reset(headerSize); + input.peekFully(buffer.data, 0, headerSize); + long atomSize = buffer.readUnsignedInt(); + int atomType = buffer.readInt(); + if (atomSize == Atom.DEFINES_LARGE_SIZE) { + // Read the large atom size. + headerSize = Atom.LONG_HEADER_SIZE; + input.peekFully(buffer.data, Atom.HEADER_SIZE, Atom.LONG_HEADER_SIZE - Atom.HEADER_SIZE); + buffer.setLimit(Atom.LONG_HEADER_SIZE); + atomSize = buffer.readLong(); + } else if (atomSize == Atom.EXTENDS_TO_END_SIZE) { + // The atom extends to the end of the file. + long fileEndPosition = input.getLength(); + if (fileEndPosition != C.LENGTH_UNSET) { + atomSize = fileEndPosition - input.getPeekPosition() + headerSize; + } + } + + if (atomSize < headerSize) { + // The file is invalid because the atom size is too small for its header. + return false; + } + bytesSearched += headerSize; + + if (atomType == Atom.TYPE_moov) { + // We have seen the moov atom. We increase the search size to make sure we don't miss an + // mvex atom because the moov's size exceeds the search length. + bytesToSearch += (int) atomSize; + if (inputLength != C.LENGTH_UNSET && bytesToSearch > inputLength) { + // Make sure we don't exceed the file size. + bytesToSearch = (int) inputLength; + } + // Check for an mvex atom inside the moov atom to identify whether the file is fragmented. + continue; + } + + if (atomType == Atom.TYPE_moof || atomType == Atom.TYPE_mvex) { + // The movie is fragmented. Stop searching as we must have read any ftyp atom already. + isFragmented = true; + break; + } + + if (bytesSearched + atomSize - headerSize >= bytesToSearch) { + // Stop searching as peeking this atom would exceed the search limit. + break; + } + + int atomDataSize = (int) (atomSize - headerSize); + bytesSearched += atomDataSize; + if (atomType == Atom.TYPE_ftyp) { + // Parse the atom and check the file type/brand is compatible with the extractors. + if (atomDataSize < 8) { + return false; + } + buffer.reset(atomDataSize); + input.peekFully(buffer.data, 0, atomDataSize); + int brandsCount = atomDataSize / 4; + for (int i = 0; i < brandsCount; i++) { + if (i == 1) { + // This index refers to the minorVersion, not a brand, so skip it. + buffer.skipBytes(4); + } else if (isCompatibleBrand(buffer.readInt())) { + foundGoodFileType = true; + break; + } + } + if (!foundGoodFileType) { + // The types were not compatible and there is only one ftyp atom, so reject the file. + return false; + } + } else if (atomDataSize != 0) { + // Skip the atom. + input.advancePeekPosition(atomDataSize); + } + } + return foundGoodFileType && fragmented == isFragmented; + } + + /** + * Returns whether {@code brand} is an ftyp atom brand that is compatible with the MP4 extractors. + */ + private static boolean isCompatibleBrand(int brand) { + // Accept all brands starting '3gp'. + if (brand >>> 8 == 0x00336770) { + return true; + } + for (int compatibleBrand : COMPATIBLE_BRANDS) { + if (compatibleBrand == brand) { + return true; + } + } + return false; + } + + private Sniffer() { + // Prevent instantiation. + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Track.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Track.java new file mode 100644 index 0000000000..b7a1555a76 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/Track.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Encapsulates information describing an MP4 track. + */ +public final class Track { + + /** + * The transformation to apply to samples in the track, if any. One of {@link + * #TRANSFORMATION_NONE} or {@link #TRANSFORMATION_CEA608_CDAT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TRANSFORMATION_NONE, TRANSFORMATION_CEA608_CDAT}) + public @interface Transformation {} + /** + * A no-op sample transformation. + */ + public static final int TRANSFORMATION_NONE = 0; + /** + * A transformation for caption samples in cdat atoms. + */ + public static final int TRANSFORMATION_CEA608_CDAT = 1; + + /** + * The track identifier. + */ + public final int id; + + /** + * One of {@link C#TRACK_TYPE_AUDIO}, {@link C#TRACK_TYPE_VIDEO} and {@link C#TRACK_TYPE_TEXT}. + */ + public final int type; + + /** + * The track timescale, defined as the number of time units that pass in one second. + */ + public final long timescale; + + /** + * The movie timescale. + */ + public final long movieTimescale; + + /** + * The duration of the track in microseconds, or {@link C#TIME_UNSET} if unknown. + */ + public final long durationUs; + + /** + * The format. + */ + public final Format format; + + /** + * One of {@code TRANSFORMATION_*}. Defines the transformation to apply before outputting each + * sample. + */ + @Transformation public final int sampleTransformation; + + /** + * Durations of edit list segments in the movie timescale. Null if there is no edit list. + */ + @Nullable public final long[] editListDurations; + + /** + * Media times for edit list segments in the track timescale. Null if there is no edit list. + */ + @Nullable public final long[] editListMediaTimes; + + /** + * For H264 video tracks, the length in bytes of the NALUnitLength field in each sample. 0 for + * other track types. + */ + public final int nalUnitLengthFieldLength; + + @Nullable private final TrackEncryptionBox[] sampleDescriptionEncryptionBoxes; + + public Track(int id, int type, long timescale, long movieTimescale, long durationUs, + Format format, @Transformation int sampleTransformation, + @Nullable TrackEncryptionBox[] sampleDescriptionEncryptionBoxes, int nalUnitLengthFieldLength, + @Nullable long[] editListDurations, @Nullable long[] editListMediaTimes) { + this.id = id; + this.type = type; + this.timescale = timescale; + this.movieTimescale = movieTimescale; + this.durationUs = durationUs; + this.format = format; + this.sampleTransformation = sampleTransformation; + this.sampleDescriptionEncryptionBoxes = sampleDescriptionEncryptionBoxes; + this.nalUnitLengthFieldLength = nalUnitLengthFieldLength; + this.editListDurations = editListDurations; + this.editListMediaTimes = editListMediaTimes; + } + + /** + * Returns the {@link TrackEncryptionBox} for the given sample description index. + * + * @param sampleDescriptionIndex The given sample description index + * @return The {@link TrackEncryptionBox} for the given sample description index. Maybe null if no + * such entry exists. + */ + @Nullable + public TrackEncryptionBox getSampleDescriptionEncryptionBox(int sampleDescriptionIndex) { + return sampleDescriptionEncryptionBoxes == null ? null + : sampleDescriptionEncryptionBoxes[sampleDescriptionIndex]; + } + + // incompatible types in argument. + @SuppressWarnings("nullness:argument.type.incompatible") + public Track copyWithFormat(Format format) { + return new Track( + id, + type, + timescale, + movieTimescale, + durationUs, + format, + sampleTransformation, + sampleDescriptionEncryptionBoxes, + nalUnitLengthFieldLength, + editListDurations, + editListMediaTimes); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackEncryptionBox.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackEncryptionBox.java new file mode 100644 index 0000000000..04bfb82210 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackEncryptionBox.java @@ -0,0 +1,103 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; + +/** + * Encapsulates information parsed from a track encryption (tenc) box or sample group description + * (sgpd) box in an MP4 stream. + */ +public final class TrackEncryptionBox { + + private static final String TAG = "TrackEncryptionBox"; + + /** + * Indicates the encryption state of the samples in the sample group. + */ + public final boolean isEncrypted; + + /** + * The protection scheme type, as defined by the 'schm' box, or null if unknown. + */ + @Nullable public final String schemeType; + + /** + * A {@link TrackOutput.CryptoData} instance containing the encryption information from this + * {@link TrackEncryptionBox}. + */ + public final TrackOutput.CryptoData cryptoData; + + /** The initialization vector size in bytes for the samples in the corresponding sample group. */ + public final int perSampleIvSize; + + /** + * If {@link #perSampleIvSize} is 0, holds the default initialization vector as defined in the + * track encryption box or sample group description box. Null otherwise. + */ + @Nullable public final byte[] defaultInitializationVector; + + /** + * @param isEncrypted See {@link #isEncrypted}. + * @param schemeType See {@link #schemeType}. + * @param perSampleIvSize See {@link #perSampleIvSize}. + * @param keyId See {@link TrackOutput.CryptoData#encryptionKey}. + * @param defaultEncryptedBlocks See {@link TrackOutput.CryptoData#encryptedBlocks}. + * @param defaultClearBlocks See {@link TrackOutput.CryptoData#clearBlocks}. + * @param defaultInitializationVector See {@link #defaultInitializationVector}. + */ + public TrackEncryptionBox( + boolean isEncrypted, + @Nullable String schemeType, + int perSampleIvSize, + byte[] keyId, + int defaultEncryptedBlocks, + int defaultClearBlocks, + @Nullable byte[] defaultInitializationVector) { + Assertions.checkArgument(perSampleIvSize == 0 ^ defaultInitializationVector == null); + this.isEncrypted = isEncrypted; + this.schemeType = schemeType; + this.perSampleIvSize = perSampleIvSize; + this.defaultInitializationVector = defaultInitializationVector; + cryptoData = new TrackOutput.CryptoData(schemeToCryptoMode(schemeType), keyId, + defaultEncryptedBlocks, defaultClearBlocks); + } + + @C.CryptoMode + private static int schemeToCryptoMode(@Nullable String schemeType) { + if (schemeType == null) { + // If unknown, assume cenc. + return C.CRYPTO_MODE_AES_CTR; + } + switch (schemeType) { + case C.CENC_TYPE_cenc: + case C.CENC_TYPE_cens: + return C.CRYPTO_MODE_AES_CTR; + case C.CENC_TYPE_cbc1: + case C.CENC_TYPE_cbcs: + return C.CRYPTO_MODE_AES_CBC; + default: + Log.w(TAG, "Unsupported protection scheme type '" + schemeType + "'. Assuming AES-CTR " + + "crypto mode."); + return C.CRYPTO_MODE_AES_CTR; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java new file mode 100644 index 0000000000..e027d6ed76 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackFragment.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * A holder for information corresponding to a single fragment of an mp4 file. + */ +/* package */ final class TrackFragment { + + /** + * The default values for samples from the track fragment header. + */ + public DefaultSampleValues header; + /** + * The position (byte offset) of the start of fragment. + */ + public long atomPosition; + /** + * The position (byte offset) of the start of data contained in the fragment. + */ + public long dataPosition; + /** + * The position (byte offset) of the start of auxiliary data. + */ + public long auxiliaryDataPosition; + /** + * The number of track runs of the fragment. + */ + public int trunCount; + /** + * The total number of samples in the fragment. + */ + public int sampleCount; + /** + * The position (byte offset) of the start of sample data of each track run in the fragment. + */ + public long[] trunDataPosition; + /** + * The number of samples contained by each track run in the fragment. + */ + public int[] trunLength; + /** + * The size of each sample in the fragment. + */ + public int[] sampleSizeTable; + /** + * The composition time offset of each sample in the fragment. + */ + public int[] sampleCompositionTimeOffsetTable; + /** + * The decoding time of each sample in the fragment. + */ + public long[] sampleDecodingTimeTable; + /** + * Indicates which samples are sync frames. + */ + public boolean[] sampleIsSyncFrameTable; + /** + * Whether the fragment defines encryption data. + */ + public boolean definesEncryptionData; + /** + * If {@link #definesEncryptionData} is true, indicates which samples use sub-sample encryption. + * Undefined otherwise. + */ + public boolean[] sampleHasSubsampleEncryptionTable; + /** + * Fragment specific track encryption. May be null. + */ + public TrackEncryptionBox trackEncryptionBox; + /** + * If {@link #definesEncryptionData} is true, indicates the length of the sample encryption data. + * Undefined otherwise. + */ + public int sampleEncryptionDataLength; + /** + * If {@link #definesEncryptionData} is true, contains binary sample encryption data. Undefined + * otherwise. + */ + public ParsableByteArray sampleEncryptionData; + /** + * Whether {@link #sampleEncryptionData} needs populating with the actual encryption data. + */ + public boolean sampleEncryptionDataNeedsFill; + /** + * The absolute decode time of the start of the next fragment. + */ + public long nextFragmentDecodeTime; + + /** + * Resets the fragment. + * <p> + * {@link #sampleCount} and {@link #nextFragmentDecodeTime} are set to 0, and both + * {@link #definesEncryptionData} and {@link #sampleEncryptionDataNeedsFill} is set to false, + * and {@link #trackEncryptionBox} is set to null. + */ + public void reset() { + trunCount = 0; + nextFragmentDecodeTime = 0; + definesEncryptionData = false; + sampleEncryptionDataNeedsFill = false; + trackEncryptionBox = null; + } + + /** + * Configures the fragment for the specified number of samples. + * <p> + * The {@link #sampleCount} of the fragment is set to the specified sample count, and the + * contained tables are resized if necessary such that they are at least this length. + * + * @param sampleCount The number of samples in the new run. + */ + public void initTables(int trunCount, int sampleCount) { + this.trunCount = trunCount; + this.sampleCount = sampleCount; + if (trunLength == null || trunLength.length < trunCount) { + trunDataPosition = new long[trunCount]; + trunLength = new int[trunCount]; + } + if (sampleSizeTable == null || sampleSizeTable.length < sampleCount) { + // Size the tables 25% larger than needed, so as to make future resize operations less + // likely. The choice of 25% is relatively arbitrary. + int tableSize = (sampleCount * 125) / 100; + sampleSizeTable = new int[tableSize]; + sampleCompositionTimeOffsetTable = new int[tableSize]; + sampleDecodingTimeTable = new long[tableSize]; + sampleIsSyncFrameTable = new boolean[tableSize]; + sampleHasSubsampleEncryptionTable = new boolean[tableSize]; + } + } + + /** + * Configures the fragment to be one that defines encryption data of the specified length. + * <p> + * {@link #definesEncryptionData} is set to true, {@link #sampleEncryptionDataLength} is set to + * the specified length, and {@link #sampleEncryptionData} is resized if necessary such that it + * is at least this length. + * + * @param length The length in bytes of the encryption data. + */ + public void initEncryptionData(int length) { + if (sampleEncryptionData == null || sampleEncryptionData.limit() < length) { + sampleEncryptionData = new ParsableByteArray(length); + } + sampleEncryptionDataLength = length; + definesEncryptionData = true; + sampleEncryptionDataNeedsFill = true; + } + + /** + * Fills {@link #sampleEncryptionData} from the provided input. + * + * @param input An {@link ExtractorInput} from which to read the encryption data. + */ + public void fillEncryptionData(ExtractorInput input) throws IOException, InterruptedException { + input.readFully(sampleEncryptionData.data, 0, sampleEncryptionDataLength); + sampleEncryptionData.setPosition(0); + sampleEncryptionDataNeedsFill = false; + } + + /** + * Fills {@link #sampleEncryptionData} from the provided source. + * + * @param source A source from which to read the encryption data. + */ + public void fillEncryptionData(ParsableByteArray source) { + source.readBytes(sampleEncryptionData.data, 0, sampleEncryptionDataLength); + sampleEncryptionData.setPosition(0); + sampleEncryptionDataNeedsFill = false; + } + + public long getSamplePresentationTime(int index) { + return sampleDecodingTimeTable[index] + sampleCompositionTimeOffsetTable[index]; + } + + /** Returns whether the sample at the given index has a subsample encryption table. */ + public boolean sampleHasSubsampleEncryptionTable(int index) { + return definesEncryptionData && sampleHasSubsampleEncryptionTable[index]; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java new file mode 100644 index 0000000000..bb9891b302 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/mp4/TrackSampleTable.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Sample table for a track in an MP4 file. + */ +/* package */ final class TrackSampleTable { + + /** The track corresponding to this sample table. */ + public final Track track; + /** Number of samples. */ + public final int sampleCount; + /** Sample offsets in bytes. */ + public final long[] offsets; + /** Sample sizes in bytes. */ + public final int[] sizes; + /** Maximum sample size in {@link #sizes}. */ + public final int maximumSize; + /** Sample timestamps in microseconds. */ + public final long[] timestampsUs; + /** Sample flags. */ + public final int[] flags; + /** + * The duration of the track sample table in microseconds, or {@link C#TIME_UNSET} if the sample + * table is empty. + */ + public final long durationUs; + + public TrackSampleTable( + Track track, + long[] offsets, + int[] sizes, + int maximumSize, + long[] timestampsUs, + int[] flags, + long durationUs) { + Assertions.checkArgument(sizes.length == timestampsUs.length); + Assertions.checkArgument(offsets.length == timestampsUs.length); + Assertions.checkArgument(flags.length == timestampsUs.length); + + this.track = track; + this.offsets = offsets; + this.sizes = sizes; + this.maximumSize = maximumSize; + this.timestampsUs = timestampsUs; + this.flags = flags; + this.durationUs = durationUs; + sampleCount = offsets.length; + if (flags.length > 0) { + flags[flags.length - 1] |= C.BUFFER_FLAG_LAST_SAMPLE; + } + } + + /** + * Returns the sample index of the closest synchronization sample at or before the given + * timestamp, if one is available. + * + * @param timeUs Timestamp adjacent to which to find a synchronization sample. + * @return Index of the synchronization sample, or {@link C#INDEX_UNSET} if none. + */ + public int getIndexOfEarlierOrEqualSynchronizationSample(long timeUs) { + // Video frame timestamps may not be sorted, so the behavior of this call can be undefined. + // Frames are not reordered past synchronization samples so this works in practice. + int startIndex = Util.binarySearchFloor(timestampsUs, timeUs, true, false); + for (int i = startIndex; i >= 0; i--) { + if ((flags[i] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + return i; + } + } + return C.INDEX_UNSET; + } + + /** + * Returns the sample index of the closest synchronization sample at or after the given timestamp, + * if one is available. + * + * @param timeUs Timestamp adjacent to which to find a synchronization sample. + * @return index Index of the synchronization sample, or {@link C#INDEX_UNSET} if none. + */ + public int getIndexOfLaterOrEqualSynchronizationSample(long timeUs) { + int startIndex = Util.binarySearchCeil(timestampsUs, timeUs, true, false); + for (int i = startIndex; i < timestampsUs.length; i++) { + if ((flags[i] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + return i; + } + } + return C.INDEX_UNSET; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java new file mode 100644 index 0000000000..5d3b27e294 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/DefaultOggSeeker.java @@ -0,0 +1,313 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; + +/** Seeks in an Ogg stream. */ +/* package */ final class DefaultOggSeeker implements OggSeeker { + + private static final int MATCH_RANGE = 72000; + private static final int MATCH_BYTE_RANGE = 100000; + private static final int DEFAULT_OFFSET = 30000; + + private static final int STATE_SEEK_TO_END = 0; + private static final int STATE_READ_LAST_PAGE = 1; + private static final int STATE_SEEK = 2; + private static final int STATE_SKIP = 3; + private static final int STATE_IDLE = 4; + + private final OggPageHeader pageHeader = new OggPageHeader(); + private final long payloadStartPosition; + private final long payloadEndPosition; + private final StreamReader streamReader; + + private int state; + private long totalGranules; + private long positionBeforeSeekToEnd; + private long targetGranule; + + private long start; + private long end; + private long startGranule; + private long endGranule; + + /** + * Constructs an OggSeeker. + * + * @param streamReader The {@link StreamReader} that owns this seeker. + * @param payloadStartPosition Start position of the payload (inclusive). + * @param payloadEndPosition End position of the payload (exclusive). + * @param firstPayloadPageSize The total size of the first payload page, in bytes. + * @param firstPayloadPageGranulePosition The granule position of the first payload page. + * @param firstPayloadPageIsLastPage Whether the first payload page is also the last page. + */ + public DefaultOggSeeker( + StreamReader streamReader, + long payloadStartPosition, + long payloadEndPosition, + long firstPayloadPageSize, + long firstPayloadPageGranulePosition, + boolean firstPayloadPageIsLastPage) { + Assertions.checkArgument( + payloadStartPosition >= 0 && payloadEndPosition > payloadStartPosition); + this.streamReader = streamReader; + this.payloadStartPosition = payloadStartPosition; + this.payloadEndPosition = payloadEndPosition; + if (firstPayloadPageSize == payloadEndPosition - payloadStartPosition + || firstPayloadPageIsLastPage) { + totalGranules = firstPayloadPageGranulePosition; + state = STATE_IDLE; + } else { + state = STATE_SEEK_TO_END; + } + } + + @Override + @SuppressWarnings("fallthrough") + public long read(ExtractorInput input) throws IOException, InterruptedException { + switch (state) { + case STATE_IDLE: + return -1; + case STATE_SEEK_TO_END: + positionBeforeSeekToEnd = input.getPosition(); + state = STATE_READ_LAST_PAGE; + // Seek to the end just before the last page of stream to get the duration. + long lastPageSearchPosition = payloadEndPosition - OggPageHeader.MAX_PAGE_SIZE; + if (lastPageSearchPosition > positionBeforeSeekToEnd) { + return lastPageSearchPosition; + } + // Fall through. + case STATE_READ_LAST_PAGE: + totalGranules = readGranuleOfLastPage(input); + state = STATE_IDLE; + return positionBeforeSeekToEnd; + case STATE_SEEK: + long position = getNextSeekPosition(input); + if (position != C.POSITION_UNSET) { + return position; + } + state = STATE_SKIP; + // Fall through. + case STATE_SKIP: + skipToPageOfTargetGranule(input); + state = STATE_IDLE; + return -(startGranule + 2); + default: + // Never happens. + throw new IllegalStateException(); + } + } + + @Override + public OggSeekMap createSeekMap() { + return totalGranules != 0 ? new OggSeekMap() : null; + } + + @Override + public void startSeek(long targetGranule) { + this.targetGranule = Util.constrainValue(targetGranule, 0, totalGranules - 1); + state = STATE_SEEK; + start = payloadStartPosition; + end = payloadEndPosition; + startGranule = 0; + endGranule = totalGranules; + } + + /** + * Performs a single step of a seeking binary search, returning the byte position from which data + * should be provided for the next step, or {@link C#POSITION_UNSET} if the search has converged. + * If the search has converged then {@link #skipToPageOfTargetGranule(ExtractorInput)} should be + * called to skip to the target page. + * + * @param input The {@link ExtractorInput} to read from. + * @return The byte position from which data should be provided for the next step, or {@link + * C#POSITION_UNSET} if the search has converged. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If interrupted while reading from the input. + */ + private long getNextSeekPosition(ExtractorInput input) throws IOException, InterruptedException { + if (start == end) { + return C.POSITION_UNSET; + } + + long currentPosition = input.getPosition(); + if (!skipToNextPage(input, end)) { + if (start == currentPosition) { + throw new IOException("No ogg page can be found."); + } + return start; + } + + pageHeader.populate(input, /* quiet= */ false); + input.resetPeekPosition(); + + long granuleDistance = targetGranule - pageHeader.granulePosition; + int pageSize = pageHeader.headerSize + pageHeader.bodySize; + if (0 <= granuleDistance && granuleDistance < MATCH_RANGE) { + return C.POSITION_UNSET; + } + + if (granuleDistance < 0) { + end = currentPosition; + endGranule = pageHeader.granulePosition; + } else { + start = input.getPosition() + pageSize; + startGranule = pageHeader.granulePosition; + } + + if (end - start < MATCH_BYTE_RANGE) { + end = start; + return start; + } + + long offset = pageSize * (granuleDistance <= 0 ? 2L : 1L); + long nextPosition = + input.getPosition() + - offset + + (granuleDistance * (end - start) / (endGranule - startGranule)); + return Util.constrainValue(nextPosition, start, end - 1); + } + + /** + * Skips forward to the start of the page containing the {@code targetGranule}. + * + * @param input The {@link ExtractorInput} to read from. + * @throws ParserException If populating the page header fails. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If interrupted while reading from the input. + */ + private void skipToPageOfTargetGranule(ExtractorInput input) + throws IOException, InterruptedException { + pageHeader.populate(input, /* quiet= */ false); + while (pageHeader.granulePosition <= targetGranule) { + input.skipFully(pageHeader.headerSize + pageHeader.bodySize); + start = input.getPosition(); + startGranule = pageHeader.granulePosition; + pageHeader.populate(input, /* quiet= */ false); + } + input.resetPeekPosition(); + } + + /** + * Skips to the next page. + * + * @param input The {@code ExtractorInput} to skip to the next page. + * @throws IOException If peeking/reading from the input fails. + * @throws InterruptedException If the thread is interrupted. + * @throws EOFException If the next page can't be found before the end of the input. + */ + @VisibleForTesting + void skipToNextPage(ExtractorInput input) throws IOException, InterruptedException { + if (!skipToNextPage(input, payloadEndPosition)) { + // Not found until eof. + throw new EOFException(); + } + } + + /** + * Skips to the next page. Searches for the next page header. + * + * @param input The {@code ExtractorInput} to skip to the next page. + * @param limit The limit up to which the search should take place. + * @return Whether the next page was found. + * @throws IOException If peeking/reading from the input fails. + * @throws InterruptedException If interrupted while peeking/reading from the input. + */ + private boolean skipToNextPage(ExtractorInput input, long limit) + throws IOException, InterruptedException { + limit = Math.min(limit + 3, payloadEndPosition); + byte[] buffer = new byte[2048]; + int peekLength = buffer.length; + while (true) { + if (input.getPosition() + peekLength > limit) { + // Make sure to not peek beyond the end of the input. + peekLength = (int) (limit - input.getPosition()); + if (peekLength < 4) { + // Not found until end. + return false; + } + } + input.peekFully(buffer, 0, peekLength, false); + for (int i = 0; i < peekLength - 3; i++) { + if (buffer[i] == 'O' + && buffer[i + 1] == 'g' + && buffer[i + 2] == 'g' + && buffer[i + 3] == 'S') { + // Match! Skip to the start of the pattern. + input.skipFully(i); + return true; + } + } + // Overlap by not skipping the entire peekLength. + input.skipFully(peekLength - 3); + } + } + + /** + * Skips to the last Ogg page in the stream and reads the header's granule field which is the + * total number of samples per channel. + * + * @param input The {@link ExtractorInput} to read from. + * @return The total number of samples of this input. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If the thread is interrupted. + */ + @VisibleForTesting + long readGranuleOfLastPage(ExtractorInput input) throws IOException, InterruptedException { + skipToNextPage(input); + pageHeader.reset(); + while ((pageHeader.type & 0x04) != 0x04 && input.getPosition() < payloadEndPosition) { + pageHeader.populate(input, /* quiet= */ false); + input.skipFully(pageHeader.headerSize + pageHeader.bodySize); + } + return pageHeader.granulePosition; + } + + private final class OggSeekMap implements SeekMap { + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + long targetGranule = streamReader.convertTimeToGranule(timeUs); + long estimatedPosition = + payloadStartPosition + + (targetGranule * (payloadEndPosition - payloadStartPosition) / totalGranules) + - DEFAULT_OFFSET; + estimatedPosition = + Util.constrainValue(estimatedPosition, payloadStartPosition, payloadEndPosition - 1); + return new SeekPoints(new SeekPoint(timeUs, estimatedPosition)); + } + + @Override + public long getDurationUs() { + return streamReader.convertGranuleToTime(totalGranules); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/FlacReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/FlacReader.java new file mode 100644 index 0000000000..449bf35f78 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/FlacReader.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacFrameReader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacMetadataReader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.FlacSeekTableSeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacConstants; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.FlacStreamMetadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; + +/** + * {@link StreamReader} to extract Flac data out of Ogg byte stream. + */ +/* package */ final class FlacReader extends StreamReader { + + private static final byte AUDIO_PACKET_TYPE = (byte) 0xFF; + + private static final int FRAME_HEADER_SAMPLE_NUMBER_OFFSET = 4; + + private FlacStreamMetadata streamMetadata; + private FlacOggSeeker flacOggSeeker; + + public static boolean verifyBitstreamType(ParsableByteArray data) { + return data.bytesLeft() >= 5 && data.readUnsignedByte() == 0x7F && // packet type + data.readUnsignedInt() == 0x464C4143; // ASCII signature "FLAC" + } + + @Override + protected void reset(boolean headerData) { + super.reset(headerData); + if (headerData) { + streamMetadata = null; + flacOggSeeker = null; + } + } + + private static boolean isAudioPacket(byte[] data) { + return data[0] == AUDIO_PACKET_TYPE; + } + + @Override + protected long preparePayload(ParsableByteArray packet) { + if (!isAudioPacket(packet.data)) { + return -1; + } + return getFlacFrameBlockSize(packet); + } + + @Override + protected boolean readHeaders(ParsableByteArray packet, long position, SetupData setupData) { + byte[] data = packet.data; + if (streamMetadata == null) { + streamMetadata = new FlacStreamMetadata(data, 17); + byte[] metadata = Arrays.copyOfRange(data, 9, packet.limit()); + setupData.format = streamMetadata.getFormat(metadata, /* id3Metadata= */ null); + } else if ((data[0] & 0x7F) == FlacConstants.METADATA_TYPE_SEEK_TABLE) { + flacOggSeeker = new FlacOggSeeker(); + FlacStreamMetadata.SeekTable seekTable = + FlacMetadataReader.readSeekTableMetadataBlock(packet); + streamMetadata = streamMetadata.copyWithSeekTable(seekTable); + } else if (isAudioPacket(data)) { + if (flacOggSeeker != null) { + flacOggSeeker.setFirstFrameOffset(position); + setupData.oggSeeker = flacOggSeeker; + } + return false; + } + return true; + } + + private int getFlacFrameBlockSize(ParsableByteArray packet) { + int blockSizeKey = (packet.data[2] & 0xFF) >> 4; + if (blockSizeKey == 6 || blockSizeKey == 7) { + // Skip the sample number. + packet.skipBytes(FRAME_HEADER_SAMPLE_NUMBER_OFFSET); + packet.readUtf8EncodedLong(); + } + int result = FlacFrameReader.readFrameBlockSizeSamplesFromKey(packet, blockSizeKey); + packet.setPosition(0); + return result; + } + + private class FlacOggSeeker implements OggSeeker { + + private long firstFrameOffset; + private long pendingSeekGranule; + + public FlacOggSeeker() { + firstFrameOffset = -1; + pendingSeekGranule = -1; + } + + public void setFirstFrameOffset(long firstFrameOffset) { + this.firstFrameOffset = firstFrameOffset; + } + + @Override + public long read(ExtractorInput input) throws IOException, InterruptedException { + if (pendingSeekGranule >= 0) { + long result = -(pendingSeekGranule + 2); + pendingSeekGranule = -1; + return result; + } + return -1; + } + + @Override + public void startSeek(long targetGranule) { + Assertions.checkNotNull(streamMetadata.seekTable); + long[] seekPointGranules = streamMetadata.seekTable.pointSampleNumbers; + int index = Util.binarySearchFloor(seekPointGranules, targetGranule, true, true); + pendingSeekGranule = seekPointGranules[index]; + } + + @Override + public SeekMap createSeekMap() { + Assertions.checkState(firstFrameOffset != -1); + return new FlacSeekTableSeekMap(streamMetadata, firstFrameOffset); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java new file mode 100644 index 0000000000..da53a47dc0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggExtractor.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * Extracts data from the Ogg container format. + */ +public class OggExtractor implements Extractor { + + /** Factory for {@link OggExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new OggExtractor()}; + + private static final int MAX_VERIFICATION_BYTES = 8; + + private ExtractorOutput output; + private StreamReader streamReader; + private boolean streamReaderInitialized; + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + try { + return sniffInternal(input); + } catch (ParserException e) { + return false; + } + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + } + + @Override + public void seek(long position, long timeUs) { + if (streamReader != null) { + streamReader.seek(position, timeUs); + } + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + if (streamReader == null) { + if (!sniffInternal(input)) { + throw new ParserException("Failed to determine bitstream type"); + } + input.resetPeekPosition(); + } + if (!streamReaderInitialized) { + TrackOutput trackOutput = output.track(0, C.TRACK_TYPE_AUDIO); + output.endTracks(); + streamReader.init(output, trackOutput); + streamReaderInitialized = true; + } + return streamReader.read(input, seekPosition); + } + + private boolean sniffInternal(ExtractorInput input) throws IOException, InterruptedException { + OggPageHeader header = new OggPageHeader(); + if (!header.populate(input, true) || (header.type & 0x02) != 0x02) { + return false; + } + + int length = Math.min(header.bodySize, MAX_VERIFICATION_BYTES); + ParsableByteArray scratch = new ParsableByteArray(length); + input.peekFully(scratch.data, 0, length); + + if (FlacReader.verifyBitstreamType(resetPosition(scratch))) { + streamReader = new FlacReader(); + } else if (VorbisReader.verifyBitstreamType(resetPosition(scratch))) { + streamReader = new VorbisReader(); + } else if (OpusReader.verifyBitstreamType(resetPosition(scratch))) { + streamReader = new OpusReader(); + } else { + return false; + } + return true; + } + + private static ParsableByteArray resetPosition(ParsableByteArray scratch) { + scratch.setPosition(0); + return scratch; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggPacket.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggPacket.java new file mode 100644 index 0000000000..1f3bf38c73 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggPacket.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.util.Arrays; + +/** + * OGG packet class. + */ +/* package */ final class OggPacket { + + private final OggPageHeader pageHeader = new OggPageHeader(); + private final ParsableByteArray packetArray = new ParsableByteArray( + new byte[OggPageHeader.MAX_PAGE_PAYLOAD], 0); + + private int currentSegmentIndex = C.INDEX_UNSET; + private int segmentCount; + private boolean populated; + + /** + * Resets this reader. + */ + public void reset() { + pageHeader.reset(); + packetArray.reset(); + currentSegmentIndex = C.INDEX_UNSET; + populated = false; + } + + /** + * Reads the next packet of the ogg stream. In case of an {@code IOException} the caller must make + * sure to pass the same instance of {@code ParsableByteArray} to this method again so this reader + * can resume properly from an error while reading a continued packet spanned across multiple + * pages. + * + * @param input The {@link ExtractorInput} to read data from. + * @return {@code true} if the read was successful. The read fails if the end of the input is + * encountered without reading data. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If the thread is interrupted. + */ + public boolean populate(ExtractorInput input) throws IOException, InterruptedException { + Assertions.checkState(input != null); + + if (populated) { + populated = false; + packetArray.reset(); + } + + while (!populated) { + if (currentSegmentIndex < 0) { + // We're at the start of a page. + if (!pageHeader.populate(input, true)) { + return false; + } + int segmentIndex = 0; + int bytesToSkip = pageHeader.headerSize; + if ((pageHeader.type & 0x01) == 0x01 && packetArray.limit() == 0) { + // After seeking, the first packet may be the remainder + // part of a continued packet which has to be discarded. + bytesToSkip += calculatePacketSize(segmentIndex); + segmentIndex += segmentCount; + } + input.skipFully(bytesToSkip); + currentSegmentIndex = segmentIndex; + } + + int size = calculatePacketSize(currentSegmentIndex); + int segmentIndex = currentSegmentIndex + segmentCount; + if (size > 0) { + if (packetArray.capacity() < packetArray.limit() + size) { + packetArray.data = Arrays.copyOf(packetArray.data, packetArray.limit() + size); + } + input.readFully(packetArray.data, packetArray.limit(), size); + packetArray.setLimit(packetArray.limit() + size); + populated = pageHeader.laces[segmentIndex - 1] != 255; + } + // Advance now since we are sure reading didn't throw an exception. + currentSegmentIndex = segmentIndex == pageHeader.pageSegmentCount ? C.INDEX_UNSET + : segmentIndex; + } + return true; + } + + /** + * An OGG Packet may span multiple pages. Returns the {@link OggPageHeader} of the last page read, + * or an empty header if the packet has yet to be populated. + * + * <p>Note that the returned {@link OggPageHeader} is mutable and may be updated during subsequent + * calls to {@link #populate(ExtractorInput)}. + * + * @return the {@code PageHeader} of the last page read or an empty header if the packet has yet + * to be populated. + */ + public OggPageHeader getPageHeader() { + return pageHeader; + } + + /** + * Returns a {@link ParsableByteArray} containing the packet's payload. + */ + public ParsableByteArray getPayload() { + return packetArray; + } + + /** + * Trims the packet data array. + */ + public void trimPayload() { + if (packetArray.data.length == OggPageHeader.MAX_PAGE_PAYLOAD) { + return; + } + packetArray.data = Arrays.copyOf(packetArray.data, Math.max(OggPageHeader.MAX_PAGE_PAYLOAD, + packetArray.limit())); + } + + /** + * Calculates the size of the packet starting from {@code startSegmentIndex}. + * + * @param startSegmentIndex the index of the first segment of the packet. + * @return Size of the packet. + */ + private int calculatePacketSize(int startSegmentIndex) { + segmentCount = 0; + int size = 0; + while (startSegmentIndex + segmentCount < pageHeader.pageSegmentCount) { + int segmentLength = pageHeader.laces[startSegmentIndex + segmentCount++]; + size += segmentLength; + if (segmentLength != 255) { + // packets end at first lace < 255 + break; + } + } + return size; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java new file mode 100644 index 0000000000..afdccf80fd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggPageHeader.java @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; + +/** + * Data object to store header information. + */ +/* package */ final class OggPageHeader { + + public static final int EMPTY_PAGE_HEADER_SIZE = 27; + public static final int MAX_SEGMENT_COUNT = 255; + public static final int MAX_PAGE_PAYLOAD = 255 * 255; + public static final int MAX_PAGE_SIZE = EMPTY_PAGE_HEADER_SIZE + MAX_SEGMENT_COUNT + + MAX_PAGE_PAYLOAD; + + private static final int TYPE_OGGS = 0x4f676753; + + public int revision; + public int type; + /** + * The absolute granule position of the page. This is the total number of samples from the start + * of the file up to the <em>end</em> of the page. Samples partially in the page that continue on + * the next page do not count. + */ + public long granulePosition; + + public long streamSerialNumber; + public long pageSequenceNumber; + public long pageChecksum; + public int pageSegmentCount; + public int headerSize; + public int bodySize; + /** + * Be aware that {@code laces.length} is always {@link #MAX_SEGMENT_COUNT}. Instead use + * {@link #pageSegmentCount} to iterate. + */ + public final int[] laces = new int[MAX_SEGMENT_COUNT]; + + private final ParsableByteArray scratch = new ParsableByteArray(MAX_SEGMENT_COUNT); + + /** + * Resets all primitive member fields to zero. + */ + public void reset() { + revision = 0; + type = 0; + granulePosition = 0; + streamSerialNumber = 0; + pageSequenceNumber = 0; + pageChecksum = 0; + pageSegmentCount = 0; + headerSize = 0; + bodySize = 0; + } + + /** + * Peeks an Ogg page header and updates this {@link OggPageHeader}. + * + * @param input The {@link ExtractorInput} to read from. + * @param quiet Whether to return {@code false} rather than throwing an exception if the header + * cannot be populated. + * @return Whether the read was successful. The read fails if the end of the input is encountered + * without reading data. + * @throws IOException If reading data fails or the stream is invalid. + * @throws InterruptedException If the thread is interrupted. + */ + public boolean populate(ExtractorInput input, boolean quiet) + throws IOException, InterruptedException { + scratch.reset(); + reset(); + boolean hasEnoughBytes = input.getLength() == C.LENGTH_UNSET + || input.getLength() - input.getPeekPosition() >= EMPTY_PAGE_HEADER_SIZE; + if (!hasEnoughBytes || !input.peekFully(scratch.data, 0, EMPTY_PAGE_HEADER_SIZE, true)) { + if (quiet) { + return false; + } else { + throw new EOFException(); + } + } + if (scratch.readUnsignedInt() != TYPE_OGGS) { + if (quiet) { + return false; + } else { + throw new ParserException("expected OggS capture pattern at begin of page"); + } + } + + revision = scratch.readUnsignedByte(); + if (revision != 0x00) { + if (quiet) { + return false; + } else { + throw new ParserException("unsupported bit stream revision"); + } + } + type = scratch.readUnsignedByte(); + + granulePosition = scratch.readLittleEndianLong(); + streamSerialNumber = scratch.readLittleEndianUnsignedInt(); + pageSequenceNumber = scratch.readLittleEndianUnsignedInt(); + pageChecksum = scratch.readLittleEndianUnsignedInt(); + pageSegmentCount = scratch.readUnsignedByte(); + headerSize = EMPTY_PAGE_HEADER_SIZE + pageSegmentCount; + + // calculate total size of header including laces + scratch.reset(); + input.peekFully(scratch.data, 0, pageSegmentCount); + for (int i = 0; i < pageSegmentCount; i++) { + laces[i] = scratch.readUnsignedByte(); + bodySize += laces[i]; + } + + return true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java new file mode 100644 index 0000000000..0a0be963f7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OggSeeker.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import java.io.IOException; + +/** + * Used to seek in an Ogg stream. OggSeeker implementation may do direct seeking or progressive + * seeking. OggSeeker works together with a {@link SeekMap} instance to capture the queried position + * and start the seeking with an initial estimated position. + */ +/* package */ interface OggSeeker { + + /** + * Returns a {@link SeekMap} that returns an initial estimated position for progressive seeking + * or the final position for direct seeking. Returns null if {@link #read} has yet to return -1. + */ + SeekMap createSeekMap(); + + /** + * Starts a seek operation. + * + * @param targetGranule The target granule position. + */ + void startSeek(long targetGranule); + + /** + * Reads data from the {@link ExtractorInput} to build the {@link SeekMap} or to continue a seek. + * <p/> + * If more data is required or if the position of the input needs to be modified then a position + * from which data should be provided is returned. Else a negative value is returned. If a seek + * has been completed then the value returned is -(currentGranule + 2). Else it is -1. + * + * @param input The {@link ExtractorInput} to read from. + * @return A non-negative position to seek the {@link ExtractorInput} to, or -(currentGranule + 2) + * if the progressive seek has completed, or -1 otherwise. + * @throws IOException If reading from the {@link ExtractorInput} fails. + * @throws InterruptedException If the thread is interrupted. + */ + long read(ExtractorInput input) throws IOException, InterruptedException; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OpusReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OpusReader.java new file mode 100644 index 0000000000..c3f3a13d54 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/OpusReader.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * {@link StreamReader} to extract Opus data out of Ogg byte stream. + */ +/* package */ final class OpusReader extends StreamReader { + + private static final int DEFAULT_SEEK_PRE_ROLL_SAMPLES = 3840; + + /** + * Opus streams are always decoded at 48000 Hz. + */ + private static final int SAMPLE_RATE = 48000; + + private static final int OPUS_CODE = 0x4f707573; + private static final byte[] OPUS_SIGNATURE = {'O', 'p', 'u', 's', 'H', 'e', 'a', 'd'}; + + private boolean headerRead; + + public static boolean verifyBitstreamType(ParsableByteArray data) { + if (data.bytesLeft() < OPUS_SIGNATURE.length) { + return false; + } + byte[] header = new byte[OPUS_SIGNATURE.length]; + data.readBytes(header, 0, OPUS_SIGNATURE.length); + return Arrays.equals(header, OPUS_SIGNATURE); + } + + @Override + protected void reset(boolean headerData) { + super.reset(headerData); + if (headerData) { + headerRead = false; + } + } + + @Override + protected long preparePayload(ParsableByteArray packet) { + return convertTimeToGranule(getPacketDurationUs(packet.data)); + } + + @Override + protected boolean readHeaders(ParsableByteArray packet, long position, SetupData setupData) { + if (!headerRead) { + byte[] metadata = Arrays.copyOf(packet.data, packet.limit()); + int channelCount = metadata[9] & 0xFF; + int preskip = ((metadata[11] & 0xFF) << 8) | (metadata[10] & 0xFF); + + List<byte[]> initializationData = new ArrayList<>(3); + initializationData.add(metadata); + putNativeOrderLong(initializationData, preskip); + putNativeOrderLong(initializationData, DEFAULT_SEEK_PRE_ROLL_SAMPLES); + + setupData.format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_OPUS, null, + Format.NO_VALUE, Format.NO_VALUE, channelCount, SAMPLE_RATE, initializationData, null, 0, + null); + headerRead = true; + } else { + boolean headerPacket = packet.readInt() == OPUS_CODE; + packet.setPosition(0); + return headerPacket; + } + return true; + } + + private void putNativeOrderLong(List<byte[]> initializationData, int samples) { + long ns = (samples * C.NANOS_PER_SECOND) / SAMPLE_RATE; + byte[] array = ByteBuffer.allocate(8).order(ByteOrder.nativeOrder()).putLong(ns).array(); + initializationData.add(array); + } + + /** + * Returns the duration of the given audio packet. + * + * @param packet Contains audio data. + * @return Returns the duration of the given audio packet. + */ + private long getPacketDurationUs(byte[] packet) { + int toc = packet[0] & 0xFF; + int frames; + switch (toc & 0x3) { + case 0: + frames = 1; + break; + case 1: + case 2: + frames = 2; + break; + default: + frames = packet[1] & 0x3F; + break; + } + + int config = toc >> 3; + int length = config & 0x3; + if (config >= 16) { + length = 2500 << length; + } else if (config >= 12) { + length = 10000 << (length & 0x1); + } else if (length == 3) { + length = 60000; + } else { + length = 10000 << length; + } + return (long) frames * length; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/StreamReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/StreamReader.java new file mode 100644 index 0000000000..067c8aef03 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/StreamReader.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** StreamReader abstract class. */ +@SuppressWarnings("UngroupedOverloads") +/* package */ abstract class StreamReader { + + private static final int STATE_READ_HEADERS = 0; + private static final int STATE_SKIP_HEADERS = 1; + private static final int STATE_READ_PAYLOAD = 2; + private static final int STATE_END_OF_INPUT = 3; + + static class SetupData { + Format format; + OggSeeker oggSeeker; + } + + private final OggPacket oggPacket; + + private TrackOutput trackOutput; + private ExtractorOutput extractorOutput; + private OggSeeker oggSeeker; + private long targetGranule; + private long payloadStartPosition; + private long currentGranule; + private int state; + private int sampleRate; + private SetupData setupData; + private long lengthOfReadPacket; + private boolean seekMapSet; + private boolean formatSet; + + public StreamReader() { + oggPacket = new OggPacket(); + } + + void init(ExtractorOutput output, TrackOutput trackOutput) { + this.extractorOutput = output; + this.trackOutput = trackOutput; + reset(true); + } + + /** + * Resets the state of the {@link StreamReader}. + * + * @param headerData Resets parsed header data too. + */ + protected void reset(boolean headerData) { + if (headerData) { + setupData = new SetupData(); + payloadStartPosition = 0; + state = STATE_READ_HEADERS; + } else { + state = STATE_SKIP_HEADERS; + } + targetGranule = -1; + currentGranule = 0; + } + + /** + * @see Extractor#seek(long, long) + */ + final void seek(long position, long timeUs) { + oggPacket.reset(); + if (position == 0) { + reset(!seekMapSet); + } else { + if (state != STATE_READ_HEADERS) { + targetGranule = convertTimeToGranule(timeUs); + oggSeeker.startSeek(targetGranule); + state = STATE_READ_PAYLOAD; + } + } + } + + /** + * @see Extractor#read(ExtractorInput, PositionHolder) + */ + final int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + switch (state) { + case STATE_READ_HEADERS: + return readHeaders(input); + case STATE_SKIP_HEADERS: + input.skipFully((int) payloadStartPosition); + state = STATE_READ_PAYLOAD; + return Extractor.RESULT_CONTINUE; + case STATE_READ_PAYLOAD: + return readPayload(input, seekPosition); + default: + // Never happens. + throw new IllegalStateException(); + } + } + + private int readHeaders(ExtractorInput input) throws IOException, InterruptedException { + boolean readingHeaders = true; + while (readingHeaders) { + if (!oggPacket.populate(input)) { + state = STATE_END_OF_INPUT; + return Extractor.RESULT_END_OF_INPUT; + } + lengthOfReadPacket = input.getPosition() - payloadStartPosition; + + readingHeaders = readHeaders(oggPacket.getPayload(), payloadStartPosition, setupData); + if (readingHeaders) { + payloadStartPosition = input.getPosition(); + } + } + + sampleRate = setupData.format.sampleRate; + if (!formatSet) { + trackOutput.format(setupData.format); + formatSet = true; + } + + if (setupData.oggSeeker != null) { + oggSeeker = setupData.oggSeeker; + } else if (input.getLength() == C.LENGTH_UNSET) { + oggSeeker = new UnseekableOggSeeker(); + } else { + OggPageHeader firstPayloadPageHeader = oggPacket.getPageHeader(); + boolean isLastPage = (firstPayloadPageHeader.type & 0x04) != 0; // Type 4 is end of stream. + oggSeeker = + new DefaultOggSeeker( + this, + payloadStartPosition, + input.getLength(), + firstPayloadPageHeader.headerSize + firstPayloadPageHeader.bodySize, + firstPayloadPageHeader.granulePosition, + isLastPage); + } + + setupData = null; + state = STATE_READ_PAYLOAD; + // First payload packet. Trim the payload array of the ogg packet after headers have been read. + oggPacket.trimPayload(); + return Extractor.RESULT_CONTINUE; + } + + private int readPayload(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + long position = oggSeeker.read(input); + if (position >= 0) { + seekPosition.position = position; + return Extractor.RESULT_SEEK; + } else if (position < -1) { + onSeekEnd(-(position + 2)); + } + if (!seekMapSet) { + SeekMap seekMap = oggSeeker.createSeekMap(); + extractorOutput.seekMap(seekMap); + seekMapSet = true; + } + + if (lengthOfReadPacket > 0 || oggPacket.populate(input)) { + lengthOfReadPacket = 0; + ParsableByteArray payload = oggPacket.getPayload(); + long granulesInPacket = preparePayload(payload); + if (granulesInPacket >= 0 && currentGranule + granulesInPacket >= targetGranule) { + // calculate time and send payload data to codec + long timeUs = convertGranuleToTime(currentGranule); + trackOutput.sampleData(payload, payload.limit()); + trackOutput.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, payload.limit(), 0, null); + targetGranule = -1; + } + currentGranule += granulesInPacket; + } else { + state = STATE_END_OF_INPUT; + return Extractor.RESULT_END_OF_INPUT; + } + return Extractor.RESULT_CONTINUE; + } + + /** + * Converts granule value to time. + * + * @param granule The granule value. + * @return Time in milliseconds. + */ + protected long convertGranuleToTime(long granule) { + return (granule * C.MICROS_PER_SECOND) / sampleRate; + } + + /** + * Converts time value to granule. + * + * @param timeUs Time in milliseconds. + * @return The granule value. + */ + protected long convertTimeToGranule(long timeUs) { + return (sampleRate * timeUs) / C.MICROS_PER_SECOND; + } + + /** + * Prepares payload data in the packet for submitting to TrackOutput and returns number of + * granules in the packet. + * + * @param packet Ogg payload data packet. + * @return Number of granules in the packet or -1 if the packet doesn't contain payload data. + */ + protected abstract long preparePayload(ParsableByteArray packet); + + /** + * Checks if the given packet is a header packet and reads it. + * + * @param packet An ogg packet. + * @param position Position of the given header packet. + * @param setupData Setup data to be filled. + * @return Whether the packet contains header data. + */ + protected abstract boolean readHeaders(ParsableByteArray packet, long position, + SetupData setupData) throws IOException, InterruptedException; + + /** + * Called on end of seeking. + * + * @param currentGranule The granule at the current input position. + */ + protected void onSeekEnd(long currentGranule) { + this.currentGranule = currentGranule; + } + + private static final class UnseekableOggSeeker implements OggSeeker { + + @Override + public long read(ExtractorInput input) { + return -1; + } + + @Override + public void startSeek(long targetGranule) { + // Do nothing. + } + + @Override + public SeekMap createSeekMap() { + return new SeekMap.Unseekable(C.TIME_UNSET); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java new file mode 100644 index 0000000000..cb0678a285 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ogg/VorbisReader.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ogg; + +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.VorbisUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.VorbisUtil.Mode; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.util.ArrayList; + +/** + * {@link StreamReader} to extract Vorbis data out of Ogg byte stream. + */ +/* package */ final class VorbisReader extends StreamReader { + + private VorbisSetup vorbisSetup; + private int previousPacketBlockSize; + private boolean seenFirstAudioPacket; + + private VorbisUtil.VorbisIdHeader vorbisIdHeader; + private VorbisUtil.CommentHeader commentHeader; + + public static boolean verifyBitstreamType(ParsableByteArray data) { + try { + return VorbisUtil.verifyVorbisHeaderCapturePattern(0x01, data, true); + } catch (ParserException e) { + return false; + } + } + + @Override + protected void reset(boolean headerData) { + super.reset(headerData); + if (headerData) { + vorbisSetup = null; + vorbisIdHeader = null; + commentHeader = null; + } + previousPacketBlockSize = 0; + seenFirstAudioPacket = false; + } + + @Override + protected void onSeekEnd(long currentGranule) { + super.onSeekEnd(currentGranule); + seenFirstAudioPacket = currentGranule != 0; + previousPacketBlockSize = vorbisIdHeader != null ? vorbisIdHeader.blockSize0 : 0; + } + + @Override + protected long preparePayload(ParsableByteArray packet) { + // if this is not an audio packet... + if ((packet.data[0] & 0x01) == 1) { + return -1; + } + + // ... we need to decode the block size + int packetBlockSize = decodeBlockSize(packet.data[0], vorbisSetup); + // a packet contains samples produced from overlapping the previous and current frame data + // (https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-350001.3.2) + int samplesInPacket = seenFirstAudioPacket ? (packetBlockSize + previousPacketBlockSize) / 4 + : 0; + // codec expects the number of samples appended to audio data + appendNumberOfSamples(packet, samplesInPacket); + + // update state in members for next iteration + seenFirstAudioPacket = true; + previousPacketBlockSize = packetBlockSize; + return samplesInPacket; + } + + @Override + protected boolean readHeaders(ParsableByteArray packet, long position, SetupData setupData) + throws IOException, InterruptedException { + if (vorbisSetup != null) { + return false; + } + + vorbisSetup = readSetupHeaders(packet); + if (vorbisSetup == null) { + return true; + } + + ArrayList<byte[]> codecInitialisationData = new ArrayList<>(); + codecInitialisationData.add(vorbisSetup.idHeader.data); + codecInitialisationData.add(vorbisSetup.setupHeaderData); + + setupData.format = Format.createAudioSampleFormat(null, MimeTypes.AUDIO_VORBIS, null, + this.vorbisSetup.idHeader.bitrateNominal, Format.NO_VALUE, + this.vorbisSetup.idHeader.channels, (int) this.vorbisSetup.idHeader.sampleRate, + codecInitialisationData, null, 0, null); + return true; + } + + @VisibleForTesting + /* package */ VorbisSetup readSetupHeaders(ParsableByteArray scratch) throws IOException { + + if (vorbisIdHeader == null) { + vorbisIdHeader = VorbisUtil.readVorbisIdentificationHeader(scratch); + return null; + } + + if (commentHeader == null) { + commentHeader = VorbisUtil.readVorbisCommentHeader(scratch); + return null; + } + + // the third packet contains the setup header + byte[] setupHeaderData = new byte[scratch.limit()]; + // raw data of vorbis setup header has to be passed to decoder as CSD buffer #2 + System.arraycopy(scratch.data, 0, setupHeaderData, 0, scratch.limit()); + // partially decode setup header to get the modes + Mode[] modes = VorbisUtil.readVorbisModes(scratch, vorbisIdHeader.channels); + // we need the ilog of modes all the time when extracting, so we compute it once + int iLogModes = VorbisUtil.iLog(modes.length - 1); + + return new VorbisSetup(vorbisIdHeader, commentHeader, setupHeaderData, modes, iLogModes); + } + + /** + * Reads an int of {@code length} bits from {@code src} starting at {@code + * leastSignificantBitIndex}. + * + * @param src the {@code byte} to read from. + * @param length the length in bits of the int to read. + * @param leastSignificantBitIndex the index of the least significant bit of the int to read. + * @return the int value read. + */ + @VisibleForTesting + /* package */ static int readBits(byte src, int length, int leastSignificantBitIndex) { + return (src >> leastSignificantBitIndex) & (255 >>> (8 - length)); + } + + @VisibleForTesting + /* package */ static void appendNumberOfSamples( + ParsableByteArray buffer, long packetSampleCount) { + + buffer.setLimit(buffer.limit() + 4); + // The vorbis decoder expects the number of samples in the packet + // to be appended to the audio data as an int32 + buffer.data[buffer.limit() - 4] = (byte) (packetSampleCount & 0xFF); + buffer.data[buffer.limit() - 3] = (byte) ((packetSampleCount >>> 8) & 0xFF); + buffer.data[buffer.limit() - 2] = (byte) ((packetSampleCount >>> 16) & 0xFF); + buffer.data[buffer.limit() - 1] = (byte) ((packetSampleCount >>> 24) & 0xFF); + } + + private static int decodeBlockSize(byte firstByteOfAudioPacket, VorbisSetup vorbisSetup) { + // read modeNumber (https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-730004.3.1) + int modeNumber = readBits(firstByteOfAudioPacket, vorbisSetup.iLogModes, 1); + int currentBlockSize; + if (!vorbisSetup.modes[modeNumber].blockFlag) { + currentBlockSize = vorbisSetup.idHeader.blockSize0; + } else { + currentBlockSize = vorbisSetup.idHeader.blockSize1; + } + return currentBlockSize; + } + + /** + * Class to hold all data read from Vorbis setup headers. + */ + /* package */ static final class VorbisSetup { + + public final VorbisUtil.VorbisIdHeader idHeader; + public final VorbisUtil.CommentHeader commentHeader; + public final byte[] setupHeaderData; + public final Mode[] modes; + public final int iLogModes; + + public VorbisSetup(VorbisUtil.VorbisIdHeader idHeader, VorbisUtil.CommentHeader + commentHeader, byte[] setupHeaderData, Mode[] modes, int iLogModes) { + this.idHeader = idHeader; + this.commentHeader = commentHeader; + this.setupHeaderData = setupHeaderData; + this.modes = modes; + this.iLogModes = iLogModes; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java new file mode 100644 index 0000000000..a7b32782ff --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/rawcc/RawCcExtractor.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.rawcc; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * Extracts data from the RawCC container format. + */ +public final class RawCcExtractor implements Extractor { + + private static final int SCRATCH_SIZE = 9; + private static final int HEADER_SIZE = 8; + private static final int HEADER_ID = 0x52434301; + private static final int TIMESTAMP_SIZE_V0 = 4; + private static final int TIMESTAMP_SIZE_V1 = 8; + + // Parser states. + private static final int STATE_READING_HEADER = 0; + private static final int STATE_READING_TIMESTAMP_AND_COUNT = 1; + private static final int STATE_READING_SAMPLES = 2; + + private final Format format; + + private final ParsableByteArray dataScratch; + + private TrackOutput trackOutput; + + private int parserState; + private int version; + private long timestampUs; + private int remainingSampleCount; + private int sampleBytesWritten; + + public RawCcExtractor(Format format) { + this.format = format; + dataScratch = new ParsableByteArray(SCRATCH_SIZE); + parserState = STATE_READING_HEADER; + } + + @Override + public void init(ExtractorOutput output) { + output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + trackOutput = output.track(0, C.TRACK_TYPE_TEXT); + output.endTracks(); + trackOutput.format(format); + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + dataScratch.reset(); + input.peekFully(dataScratch.data, 0, HEADER_SIZE); + return dataScratch.readInt() == HEADER_ID; + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + while (true) { + switch (parserState) { + case STATE_READING_HEADER: + if (parseHeader(input)) { + parserState = STATE_READING_TIMESTAMP_AND_COUNT; + } else { + return RESULT_END_OF_INPUT; + } + break; + case STATE_READING_TIMESTAMP_AND_COUNT: + if (parseTimestampAndSampleCount(input)) { + parserState = STATE_READING_SAMPLES; + } else { + parserState = STATE_READING_HEADER; + return RESULT_END_OF_INPUT; + } + break; + case STATE_READING_SAMPLES: + parseSamples(input); + parserState = STATE_READING_TIMESTAMP_AND_COUNT; + return RESULT_CONTINUE; + default: + throw new IllegalStateException(); + } + } + } + + @Override + public void seek(long position, long timeUs) { + parserState = STATE_READING_HEADER; + } + + @Override + public void release() { + // Do nothing + } + + private boolean parseHeader(ExtractorInput input) throws IOException, InterruptedException { + dataScratch.reset(); + if (input.readFully(dataScratch.data, 0, HEADER_SIZE, true)) { + if (dataScratch.readInt() != HEADER_ID) { + throw new IOException("Input not RawCC"); + } + version = dataScratch.readUnsignedByte(); + // no versions use the flag fields yet + return true; + } else { + return false; + } + } + + private boolean parseTimestampAndSampleCount(ExtractorInput input) throws IOException, + InterruptedException { + dataScratch.reset(); + if (version == 0) { + if (!input.readFully(dataScratch.data, 0, TIMESTAMP_SIZE_V0 + 1, true)) { + return false; + } + // version 0 timestamps are 45kHz, so we need to convert them into us + timestampUs = dataScratch.readUnsignedInt() * 1000 / 45; + } else if (version == 1) { + if (!input.readFully(dataScratch.data, 0, TIMESTAMP_SIZE_V1 + 1, true)) { + return false; + } + timestampUs = dataScratch.readLong(); + } else { + throw new ParserException("Unsupported version number: " + version); + } + + remainingSampleCount = dataScratch.readUnsignedByte(); + sampleBytesWritten = 0; + return true; + } + + private void parseSamples(ExtractorInput input) throws IOException, InterruptedException { + for (; remainingSampleCount > 0; remainingSampleCount--) { + dataScratch.reset(); + input.readFully(dataScratch.data, 0, 3); + + trackOutput.sampleData(dataScratch, 3); + sampleBytesWritten += 3; + } + + if (sampleBytesWritten > 0) { + trackOutput.sampleMetadata(timestampUs, C.BUFFER_FLAG_KEY_FRAME, sampleBytesWritten, 0, null); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java new file mode 100644 index 0000000000..a0a1365935 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac3Extractor.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_HEADER_LENGTH; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_TAG; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac3Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * Extracts data from (E-)AC-3 bitstreams. + */ +public final class Ac3Extractor implements Extractor { + + /** Factory for {@link Ac3Extractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new Ac3Extractor()}; + + /** + * The maximum number of bytes to search when sniffing, excluding ID3 information, before giving + * up. + */ + private static final int MAX_SNIFF_BYTES = 8 * 1024; + private static final int AC3_SYNC_WORD = 0x0B77; + private static final int MAX_SYNC_FRAME_SIZE = 2786; + + private final Ac3Reader reader; + private final ParsableByteArray sampleData; + + private boolean startedPacket; + + /** Creates a new extractor for AC-3 bitstreams. */ + public Ac3Extractor() { + reader = new Ac3Reader(); + sampleData = new ParsableByteArray(MAX_SYNC_FRAME_SIZE); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // Skip any ID3 headers. + ParsableByteArray scratch = new ParsableByteArray(ID3_HEADER_LENGTH); + int startPosition = 0; + while (true) { + input.peekFully(scratch.data, /* offset= */ 0, ID3_HEADER_LENGTH); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != ID3_TAG) { + break; + } + scratch.skipBytes(3); // version, flags + int length = scratch.readSynchSafeInt(); + startPosition += 10 + length; + input.advancePeekPosition(length); + } + input.resetPeekPosition(); + input.advancePeekPosition(startPosition); + + int headerPosition = startPosition; + int validFramesCount = 0; + while (true) { + input.peekFully(scratch.data, 0, 6); + scratch.setPosition(0); + int syncBytes = scratch.readUnsignedShort(); + if (syncBytes != AC3_SYNC_WORD) { + validFramesCount = 0; + input.resetPeekPosition(); + if (++headerPosition - startPosition >= MAX_SNIFF_BYTES) { + return false; + } + input.advancePeekPosition(headerPosition); + } else { + if (++validFramesCount >= 4) { + return true; + } + int frameSize = Ac3Util.parseAc3SyncframeSize(scratch.data); + if (frameSize == C.LENGTH_UNSET) { + return false; + } + input.advancePeekPosition(frameSize - 6); + } + } + } + + @Override + public void init(ExtractorOutput output) { + reader.createTracks(output, new TrackIdGenerator(0, 1)); + output.endTracks(); + output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + } + + @Override + public void seek(long position, long timeUs) { + startedPacket = false; + reader.seek(); + } + + @Override + public void release() { + // Do nothing. + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) throws IOException, + InterruptedException { + int bytesRead = input.read(sampleData.data, 0, MAX_SYNC_FRAME_SIZE); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + + // Feed whatever data we have to the reader, regardless of whether the read finished or not. + sampleData.setPosition(0); + sampleData.setLimit(bytesRead); + + if (!startedPacket) { + // Pass data to the reader as though it's contained within a single infinitely long packet. + reader.packetStarted(/* pesTimeUs= */ 0, FLAG_DATA_ALIGNMENT_INDICATOR); + startedPacket = true; + } + // TODO: Make it possible for the reader to consume the dataSource directly, so that it becomes + // unnecessary to copy the data through packetBuffer. + reader.consume(sampleData); + return RESULT_CONTINUE; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java new file mode 100644 index 0000000000..3a6eebbcd2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac3Reader.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac3Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac3Util.SyncFrameInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Parses a continuous (E-)AC-3 byte stream and extracts individual samples. + */ +public final class Ac3Reader implements ElementaryStreamReader { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_FINDING_SYNC, STATE_READING_HEADER, STATE_READING_SAMPLE}) + private @interface State {} + + private static final int STATE_FINDING_SYNC = 0; + private static final int STATE_READING_HEADER = 1; + private static final int STATE_READING_SAMPLE = 2; + + private static final int HEADER_SIZE = 128; + + private final ParsableBitArray headerScratchBits; + private final ParsableByteArray headerScratchBytes; + private final String language; + + private String trackFormatId; + private TrackOutput output; + + @State private int state; + private int bytesRead; + + // Used to find the header. + private boolean lastByteWas0B; + + // Used when parsing the header. + private long sampleDurationUs; + private Format format; + private int sampleSize; + + // Used when reading the samples. + private long timeUs; + + /** + * Constructs a new reader for (E-)AC-3 elementary streams. + */ + public Ac3Reader() { + this(null); + } + + /** + * Constructs a new reader for (E-)AC-3 elementary streams. + * + * @param language Track language. + */ + public Ac3Reader(String language) { + headerScratchBits = new ParsableBitArray(new byte[HEADER_SIZE]); + headerScratchBytes = new ParsableByteArray(headerScratchBits.data); + state = STATE_FINDING_SYNC; + this.language = language; + } + + @Override + public void seek() { + state = STATE_FINDING_SYNC; + bytesRead = 0; + lastByteWas0B = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator generator) { + generator.generateNewId(); + trackFormatId = generator.getFormatId(); + output = extractorOutput.track(generator.getTrackId(), C.TRACK_TYPE_AUDIO); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + timeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_SYNC: + if (skipToNextSync(data)) { + state = STATE_READING_HEADER; + headerScratchBytes.data[0] = 0x0B; + headerScratchBytes.data[1] = 0x77; + bytesRead = 2; + } + break; + case STATE_READING_HEADER: + if (continueRead(data, headerScratchBytes.data, HEADER_SIZE)) { + parseHeader(); + headerScratchBytes.setPosition(0); + output.sampleData(headerScratchBytes, HEADER_SIZE); + state = STATE_READING_SAMPLE; + } + break; + case STATE_READING_SAMPLE: + int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + output.sampleData(data, bytesToRead); + bytesRead += bytesToRead; + if (bytesRead == sampleSize) { + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + timeUs += sampleDurationUs; + state = STATE_FINDING_SYNC; + } + break; + default: + break; + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Continues a read from the provided {@code source} into a given {@code target}. It's assumed + * that the data should be written into {@code target} starting from an offset of zero. + * + * @param source The source from which to read. + * @param target The target into which data is to be read. + * @param targetLength The target length of the read. + * @return Whether the target length was reached. + */ + private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { + int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + source.readBytes(target, bytesRead, bytesToRead); + bytesRead += bytesToRead; + return bytesRead == targetLength; + } + + /** + * Locates the next syncword, advancing the position to the byte that immediately follows it. If a + * syncword was not located, the position is advanced to the limit. + * + * @param pesBuffer The buffer whose position should be advanced. + * @return Whether a syncword position was found. + */ + private boolean skipToNextSync(ParsableByteArray pesBuffer) { + while (pesBuffer.bytesLeft() > 0) { + if (!lastByteWas0B) { + lastByteWas0B = pesBuffer.readUnsignedByte() == 0x0B; + continue; + } + int secondByte = pesBuffer.readUnsignedByte(); + if (secondByte == 0x77) { + lastByteWas0B = false; + return true; + } else { + lastByteWas0B = secondByte == 0x0B; + } + } + return false; + } + + /** + * Parses the sample header. + */ + @SuppressWarnings("ReferenceEquality") + private void parseHeader() { + headerScratchBits.setPosition(0); + SyncFrameInfo frameInfo = Ac3Util.parseAc3SyncframeInfo(headerScratchBits); + if (format == null || frameInfo.channelCount != format.channelCount + || frameInfo.sampleRate != format.sampleRate + || frameInfo.mimeType != format.sampleMimeType) { + format = Format.createAudioSampleFormat(trackFormatId, frameInfo.mimeType, null, + Format.NO_VALUE, Format.NO_VALUE, frameInfo.channelCount, frameInfo.sampleRate, null, + null, 0, language); + output.format(format); + } + sampleSize = frameInfo.frameSize; + // In this class a sample is an access unit (syncframe in AC-3), but Format#sampleRate + // specifies the number of PCM audio samples per second. + sampleDurationUs = C.MICROS_PER_SECOND * frameInfo.sampleCount / format.sampleRate; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java new file mode 100644 index 0000000000..9578d110b7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac4Extractor.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util.AC40_SYNCWORD; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util.AC41_SYNCWORD; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_HEADER_LENGTH; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_TAG; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** Extracts data from AC-4 bitstreams. */ +public final class Ac4Extractor implements Extractor { + + /** Factory for {@link Ac4Extractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new Ac4Extractor()}; + + /** + * The maximum number of bytes to search when sniffing, excluding ID3 information, before giving + * up. + */ + private static final int MAX_SNIFF_BYTES = 8 * 1024; + + /** + * The size of the reading buffer, in bytes. This value is determined based on the maximum frame + * size used in broadcast applications. + */ + private static final int READ_BUFFER_SIZE = 16384; + + /** The size of the frame header, in bytes. */ + private static final int FRAME_HEADER_SIZE = 7; + + private final Ac4Reader reader; + private final ParsableByteArray sampleData; + + private boolean startedPacket; + + /** Creates a new extractor for AC-4 bitstreams. */ + public Ac4Extractor() { + reader = new Ac4Reader(); + sampleData = new ParsableByteArray(READ_BUFFER_SIZE); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // Skip any ID3 headers. + ParsableByteArray scratch = new ParsableByteArray(ID3_HEADER_LENGTH); + int startPosition = 0; + while (true) { + input.peekFully(scratch.data, /* offset= */ 0, ID3_HEADER_LENGTH); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != ID3_TAG) { + break; + } + scratch.skipBytes(3); // version, flags + int length = scratch.readSynchSafeInt(); + startPosition += 10 + length; + input.advancePeekPosition(length); + } + input.resetPeekPosition(); + input.advancePeekPosition(startPosition); + + int headerPosition = startPosition; + int validFramesCount = 0; + while (true) { + input.peekFully(scratch.data, /* offset= */ 0, /* length= */ FRAME_HEADER_SIZE); + scratch.setPosition(0); + int syncBytes = scratch.readUnsignedShort(); + if (syncBytes != AC40_SYNCWORD && syncBytes != AC41_SYNCWORD) { + validFramesCount = 0; + input.resetPeekPosition(); + if (++headerPosition - startPosition >= MAX_SNIFF_BYTES) { + return false; + } + input.advancePeekPosition(headerPosition); + } else { + if (++validFramesCount >= 4) { + return true; + } + int frameSize = Ac4Util.parseAc4SyncframeSize(scratch.data, syncBytes); + if (frameSize == C.LENGTH_UNSET) { + return false; + } + input.advancePeekPosition(frameSize - FRAME_HEADER_SIZE); + } + } + } + + @Override + public void init(ExtractorOutput output) { + reader.createTracks( + output, new TrackIdGenerator(/* firstTrackId= */ 0, /* trackIdIncrement= */ 1)); + output.endTracks(); + output.seekMap(new SeekMap.Unseekable(/* durationUs= */ C.TIME_UNSET)); + } + + @Override + public void seek(long position, long timeUs) { + startedPacket = false; + reader.seek(); + } + + @Override + public void release() { + // Do nothing. + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + int bytesRead = input.read(sampleData.data, /* offset= */ 0, /* length= */ READ_BUFFER_SIZE); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return RESULT_END_OF_INPUT; + } + + // Feed whatever data we have to the reader, regardless of whether the read finished or not. + sampleData.setPosition(0); + sampleData.setLimit(bytesRead); + + if (!startedPacket) { + // Pass data to the reader as though it's contained within a single infinitely long packet. + reader.packetStarted(/* pesTimeUs= */ 0, FLAG_DATA_ALIGNMENT_INDICATOR); + startedPacket = true; + } + // TODO: Make it possible for the reader to consume the dataSource directly, so that it becomes + // unnecessary to copy the data through packetBuffer. + reader.consume(sampleData); + return RESULT_CONTINUE; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java new file mode 100644 index 0000000000..2b9965b19b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Ac4Reader.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.Ac4Util.SyncFrameInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Parses a continuous AC-4 byte stream and extracts individual samples. */ +public final class Ac4Reader implements ElementaryStreamReader { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_FINDING_SYNC, STATE_READING_HEADER, STATE_READING_SAMPLE}) + private @interface State {} + + private static final int STATE_FINDING_SYNC = 0; + private static final int STATE_READING_HEADER = 1; + private static final int STATE_READING_SAMPLE = 2; + + private final ParsableBitArray headerScratchBits; + private final ParsableByteArray headerScratchBytes; + private final String language; + + private String trackFormatId; + private TrackOutput output; + + @State private int state; + private int bytesRead; + + // Used to find the header. + private boolean lastByteWasAC; + private boolean hasCRC; + + // Used when parsing the header. + private long sampleDurationUs; + private Format format; + private int sampleSize; + + // Used when reading the samples. + private long timeUs; + + /** Constructs a new reader for AC-4 elementary streams. */ + public Ac4Reader() { + this(null); + } + + /** + * Constructs a new reader for AC-4 elementary streams. + * + * @param language Track language. + */ + public Ac4Reader(String language) { + headerScratchBits = new ParsableBitArray(new byte[Ac4Util.HEADER_SIZE_FOR_PARSER]); + headerScratchBytes = new ParsableByteArray(headerScratchBits.data); + state = STATE_FINDING_SYNC; + bytesRead = 0; + lastByteWasAC = false; + hasCRC = false; + this.language = language; + } + + @Override + public void seek() { + state = STATE_FINDING_SYNC; + bytesRead = 0; + lastByteWasAC = false; + hasCRC = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator generator) { + generator.generateNewId(); + trackFormatId = generator.getFormatId(); + output = extractorOutput.track(generator.getTrackId(), C.TRACK_TYPE_AUDIO); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + timeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_SYNC: + if (skipToNextSync(data)) { + state = STATE_READING_HEADER; + headerScratchBytes.data[0] = (byte) 0xAC; + headerScratchBytes.data[1] = (byte) (hasCRC ? 0x41 : 0x40); + bytesRead = 2; + } + break; + case STATE_READING_HEADER: + if (continueRead(data, headerScratchBytes.data, Ac4Util.HEADER_SIZE_FOR_PARSER)) { + parseHeader(); + headerScratchBytes.setPosition(0); + output.sampleData(headerScratchBytes, Ac4Util.HEADER_SIZE_FOR_PARSER); + state = STATE_READING_SAMPLE; + } + break; + case STATE_READING_SAMPLE: + int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + output.sampleData(data, bytesToRead); + bytesRead += bytesToRead; + if (bytesRead == sampleSize) { + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + timeUs += sampleDurationUs; + state = STATE_FINDING_SYNC; + } + break; + default: + break; + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Continues a read from the provided {@code source} into a given {@code target}. It's assumed + * that the data should be written into {@code target} starting from an offset of zero. + * + * @param source The source from which to read. + * @param target The target into which data is to be read. + * @param targetLength The target length of the read. + * @return Whether the target length was reached. + */ + private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { + int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + source.readBytes(target, bytesRead, bytesToRead); + bytesRead += bytesToRead; + return bytesRead == targetLength; + } + + /** + * Locates the next syncword, advancing the position to the byte that immediately follows it. If a + * syncword was not located, the position is advanced to the limit. + * + * @param pesBuffer The buffer whose position should be advanced. + * @return Whether a syncword position was found. + */ + private boolean skipToNextSync(ParsableByteArray pesBuffer) { + while (pesBuffer.bytesLeft() > 0) { + if (!lastByteWasAC) { + lastByteWasAC = (pesBuffer.readUnsignedByte() == 0xAC); + continue; + } + int secondByte = pesBuffer.readUnsignedByte(); + lastByteWasAC = secondByte == 0xAC; + if (secondByte == 0x40 || secondByte == 0x41) { + hasCRC = secondByte == 0x41; + return true; + } + } + return false; + } + + /** Parses the sample header. */ + @SuppressWarnings("ReferenceEquality") + private void parseHeader() { + headerScratchBits.setPosition(0); + SyncFrameInfo frameInfo = Ac4Util.parseAc4SyncframeInfo(headerScratchBits); + if (format == null + || frameInfo.channelCount != format.channelCount + || frameInfo.sampleRate != format.sampleRate + || !MimeTypes.AUDIO_AC4.equals(format.sampleMimeType)) { + format = + Format.createAudioSampleFormat( + trackFormatId, + MimeTypes.AUDIO_AC4, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + frameInfo.channelCount, + frameInfo.sampleRate, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + language); + output.format(format); + } + sampleSize = frameInfo.frameSize; + // In this class a sample is an AC-4 sync frame, but Format#sampleRate specifies the number of + // PCM audio samples per second. + sampleDurationUs = C.MICROS_PER_SECOND * frameInfo.sampleCount / format.sampleRate; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java new file mode 100644 index 0000000000..b91abfc75a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/AdtsExtractor.java @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_HEADER_LENGTH; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_TAG; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ConstantBitrateSeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Extracts data from AAC bit streams with ADTS framing. + */ +public final class AdtsExtractor implements Extractor { + + /** Factory for {@link AdtsExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new AdtsExtractor()}; + + /** + * Flags controlling the behavior of the extractor. Possible flag value is {@link + * #FLAG_ENABLE_CONSTANT_BITRATE_SEEKING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_ENABLE_CONSTANT_BITRATE_SEEKING}) + public @interface Flags {} + /** + * Flag to force enable seeking using a constant bitrate assumption in cases where seeking would + * otherwise not be possible. + * + * <p>Note that this approach may result in approximated stream duration and seek position that + * are not precise, especially when the stream bitrate varies a lot. + */ + public static final int FLAG_ENABLE_CONSTANT_BITRATE_SEEKING = 1; + + private static final int MAX_PACKET_SIZE = 2 * 1024; + /** + * The maximum number of bytes to search when sniffing, excluding the header, before giving up. + * Frame sizes are represented by 13-bit fields, so expect a valid frame in the first 8192 bytes. + */ + private static final int MAX_SNIFF_BYTES = 8 * 1024; + /** + * The maximum number of frames to use when calculating the average frame size for constant + * bitrate seeking. + */ + private static final int NUM_FRAMES_FOR_AVERAGE_FRAME_SIZE = 1000; + + private final @Flags int flags; + + private final AdtsReader reader; + private final ParsableByteArray packetBuffer; + private final ParsableByteArray scratch; + private final ParsableBitArray scratchBits; + + @Nullable private ExtractorOutput extractorOutput; + + private long firstSampleTimestampUs; + private long firstFramePosition; + private int averageFrameSize; + private boolean hasCalculatedAverageFrameSize; + private boolean startedPacket; + private boolean hasOutputSeekMap; + + /** Creates a new extractor for ADTS bitstreams. */ + public AdtsExtractor() { + this(/* flags= */ 0); + } + + /** + * Creates a new extractor for ADTS bitstreams. + * + * @param flags Flags that control the extractor's behavior. + */ + public AdtsExtractor(@Flags int flags) { + this.flags = flags; + reader = new AdtsReader(true); + packetBuffer = new ParsableByteArray(MAX_PACKET_SIZE); + averageFrameSize = C.LENGTH_UNSET; + firstFramePosition = C.POSITION_UNSET; + // Allocate scratch space for an ID3 header. The same buffer is also used to read 4 byte values. + scratch = new ParsableByteArray(ID3_HEADER_LENGTH); + scratchBits = new ParsableBitArray(scratch.data); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // Skip any ID3 headers. + int startPosition = peekId3Header(input); + + // Try to find four or more consecutive AAC audio frames, exceeding the MPEG TS packet size. + int headerPosition = startPosition; + int totalValidFramesSize = 0; + int validFramesCount = 0; + while (true) { + input.peekFully(scratch.data, 0, 2); + scratch.setPosition(0); + int syncBytes = scratch.readUnsignedShort(); + if (!AdtsReader.isAdtsSyncWord(syncBytes)) { + validFramesCount = 0; + totalValidFramesSize = 0; + input.resetPeekPosition(); + if (++headerPosition - startPosition >= MAX_SNIFF_BYTES) { + return false; + } + input.advancePeekPosition(headerPosition); + } else { + if (++validFramesCount >= 4 && totalValidFramesSize > TsExtractor.TS_PACKET_SIZE) { + return true; + } + + // Skip the frame. + input.peekFully(scratch.data, 0, 4); + scratchBits.setPosition(14); + int frameSize = scratchBits.readBits(13); + // Either the stream is malformed OR we're not parsing an ADTS stream. + if (frameSize <= 6) { + return false; + } + input.advancePeekPosition(frameSize - 6); + totalValidFramesSize += frameSize; + } + } + } + + @Override + public void init(ExtractorOutput output) { + this.extractorOutput = output; + reader.createTracks(output, new TrackIdGenerator(0, 1)); + output.endTracks(); + } + + @Override + public void seek(long position, long timeUs) { + startedPacket = false; + reader.seek(); + firstSampleTimestampUs = timeUs; + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + long inputLength = input.getLength(); + boolean canUseConstantBitrateSeeking = + (flags & FLAG_ENABLE_CONSTANT_BITRATE_SEEKING) != 0 && inputLength != C.LENGTH_UNSET; + if (canUseConstantBitrateSeeking) { + calculateAverageFrameSize(input); + } + + int bytesRead = input.read(packetBuffer.data, 0, MAX_PACKET_SIZE); + boolean readEndOfStream = bytesRead == RESULT_END_OF_INPUT; + maybeOutputSeekMap(inputLength, canUseConstantBitrateSeeking, readEndOfStream); + if (readEndOfStream) { + return RESULT_END_OF_INPUT; + } + + // Feed whatever data we have to the reader, regardless of whether the read finished or not. + packetBuffer.setPosition(0); + packetBuffer.setLimit(bytesRead); + + if (!startedPacket) { + // Pass data to the reader as though it's contained within a single infinitely long packet. + reader.packetStarted(firstSampleTimestampUs, FLAG_DATA_ALIGNMENT_INDICATOR); + startedPacket = true; + } + // TODO: Make it possible for reader to consume the dataSource directly, so that it becomes + // unnecessary to copy the data through packetBuffer. + reader.consume(packetBuffer); + return RESULT_CONTINUE; + } + + private int peekId3Header(ExtractorInput input) throws IOException, InterruptedException { + int firstFramePosition = 0; + while (true) { + input.peekFully(scratch.data, /* offset= */ 0, ID3_HEADER_LENGTH); + scratch.setPosition(0); + if (scratch.readUnsignedInt24() != ID3_TAG) { + break; + } + scratch.skipBytes(3); + int length = scratch.readSynchSafeInt(); + firstFramePosition += ID3_HEADER_LENGTH + length; + input.advancePeekPosition(length); + } + input.resetPeekPosition(); + input.advancePeekPosition(firstFramePosition); + if (this.firstFramePosition == C.POSITION_UNSET) { + this.firstFramePosition = firstFramePosition; + } + return firstFramePosition; + } + + private void maybeOutputSeekMap( + long inputLength, boolean canUseConstantBitrateSeeking, boolean readEndOfStream) { + if (hasOutputSeekMap) { + return; + } + boolean useConstantBitrateSeeking = canUseConstantBitrateSeeking && averageFrameSize > 0; + if (useConstantBitrateSeeking + && reader.getSampleDurationUs() == C.TIME_UNSET + && !readEndOfStream) { + // Wait for the sampleDurationUs to be available, or for the end of the stream to be reached, + // before creating seek map. + return; + } + + ExtractorOutput extractorOutput = Assertions.checkNotNull(this.extractorOutput); + if (useConstantBitrateSeeking && reader.getSampleDurationUs() != C.TIME_UNSET) { + extractorOutput.seekMap(getConstantBitrateSeekMap(inputLength)); + } else { + extractorOutput.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + } + hasOutputSeekMap = true; + } + + private void calculateAverageFrameSize(ExtractorInput input) + throws IOException, InterruptedException { + if (hasCalculatedAverageFrameSize) { + return; + } + averageFrameSize = C.LENGTH_UNSET; + input.resetPeekPosition(); + if (input.getPosition() == 0) { + // Skip any ID3 headers. + peekId3Header(input); + } + + int numValidFrames = 0; + long totalValidFramesSize = 0; + try { + while (input.peekFully( + scratch.data, /* offset= */ 0, /* length= */ 2, /* allowEndOfInput= */ true)) { + scratch.setPosition(0); + int syncBytes = scratch.readUnsignedShort(); + if (!AdtsReader.isAdtsSyncWord(syncBytes)) { + // Invalid sync byte pattern. + // Constant bit-rate seeking will probably fail for this stream. + numValidFrames = 0; + break; + } else { + // Read the frame size. + if (!input.peekFully( + scratch.data, /* offset= */ 0, /* length= */ 4, /* allowEndOfInput= */ true)) { + break; + } + scratchBits.setPosition(14); + int currentFrameSize = scratchBits.readBits(13); + // Either the stream is malformed OR we're not parsing an ADTS stream. + if (currentFrameSize <= 6) { + hasCalculatedAverageFrameSize = true; + throw new ParserException("Malformed ADTS stream"); + } + totalValidFramesSize += currentFrameSize; + if (++numValidFrames == NUM_FRAMES_FOR_AVERAGE_FRAME_SIZE) { + break; + } + if (!input.advancePeekPosition(currentFrameSize - 6, /* allowEndOfInput= */ true)) { + break; + } + } + } + } catch (EOFException e) { + // We reached the end of the input during a peekFully() or advancePeekPosition() operation. + // This is OK, it just means the input has an incomplete ADTS frame at the end. Ideally + // ExtractorInput would allow these operations to encounter end-of-input without throwing an + // exception [internal: b/145586657]. + } + input.resetPeekPosition(); + if (numValidFrames > 0) { + averageFrameSize = (int) (totalValidFramesSize / numValidFrames); + } else { + averageFrameSize = C.LENGTH_UNSET; + } + hasCalculatedAverageFrameSize = true; + } + + private SeekMap getConstantBitrateSeekMap(long inputLength) { + int bitrate = getBitrateFromFrameSize(averageFrameSize, reader.getSampleDurationUs()); + return new ConstantBitrateSeekMap(inputLength, firstFramePosition, bitrate, averageFrameSize); + } + + /** + * Returns the stream bitrate, given a frame size and the duration of that frame in microseconds. + * + * @param frameSize The size of each frame in the stream. + * @param durationUsPerFrame The duration of the given frame in microseconds. + * @return The stream bitrate. + */ + private static int getBitrateFromFrameSize(int frameSize, long durationUsPerFrame) { + return (int) ((frameSize * C.BITS_PER_BYTE * C.MICROS_PER_SECOND) / durationUsPerFrame); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/AdtsReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/AdtsReader.java new file mode 100644 index 0000000000..f577747ec2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/AdtsReader.java @@ -0,0 +1,532 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.CodecSpecificDataUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Arrays; +import java.util.Collections; + +/** + * Parses a continuous ADTS byte stream and extracts individual frames. + */ +public final class AdtsReader implements ElementaryStreamReader { + + private static final String TAG = "AdtsReader"; + + private static final int STATE_FINDING_SAMPLE = 0; + private static final int STATE_CHECKING_ADTS_HEADER = 1; + private static final int STATE_READING_ID3_HEADER = 2; + private static final int STATE_READING_ADTS_HEADER = 3; + private static final int STATE_READING_SAMPLE = 4; + + private static final int HEADER_SIZE = 5; + private static final int CRC_SIZE = 2; + + // Match states used while looking for the next sample + private static final int MATCH_STATE_VALUE_SHIFT = 8; + private static final int MATCH_STATE_START = 1 << MATCH_STATE_VALUE_SHIFT; + private static final int MATCH_STATE_FF = 2 << MATCH_STATE_VALUE_SHIFT; + private static final int MATCH_STATE_I = 3 << MATCH_STATE_VALUE_SHIFT; + private static final int MATCH_STATE_ID = 4 << MATCH_STATE_VALUE_SHIFT; + + private static final int ID3_HEADER_SIZE = 10; + private static final int ID3_SIZE_OFFSET = 6; + private static final byte[] ID3_IDENTIFIER = {'I', 'D', '3'}; + private static final int VERSION_UNSET = -1; + + private final boolean exposeId3; + private final ParsableBitArray adtsScratch; + private final ParsableByteArray id3HeaderBuffer; + private final String language; + + private String formatId; + private TrackOutput output; + private TrackOutput id3Output; + + private int state; + private int bytesRead; + + private int matchState; + + private boolean hasCrc; + private boolean foundFirstFrame; + + // Used to verifies sync words + private int firstFrameVersion; + private int firstFrameSampleRateIndex; + + private int currentFrameVersion; + + // Used when parsing the header. + private boolean hasOutputFormat; + private long sampleDurationUs; + private int sampleSize; + + // Used when reading the samples. + private long timeUs; + + private TrackOutput currentOutput; + private long currentSampleDuration; + + /** + * @param exposeId3 True if the reader should expose ID3 information. + */ + public AdtsReader(boolean exposeId3) { + this(exposeId3, null); + } + + /** + * @param exposeId3 True if the reader should expose ID3 information. + * @param language Track language. + */ + public AdtsReader(boolean exposeId3, String language) { + adtsScratch = new ParsableBitArray(new byte[HEADER_SIZE + CRC_SIZE]); + id3HeaderBuffer = new ParsableByteArray(Arrays.copyOf(ID3_IDENTIFIER, ID3_HEADER_SIZE)); + setFindingSampleState(); + firstFrameVersion = VERSION_UNSET; + firstFrameSampleRateIndex = C.INDEX_UNSET; + sampleDurationUs = C.TIME_UNSET; + this.exposeId3 = exposeId3; + this.language = language; + } + + /** Returns whether an integer matches an ADTS SYNC word. */ + public static boolean isAdtsSyncWord(int candidateSyncWord) { + return (candidateSyncWord & 0xFFF6) == 0xFFF0; + } + + @Override + public void seek() { + resetSync(); + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_AUDIO); + if (exposeId3) { + idGenerator.generateNewId(); + id3Output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_METADATA); + id3Output.format(Format.createSampleFormat(idGenerator.getFormatId(), + MimeTypes.APPLICATION_ID3, null, Format.NO_VALUE, null)); + } else { + id3Output = new DummyTrackOutput(); + } + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + timeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) throws ParserException { + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_SAMPLE: + findNextSample(data); + break; + case STATE_READING_ID3_HEADER: + if (continueRead(data, id3HeaderBuffer.data, ID3_HEADER_SIZE)) { + parseId3Header(); + } + break; + case STATE_CHECKING_ADTS_HEADER: + checkAdtsHeader(data); + break; + case STATE_READING_ADTS_HEADER: + int targetLength = hasCrc ? HEADER_SIZE + CRC_SIZE : HEADER_SIZE; + if (continueRead(data, adtsScratch.data, targetLength)) { + parseAdtsHeader(); + } + break; + case STATE_READING_SAMPLE: + readSample(data); + break; + default: + throw new IllegalStateException(); + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Returns the duration in microseconds per sample, or {@link C#TIME_UNSET} if the sample duration + * is not available. + */ + public long getSampleDurationUs() { + return sampleDurationUs; + } + + private void resetSync() { + foundFirstFrame = false; + setFindingSampleState(); + } + + /** + * Continues a read from the provided {@code source} into a given {@code target}. It's assumed + * that the data should be written into {@code target} starting from an offset of zero. + * + * @param source The source from which to read. + * @param target The target into which data is to be read. + * @param targetLength The target length of the read. + * @return Whether the target length was reached. + */ + private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { + int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + source.readBytes(target, bytesRead, bytesToRead); + bytesRead += bytesToRead; + return bytesRead == targetLength; + } + + /** + * Sets the state to STATE_FINDING_SAMPLE. + */ + private void setFindingSampleState() { + state = STATE_FINDING_SAMPLE; + bytesRead = 0; + matchState = MATCH_STATE_START; + } + + /** + * Sets the state to STATE_READING_ID3_HEADER and resets the fields required for + * {@link #parseId3Header()}. + */ + private void setReadingId3HeaderState() { + state = STATE_READING_ID3_HEADER; + bytesRead = ID3_IDENTIFIER.length; + sampleSize = 0; + id3HeaderBuffer.setPosition(0); + } + + /** + * Sets the state to STATE_READING_SAMPLE. + * + * @param outputToUse TrackOutput object to write the sample to + * @param currentSampleDuration Duration of the sample to be read + * @param priorReadBytes Size of prior read bytes + * @param sampleSize Size of the sample + */ + private void setReadingSampleState(TrackOutput outputToUse, long currentSampleDuration, + int priorReadBytes, int sampleSize) { + state = STATE_READING_SAMPLE; + bytesRead = priorReadBytes; + this.currentOutput = outputToUse; + this.currentSampleDuration = currentSampleDuration; + this.sampleSize = sampleSize; + } + + /** + * Sets the state to STATE_READING_ADTS_HEADER. + */ + private void setReadingAdtsHeaderState() { + state = STATE_READING_ADTS_HEADER; + bytesRead = 0; + } + + /** Sets the state to STATE_CHECKING_ADTS_HEADER. */ + private void setCheckingAdtsHeaderState() { + state = STATE_CHECKING_ADTS_HEADER; + bytesRead = 0; + } + + /** + * Locates the next sample start, advancing the position to the byte that immediately follows + * identifier. If a sample was not located, the position is advanced to the limit. + * + * @param pesBuffer The buffer whose position should be advanced. + */ + private void findNextSample(ParsableByteArray pesBuffer) { + byte[] adtsData = pesBuffer.data; + int position = pesBuffer.getPosition(); + int endOffset = pesBuffer.limit(); + while (position < endOffset) { + int data = adtsData[position++] & 0xFF; + if (matchState == MATCH_STATE_FF && isAdtsSyncBytes((byte) 0xFF, (byte) data)) { + if (foundFirstFrame + || checkSyncPositionValid(pesBuffer, /* syncPositionCandidate= */ position - 2)) { + currentFrameVersion = (data & 0x8) >> 3; + hasCrc = (data & 0x1) == 0; + if (!foundFirstFrame) { + setCheckingAdtsHeaderState(); + } else { + setReadingAdtsHeaderState(); + } + pesBuffer.setPosition(position); + return; + } + } + + switch (matchState | data) { + case MATCH_STATE_START | 0xFF: + matchState = MATCH_STATE_FF; + break; + case MATCH_STATE_START | 'I': + matchState = MATCH_STATE_I; + break; + case MATCH_STATE_I | 'D': + matchState = MATCH_STATE_ID; + break; + case MATCH_STATE_ID | '3': + setReadingId3HeaderState(); + pesBuffer.setPosition(position); + return; + default: + if (matchState != MATCH_STATE_START) { + // If matching fails in a later state, revert to MATCH_STATE_START and + // check this byte again + matchState = MATCH_STATE_START; + position--; + } + break; + } + } + pesBuffer.setPosition(position); + } + + /** + * Peeks the Adts header of the current frame and checks if it is valid. If the header is valid, + * transition to {@link #STATE_READING_ADTS_HEADER}; else, transition to {@link + * #STATE_FINDING_SAMPLE}. + */ + private void checkAdtsHeader(ParsableByteArray buffer) { + if (buffer.bytesLeft() == 0) { + // Not enough data to check yet, defer this check. + return; + } + // Peek the next byte of buffer into scratch array. + adtsScratch.data[0] = buffer.data[buffer.getPosition()]; + + adtsScratch.setPosition(2); + int currentFrameSampleRateIndex = adtsScratch.readBits(4); + if (firstFrameSampleRateIndex != C.INDEX_UNSET + && currentFrameSampleRateIndex != firstFrameSampleRateIndex) { + // Invalid header. + resetSync(); + return; + } + + if (!foundFirstFrame) { + foundFirstFrame = true; + firstFrameVersion = currentFrameVersion; + firstFrameSampleRateIndex = currentFrameSampleRateIndex; + } + setReadingAdtsHeaderState(); + } + + /** + * Checks whether a candidate SYNC word position is likely to be the position of a real SYNC word. + * The caller must check that the first byte of the SYNC word is 0xFF before calling this method. + * This method performs the following checks: + * + * <ul> + * <li>The MPEG version of this frame must match the previously detected version. + * <li>The sample rate index of this frame must match the previously detected sample rate index. + * <li>The frame size must be at least 7 bytes + * <li>The bytes following the frame must be either another SYNC word with the same MPEG + * version, or the start of an ID3 header. + * </ul> + * + * With the exception of the first check, if there is insufficient data in the buffer then checks + * are optimistically skipped and {@code true} is returned. + * + * @param pesBuffer The buffer containing at data to check. + * @param syncPositionCandidate The candidate SYNC word position. May be -1 if the first byte of + * the candidate was the last byte of the previously consumed buffer. + * @return True if all checks were passed or skipped, indicating the position is likely to be the + * position of a real SYNC word. False otherwise. + */ + private boolean checkSyncPositionValid(ParsableByteArray pesBuffer, int syncPositionCandidate) { + pesBuffer.setPosition(syncPositionCandidate + 1); + if (!tryRead(pesBuffer, adtsScratch.data, 1)) { + return false; + } + + // The MPEG version of this frame must match the previously detected version. + adtsScratch.setPosition(4); + int currentFrameVersion = adtsScratch.readBits(1); + if (firstFrameVersion != VERSION_UNSET && currentFrameVersion != firstFrameVersion) { + return false; + } + + // The sample rate index of this frame must match the previously detected sample rate index. + if (firstFrameSampleRateIndex != C.INDEX_UNSET) { + if (!tryRead(pesBuffer, adtsScratch.data, 1)) { + // Insufficient data for further checks. + return true; + } + adtsScratch.setPosition(2); + int currentFrameSampleRateIndex = adtsScratch.readBits(4); + if (currentFrameSampleRateIndex != firstFrameSampleRateIndex) { + return false; + } + pesBuffer.setPosition(syncPositionCandidate + 2); + } + + // The frame size must be at least 7 bytes. + if (!tryRead(pesBuffer, adtsScratch.data, 4)) { + // Insufficient data for further checks. + return true; + } + adtsScratch.setPosition(14); + int frameSize = adtsScratch.readBits(13); + if (frameSize < 7) { + return false; + } + + // The bytes following the frame must be either another SYNC word with the same MPEG version, or + // the start of an ID3 header. + byte[] data = pesBuffer.data; + int dataLimit = pesBuffer.limit(); + int nextSyncPosition = syncPositionCandidate + frameSize; + if (nextSyncPosition >= dataLimit) { + // Insufficient data for further checks. + return true; + } + if (data[nextSyncPosition] == (byte) 0xFF) { + if (nextSyncPosition + 1 == dataLimit) { + // Insufficient data for further checks. + return true; + } + return isAdtsSyncBytes((byte) 0xFF, data[nextSyncPosition + 1]) + && ((data[nextSyncPosition + 1] & 0x8) >> 3) == currentFrameVersion; + } else { + if (data[nextSyncPosition] != 'I') { + return false; + } + if (nextSyncPosition + 1 == dataLimit) { + // Insufficient data for further checks. + return true; + } + if (data[nextSyncPosition + 1] != 'D') { + return false; + } + if (nextSyncPosition + 2 == dataLimit) { + // Insufficient data for further checks. + return true; + } + return data[nextSyncPosition + 2] == '3'; + } + } + + private boolean isAdtsSyncBytes(byte firstByte, byte secondByte) { + int syncWord = (firstByte & 0xFF) << 8 | (secondByte & 0xFF); + return isAdtsSyncWord(syncWord); + } + + /** Reads {@code targetLength} bytes into target, and returns whether the read succeeded. */ + private boolean tryRead(ParsableByteArray source, byte[] target, int targetLength) { + if (source.bytesLeft() < targetLength) { + return false; + } + source.readBytes(target, /* offset= */ 0, targetLength); + return true; + } + + /** + * Parses the Id3 header. + */ + private void parseId3Header() { + id3Output.sampleData(id3HeaderBuffer, ID3_HEADER_SIZE); + id3HeaderBuffer.setPosition(ID3_SIZE_OFFSET); + setReadingSampleState(id3Output, 0, ID3_HEADER_SIZE, + id3HeaderBuffer.readSynchSafeInt() + ID3_HEADER_SIZE); + } + + /** + * Parses the sample header. + */ + private void parseAdtsHeader() throws ParserException { + adtsScratch.setPosition(0); + + if (!hasOutputFormat) { + int audioObjectType = adtsScratch.readBits(2) + 1; + if (audioObjectType != 2) { + // The stream indicates AAC-Main (1), AAC-SSR (3) or AAC-LTP (4). When the stream indicates + // AAC-Main it's more likely that the stream contains HE-AAC (5), which cannot be + // represented correctly in the 2 bit audio_object_type field in the ADTS header. In + // practice when the stream indicates AAC-SSR or AAC-LTP it more commonly contains AAC-LC or + // HE-AAC. Since most Android devices don't support AAC-Main, AAC-SSR or AAC-LTP, and since + // indicating AAC-LC works for HE-AAC streams, we pretend that we're dealing with AAC-LC and + // hope for the best. In practice this often works. + // See: https://github.com/google/ExoPlayer/issues/774 + // See: https://github.com/google/ExoPlayer/issues/1383 + Log.w(TAG, "Detected audio object type: " + audioObjectType + ", but assuming AAC LC."); + audioObjectType = 2; + } + + adtsScratch.skipBits(5); + int channelConfig = adtsScratch.readBits(3); + + byte[] audioSpecificConfig = + CodecSpecificDataUtil.buildAacAudioSpecificConfig( + audioObjectType, firstFrameSampleRateIndex, channelConfig); + Pair<Integer, Integer> audioParams = CodecSpecificDataUtil.parseAacAudioSpecificConfig( + audioSpecificConfig); + + Format format = Format.createAudioSampleFormat(formatId, MimeTypes.AUDIO_AAC, null, + Format.NO_VALUE, Format.NO_VALUE, audioParams.second, audioParams.first, + Collections.singletonList(audioSpecificConfig), null, 0, language); + // In this class a sample is an access unit, but the MediaFormat sample rate specifies the + // number of PCM audio samples per second. + sampleDurationUs = (C.MICROS_PER_SECOND * 1024) / format.sampleRate; + output.format(format); + hasOutputFormat = true; + } else { + adtsScratch.skipBits(10); + } + + adtsScratch.skipBits(4); + int sampleSize = adtsScratch.readBits(13) - 2 /* the sync word */ - HEADER_SIZE; + if (hasCrc) { + sampleSize -= CRC_SIZE; + } + + setReadingSampleState(output, sampleDurationUs, 0, sampleSize); + } + + /** + * Reads the rest of the sample + */ + private void readSample(ParsableByteArray data) { + int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + currentOutput.sampleData(data, bytesToRead); + bytesRead += bytesToRead; + if (bytesRead == sampleSize) { + currentOutput.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + timeUs += currentSampleDuration; + setFindingSampleState(); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java new file mode 100644 index 0000000000..cfbc64d2ee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DefaultTsPayloadReaderFactory.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import android.util.SparseArray; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.Cea708InitializationData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Default {@link TsPayloadReader.Factory} implementation. + */ +public final class DefaultTsPayloadReaderFactory implements TsPayloadReader.Factory { + + /** + * Flags controlling elementary stream readers' behavior. Possible flag values are {@link + * #FLAG_ALLOW_NON_IDR_KEYFRAMES}, {@link #FLAG_IGNORE_AAC_STREAM}, {@link + * #FLAG_IGNORE_H264_STREAM}, {@link #FLAG_DETECT_ACCESS_UNITS}, {@link + * #FLAG_IGNORE_SPLICE_INFO_STREAM}, {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} and {@link + * #FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FLAG_ALLOW_NON_IDR_KEYFRAMES, + FLAG_IGNORE_AAC_STREAM, + FLAG_IGNORE_H264_STREAM, + FLAG_DETECT_ACCESS_UNITS, + FLAG_IGNORE_SPLICE_INFO_STREAM, + FLAG_OVERRIDE_CAPTION_DESCRIPTORS, + FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS + }) + public @interface Flags {} + + /** + * When extracting H.264 samples, whether to treat samples consisting of non-IDR I slices as + * synchronization samples (key-frames). + */ + public static final int FLAG_ALLOW_NON_IDR_KEYFRAMES = 1; + /** + * Prevents the creation of {@link AdtsReader} and {@link LatmReader} instances. This flag should + * be enabled if the transport stream contains no packets for an AAC elementary stream that is + * declared in the PMT. + */ + public static final int FLAG_IGNORE_AAC_STREAM = 1 << 1; + /** + * Prevents the creation of {@link H264Reader} instances. This flag should be enabled if the + * transport stream contains no packets for an H.264 elementary stream that is declared in the + * PMT. + */ + public static final int FLAG_IGNORE_H264_STREAM = 1 << 2; + /** + * When extracting H.264 samples, whether to split the input stream into access units (samples) + * based on slice headers. This flag should be disabled if the stream contains access unit + * delimiters (AUDs). + */ + public static final int FLAG_DETECT_ACCESS_UNITS = 1 << 3; + /** Prevents the creation of {@link SpliceInfoSectionReader} instances. */ + public static final int FLAG_IGNORE_SPLICE_INFO_STREAM = 1 << 4; + /** + * Whether the list of {@code closedCaptionFormats} passed to {@link + * DefaultTsPayloadReaderFactory#DefaultTsPayloadReaderFactory(int, List)} should be used in spite + * of any closed captions service descriptors. If this flag is disabled, {@code + * closedCaptionFormats} will be ignored if the PMT contains closed captions service descriptors. + */ + public static final int FLAG_OVERRIDE_CAPTION_DESCRIPTORS = 1 << 5; + /** + * Sets whether HDMV DTS audio streams will be handled. If this flag is set, SCTE subtitles will + * not be detected, as they share the same elementary stream type as HDMV DTS. + */ + public static final int FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS = 1 << 6; + + private static final int DESCRIPTOR_TAG_CAPTION_SERVICE = 0x86; + + @Flags private final int flags; + private final List<Format> closedCaptionFormats; + + public DefaultTsPayloadReaderFactory() { + this(0); + } + + /** + * @param flags A combination of {@code FLAG_*} values that control the behavior of the created + * readers. + */ + public DefaultTsPayloadReaderFactory(@Flags int flags) { + this( + flags, + Collections.singletonList( + Format.createTextSampleFormat(null, MimeTypes.APPLICATION_CEA608, 0, null))); + } + + /** + * @param flags A combination of {@code FLAG_*} values that control the behavior of the created + * readers. + * @param closedCaptionFormats {@link Format}s to be exposed by payload readers for streams with + * embedded closed captions when no caption service descriptors are provided. If + * {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} is set, {@code closedCaptionFormats} overrides + * any descriptor information. If not set, and {@code closedCaptionFormats} is empty, a + * closed caption track with {@link Format#accessibilityChannel} {@link Format#NO_VALUE} will + * be exposed. + */ + public DefaultTsPayloadReaderFactory(@Flags int flags, List<Format> closedCaptionFormats) { + this.flags = flags; + this.closedCaptionFormats = closedCaptionFormats; + } + + @Override + public SparseArray<TsPayloadReader> createInitialPayloadReaders() { + return new SparseArray<>(); + } + + @Override + @SuppressWarnings("fallthrough") + public TsPayloadReader createPayloadReader(int streamType, EsInfo esInfo) { + switch (streamType) { + case TsExtractor.TS_STREAM_TYPE_MPA: + case TsExtractor.TS_STREAM_TYPE_MPA_LSF: + return new PesReader(new MpegAudioReader(esInfo.language)); + case TsExtractor.TS_STREAM_TYPE_AAC_ADTS: + return isSet(FLAG_IGNORE_AAC_STREAM) + ? null : new PesReader(new AdtsReader(false, esInfo.language)); + case TsExtractor.TS_STREAM_TYPE_AAC_LATM: + return isSet(FLAG_IGNORE_AAC_STREAM) + ? null : new PesReader(new LatmReader(esInfo.language)); + case TsExtractor.TS_STREAM_TYPE_AC3: + case TsExtractor.TS_STREAM_TYPE_E_AC3: + return new PesReader(new Ac3Reader(esInfo.language)); + case TsExtractor.TS_STREAM_TYPE_AC4: + return new PesReader(new Ac4Reader(esInfo.language)); + case TsExtractor.TS_STREAM_TYPE_HDMV_DTS: + if (!isSet(FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)) { + return null; + } + // Fall through. + case TsExtractor.TS_STREAM_TYPE_DTS: + return new PesReader(new DtsReader(esInfo.language)); + case TsExtractor.TS_STREAM_TYPE_H262: + return new PesReader(new H262Reader(buildUserDataReader(esInfo))); + case TsExtractor.TS_STREAM_TYPE_H264: + return isSet(FLAG_IGNORE_H264_STREAM) ? null + : new PesReader(new H264Reader(buildSeiReader(esInfo), + isSet(FLAG_ALLOW_NON_IDR_KEYFRAMES), isSet(FLAG_DETECT_ACCESS_UNITS))); + case TsExtractor.TS_STREAM_TYPE_H265: + return new PesReader(new H265Reader(buildSeiReader(esInfo))); + case TsExtractor.TS_STREAM_TYPE_SPLICE_INFO: + return isSet(FLAG_IGNORE_SPLICE_INFO_STREAM) + ? null : new SectionReader(new SpliceInfoSectionReader()); + case TsExtractor.TS_STREAM_TYPE_ID3: + return new PesReader(new Id3Reader()); + case TsExtractor.TS_STREAM_TYPE_DVBSUBS: + return new PesReader( + new DvbSubtitleReader(esInfo.dvbSubtitleInfos)); + default: + return null; + } + } + + /** + * If {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} is set, returns a {@link SeiReader} for + * {@link #closedCaptionFormats}. If unset, parses the PMT descriptor information and returns a + * {@link SeiReader} for the declared formats, or {@link #closedCaptionFormats} if the descriptor + * is not present. + * + * @param esInfo The {@link EsInfo} passed to {@link #createPayloadReader(int, EsInfo)}. + * @return A {@link SeiReader} for closed caption tracks. + */ + private SeiReader buildSeiReader(EsInfo esInfo) { + return new SeiReader(getClosedCaptionFormats(esInfo)); + } + + /** + * If {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} is set, returns a {@link UserDataReader} for + * {@link #closedCaptionFormats}. If unset, parses the PMT descriptor information and returns a + * {@link UserDataReader} for the declared formats, or {@link #closedCaptionFormats} if the + * descriptor is not present. + * + * @param esInfo The {@link EsInfo} passed to {@link #createPayloadReader(int, EsInfo)}. + * @return A {@link UserDataReader} for closed caption tracks. + */ + private UserDataReader buildUserDataReader(EsInfo esInfo) { + return new UserDataReader(getClosedCaptionFormats(esInfo)); + } + + /** + * If {@link #FLAG_OVERRIDE_CAPTION_DESCRIPTORS} is set, returns a {@link List<Format>} of {@link + * #closedCaptionFormats}. If unset, parses the PMT descriptor information and returns a {@link + * List<Format>} for the declared formats, or {@link #closedCaptionFormats} if the descriptor is + * not present. + * + * @param esInfo The {@link EsInfo} passed to {@link #createPayloadReader(int, EsInfo)}. + * @return A {@link List<Format>} containing list of closed caption formats. + */ + private List<Format> getClosedCaptionFormats(EsInfo esInfo) { + if (isSet(FLAG_OVERRIDE_CAPTION_DESCRIPTORS)) { + return closedCaptionFormats; + } + ParsableByteArray scratchDescriptorData = new ParsableByteArray(esInfo.descriptorBytes); + List<Format> closedCaptionFormats = this.closedCaptionFormats; + while (scratchDescriptorData.bytesLeft() > 0) { + int descriptorTag = scratchDescriptorData.readUnsignedByte(); + int descriptorLength = scratchDescriptorData.readUnsignedByte(); + int nextDescriptorPosition = scratchDescriptorData.getPosition() + descriptorLength; + if (descriptorTag == DESCRIPTOR_TAG_CAPTION_SERVICE) { + // Note: see ATSC A/65 for detailed information about the caption service descriptor. + closedCaptionFormats = new ArrayList<>(); + int numberOfServices = scratchDescriptorData.readUnsignedByte() & 0x1F; + for (int i = 0; i < numberOfServices; i++) { + String language = scratchDescriptorData.readString(3); + int captionTypeByte = scratchDescriptorData.readUnsignedByte(); + boolean isDigital = (captionTypeByte & 0x80) != 0; + String mimeType; + int accessibilityChannel; + if (isDigital) { + mimeType = MimeTypes.APPLICATION_CEA708; + accessibilityChannel = captionTypeByte & 0x3F; + } else { + mimeType = MimeTypes.APPLICATION_CEA608; + accessibilityChannel = 1; + } + + // easy_reader(1), wide_aspect_ratio(1), reserved(6). + byte flags = (byte) scratchDescriptorData.readUnsignedByte(); + // Skip reserved (8). + scratchDescriptorData.skipBytes(1); + + List<byte[]> initializationData = null; + // The wide_aspect_ratio flag only has meaning for CEA-708. + if (isDigital) { + boolean isWideAspectRatio = (flags & 0x40) != 0; + initializationData = Cea708InitializationData.buildData(isWideAspectRatio); + } + + closedCaptionFormats.add( + Format.createTextSampleFormat( + /* id= */ null, + mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + language, + accessibilityChannel, + /* drmInitData= */ null, + Format.OFFSET_SAMPLE_RELATIVE, + initializationData)); + } + } else { + // Unknown descriptor. Ignore. + } + scratchDescriptorData.setPosition(nextDescriptorPosition); + } + + return closedCaptionFormats; + } + + private boolean isSet(@Flags int flag) { + return (flags & flag) != 0; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DtsReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DtsReader.java new file mode 100644 index 0000000000..a4205add7b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DtsReader.java @@ -0,0 +1,181 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.DtsUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Parses a continuous DTS byte stream and extracts individual samples. + */ +public final class DtsReader implements ElementaryStreamReader { + + private static final int STATE_FINDING_SYNC = 0; + private static final int STATE_READING_HEADER = 1; + private static final int STATE_READING_SAMPLE = 2; + + private static final int HEADER_SIZE = 18; + + private final ParsableByteArray headerScratchBytes; + private final String language; + + private String formatId; + private TrackOutput output; + + private int state; + private int bytesRead; + + // Used to find the header. + private int syncBytes; + + // Used when parsing the header. + private long sampleDurationUs; + private Format format; + private int sampleSize; + + // Used when reading the samples. + private long timeUs; + + /** + * Constructs a new reader for DTS elementary streams. + * + * @param language Track language. + */ + public DtsReader(String language) { + headerScratchBytes = new ParsableByteArray(new byte[HEADER_SIZE]); + state = STATE_FINDING_SYNC; + this.language = language; + } + + @Override + public void seek() { + state = STATE_FINDING_SYNC; + bytesRead = 0; + syncBytes = 0; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_AUDIO); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + timeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_SYNC: + if (skipToNextSync(data)) { + state = STATE_READING_HEADER; + } + break; + case STATE_READING_HEADER: + if (continueRead(data, headerScratchBytes.data, HEADER_SIZE)) { + parseHeader(); + headerScratchBytes.setPosition(0); + output.sampleData(headerScratchBytes, HEADER_SIZE); + state = STATE_READING_SAMPLE; + } + break; + case STATE_READING_SAMPLE: + int bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + output.sampleData(data, bytesToRead); + bytesRead += bytesToRead; + if (bytesRead == sampleSize) { + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + timeUs += sampleDurationUs; + state = STATE_FINDING_SYNC; + } + break; + default: + throw new IllegalStateException(); + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Continues a read from the provided {@code source} into a given {@code target}. It's assumed + * that the data should be written into {@code target} starting from an offset of zero. + * + * @param source The source from which to read. + * @param target The target into which data is to be read. + * @param targetLength The target length of the read. + * @return Whether the target length was reached. + */ + private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { + int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + source.readBytes(target, bytesRead, bytesToRead); + bytesRead += bytesToRead; + return bytesRead == targetLength; + } + + /** + * Locates the next SYNC value in the buffer, advancing the position to the byte that immediately + * follows it. If SYNC was not located, the position is advanced to the limit. + * + * @param pesBuffer The buffer whose position should be advanced. + * @return Whether SYNC was found. + */ + private boolean skipToNextSync(ParsableByteArray pesBuffer) { + while (pesBuffer.bytesLeft() > 0) { + syncBytes <<= 8; + syncBytes |= pesBuffer.readUnsignedByte(); + if (DtsUtil.isSyncWord(syncBytes)) { + headerScratchBytes.data[0] = (byte) ((syncBytes >> 24) & 0xFF); + headerScratchBytes.data[1] = (byte) ((syncBytes >> 16) & 0xFF); + headerScratchBytes.data[2] = (byte) ((syncBytes >> 8) & 0xFF); + headerScratchBytes.data[3] = (byte) (syncBytes & 0xFF); + bytesRead = 4; + syncBytes = 0; + return true; + } + } + return false; + } + + /** + * Parses the sample header. + */ + private void parseHeader() { + byte[] frameData = headerScratchBytes.data; + if (format == null) { + format = DtsUtil.parseDtsFormat(frameData, formatId, language, null); + output.format(format); + } + sampleSize = DtsUtil.getDtsFrameSize(frameData); + // In this class a sample is an access unit (frame in DTS), but the format's sample rate + // specifies the number of PCM audio samples per second. + sampleDurationUs = (int) (C.MICROS_PER_SECOND + * DtsUtil.parseDtsAudioSampleCount(frameData) / format.sampleRate); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java new file mode 100644 index 0000000000..aceab78bf0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/DvbSubtitleReader.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.DvbSubtitleInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Collections; +import java.util.List; + +/** + * Parses DVB subtitle data and extracts individual frames. + */ +public final class DvbSubtitleReader implements ElementaryStreamReader { + + private final List<DvbSubtitleInfo> subtitleInfos; + private final TrackOutput[] outputs; + + private boolean writingSample; + private int bytesToCheck; + private int sampleBytesWritten; + private long sampleTimeUs; + + /** + * @param subtitleInfos Information about the DVB subtitles associated to the stream. + */ + public DvbSubtitleReader(List<DvbSubtitleInfo> subtitleInfos) { + this.subtitleInfos = subtitleInfos; + outputs = new TrackOutput[subtitleInfos.size()]; + } + + @Override + public void seek() { + writingSample = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + for (int i = 0; i < outputs.length; i++) { + DvbSubtitleInfo subtitleInfo = subtitleInfos.get(i); + idGenerator.generateNewId(); + TrackOutput output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_TEXT); + output.format( + Format.createImageSampleFormat( + idGenerator.getFormatId(), + MimeTypes.APPLICATION_DVBSUBS, + null, + Format.NO_VALUE, + 0, + Collections.singletonList(subtitleInfo.initializationData), + subtitleInfo.language, + null)); + outputs[i] = output; + } + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + if ((flags & FLAG_DATA_ALIGNMENT_INDICATOR) == 0) { + return; + } + writingSample = true; + sampleTimeUs = pesTimeUs; + sampleBytesWritten = 0; + bytesToCheck = 2; + } + + @Override + public void packetFinished() { + if (writingSample) { + for (TrackOutput output : outputs) { + output.sampleMetadata(sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleBytesWritten, 0, null); + } + writingSample = false; + } + } + + @Override + public void consume(ParsableByteArray data) { + if (writingSample) { + if (bytesToCheck == 2 && !checkNextByte(data, 0x20)) { + // Failed to check data_identifier + return; + } + if (bytesToCheck == 1 && !checkNextByte(data, 0x00)) { + // Check and discard the subtitle_stream_id + return; + } + int dataPosition = data.getPosition(); + int bytesAvailable = data.bytesLeft(); + for (TrackOutput output : outputs) { + data.setPosition(dataPosition); + output.sampleData(data, bytesAvailable); + } + sampleBytesWritten += bytesAvailable; + } + } + + private boolean checkNextByte(ParsableByteArray data, int expectedValue) { + if (data.bytesLeft() == 0) { + return false; + } + if (data.readUnsignedByte() != expectedValue) { + writingSample = false; + } + bytesToCheck--; + return writingSample; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java new file mode 100644 index 0000000000..edd33d02c2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/ElementaryStreamReader.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Extracts individual samples from an elementary media stream, preserving original order. + */ +public interface ElementaryStreamReader { + + /** + * Notifies the reader that a seek has occurred. + */ + void seek(); + + /** + * Initializes the reader by providing outputs and ids for the tracks. + * + * @param extractorOutput The {@link ExtractorOutput} that receives the extracted data. + * @param idGenerator A {@link PesReader.TrackIdGenerator} that generates unique track ids for the + * {@link TrackOutput}s. + */ + void createTracks(ExtractorOutput extractorOutput, PesReader.TrackIdGenerator idGenerator); + + /** + * Called when a packet starts. + * + * @param pesTimeUs The timestamp associated with the packet. + * @param flags See {@link TsPayloadReader.Flags}. + */ + void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags); + + /** + * Consumes (possibly partial) data from the current packet. + * + * @param data The data to consume. + * @throws ParserException If the data could not be parsed. + */ + void consume(ParsableByteArray data) throws ParserException; + + /** + * Called when a packet ends. + */ + void packetFinished(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H262Reader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H262Reader.java new file mode 100644 index 0000000000..576607366e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H262Reader.java @@ -0,0 +1,333 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Arrays; +import java.util.Collections; + +/** + * Parses a continuous H262 byte stream and extracts individual frames. + */ +public final class H262Reader implements ElementaryStreamReader { + + private static final int START_PICTURE = 0x00; + private static final int START_SEQUENCE_HEADER = 0xB3; + private static final int START_EXTENSION = 0xB5; + private static final int START_GROUP = 0xB8; + private static final int START_USER_DATA = 0xB2; + + private String formatId; + private TrackOutput output; + + // Maps (frame_rate_code - 1) indices to values, as defined in ITU-T H.262 Table 6-4. + private static final double[] FRAME_RATE_VALUES = new double[] { + 24000d / 1001, 24, 25, 30000d / 1001, 30, 50, 60000d / 1001, 60}; + + // State that should not be reset on seek. + private boolean hasOutputFormat; + private long frameDurationUs; + + private final UserDataReader userDataReader; + private final ParsableByteArray userDataParsable; + + // State that should be reset on seek. + private final boolean[] prefixFlags; + private final CsdBuffer csdBuffer; + private final NalUnitTargetBuffer userData; + private long totalBytesWritten; + private boolean startedFirstSample; + + // Per packet state that gets reset at the start of each packet. + private long pesTimeUs; + + // Per sample state that gets reset at the start of each sample. + private long samplePosition; + private long sampleTimeUs; + private boolean sampleIsKeyframe; + private boolean sampleHasPicture; + + public H262Reader() { + this(null); + } + + /* package */ H262Reader(UserDataReader userDataReader) { + this.userDataReader = userDataReader; + prefixFlags = new boolean[4]; + csdBuffer = new CsdBuffer(128); + if (userDataReader != null) { + userData = new NalUnitTargetBuffer(START_USER_DATA, 128); + userDataParsable = new ParsableByteArray(); + } else { + userData = null; + userDataParsable = null; + } + } + + @Override + public void seek() { + NalUnitUtil.clearPrefixFlags(prefixFlags); + csdBuffer.reset(); + if (userDataReader != null) { + userData.reset(); + } + totalBytesWritten = 0; + startedFirstSample = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_VIDEO); + if (userDataReader != null) { + userDataReader.createTracks(extractorOutput, idGenerator); + } + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + // TODO (Internal b/32267012): Consider using random access indicator. + this.pesTimeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + int offset = data.getPosition(); + int limit = data.limit(); + byte[] dataArray = data.data; + + // Append the data to the buffer. + totalBytesWritten += data.bytesLeft(); + output.sampleData(data, data.bytesLeft()); + + while (true) { + int startCodeOffset = NalUnitUtil.findNalUnit(dataArray, offset, limit, prefixFlags); + + if (startCodeOffset == limit) { + // We've scanned to the end of the data without finding another start code. + if (!hasOutputFormat) { + csdBuffer.onData(dataArray, offset, limit); + } + if (userDataReader != null) { + userData.appendToNalUnit(dataArray, offset, limit); + } + return; + } + + // We've found a start code with the following value. + int startCodeValue = data.data[startCodeOffset + 3] & 0xFF; + // This is the number of bytes from the current offset to the start of the next start + // code. It may be negative if the start code started in the previously consumed data. + int lengthToStartCode = startCodeOffset - offset; + + if (!hasOutputFormat) { + if (lengthToStartCode > 0) { + csdBuffer.onData(dataArray, offset, startCodeOffset); + } + // This is the number of bytes belonging to the next start code that have already been + // passed to csdBuffer. + int bytesAlreadyPassed = lengthToStartCode < 0 ? -lengthToStartCode : 0; + if (csdBuffer.onStartCode(startCodeValue, bytesAlreadyPassed)) { + // The csd data is complete, so we can decode and output the media format. + Pair<Format, Long> result = parseCsdBuffer(csdBuffer, formatId); + output.format(result.first); + frameDurationUs = result.second; + hasOutputFormat = true; + } + } + if (userDataReader != null) { + int bytesAlreadyPassed = 0; + if (lengthToStartCode > 0) { + userData.appendToNalUnit(dataArray, offset, startCodeOffset); + } else { + bytesAlreadyPassed = -lengthToStartCode; + } + + if (userData.endNalUnit(bytesAlreadyPassed)) { + int unescapedLength = NalUnitUtil.unescapeStream(userData.nalData, userData.nalLength); + userDataParsable.reset(userData.nalData, unescapedLength); + userDataReader.consume(sampleTimeUs, userDataParsable); + } + + if (startCodeValue == START_USER_DATA && data.data[startCodeOffset + 2] == 0x1) { + userData.startNalUnit(startCodeValue); + } + } + if (startCodeValue == START_PICTURE || startCodeValue == START_SEQUENCE_HEADER) { + int bytesWrittenPastStartCode = limit - startCodeOffset; + if (startedFirstSample && sampleHasPicture && hasOutputFormat) { + // Output the sample. + @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; + int size = (int) (totalBytesWritten - samplePosition) - bytesWrittenPastStartCode; + output.sampleMetadata(sampleTimeUs, flags, size, bytesWrittenPastStartCode, null); + } + if (!startedFirstSample || sampleHasPicture) { + // Start the next sample. + samplePosition = totalBytesWritten - bytesWrittenPastStartCode; + sampleTimeUs = pesTimeUs != C.TIME_UNSET ? pesTimeUs + : (startedFirstSample ? (sampleTimeUs + frameDurationUs) : 0); + sampleIsKeyframe = false; + pesTimeUs = C.TIME_UNSET; + startedFirstSample = true; + } + sampleHasPicture = startCodeValue == START_PICTURE; + } else if (startCodeValue == START_GROUP) { + sampleIsKeyframe = true; + } + + offset = startCodeOffset + 3; + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Parses the {@link Format} and frame duration from a csd buffer. + * + * @param csdBuffer The csd buffer. + * @param formatId The id for the generated format. May be null. + * @return A pair consisting of the {@link Format} and the frame duration in microseconds, or + * 0 if the duration could not be determined. + */ + private static Pair<Format, Long> parseCsdBuffer(CsdBuffer csdBuffer, String formatId) { + byte[] csdData = Arrays.copyOf(csdBuffer.data, csdBuffer.length); + + int firstByte = csdData[4] & 0xFF; + int secondByte = csdData[5] & 0xFF; + int thirdByte = csdData[6] & 0xFF; + int width = (firstByte << 4) | (secondByte >> 4); + int height = (secondByte & 0x0F) << 8 | thirdByte; + + float pixelWidthHeightRatio = 1f; + int aspectRatioCode = (csdData[7] & 0xF0) >> 4; + switch(aspectRatioCode) { + case 2: + pixelWidthHeightRatio = (4 * height) / (float) (3 * width); + break; + case 3: + pixelWidthHeightRatio = (16 * height) / (float) (9 * width); + break; + case 4: + pixelWidthHeightRatio = (121 * height) / (float) (100 * width); + break; + default: + // Do nothing. + break; + } + + Format format = Format.createVideoSampleFormat(formatId, MimeTypes.VIDEO_MPEG2, null, + Format.NO_VALUE, Format.NO_VALUE, width, height, Format.NO_VALUE, + Collections.singletonList(csdData), Format.NO_VALUE, pixelWidthHeightRatio, null); + + long frameDurationUs = 0; + int frameRateCodeMinusOne = (csdData[7] & 0x0F) - 1; + if (0 <= frameRateCodeMinusOne && frameRateCodeMinusOne < FRAME_RATE_VALUES.length) { + double frameRate = FRAME_RATE_VALUES[frameRateCodeMinusOne]; + int sequenceExtensionPosition = csdBuffer.sequenceExtensionPosition; + int frameRateExtensionN = (csdData[sequenceExtensionPosition + 9] & 0x60) >> 5; + int frameRateExtensionD = (csdData[sequenceExtensionPosition + 9] & 0x1F); + if (frameRateExtensionN != frameRateExtensionD) { + frameRate *= (frameRateExtensionN + 1d) / (frameRateExtensionD + 1); + } + frameDurationUs = (long) (C.MICROS_PER_SECOND / frameRate); + } + + return Pair.create(format, frameDurationUs); + } + + private static final class CsdBuffer { + + private static final byte[] START_CODE = new byte[] {0, 0, 1}; + + private boolean isFilling; + + public int length; + public int sequenceExtensionPosition; + public byte[] data; + + public CsdBuffer(int initialCapacity) { + data = new byte[initialCapacity]; + } + + /** + * Resets the buffer, clearing any data that it holds. + */ + public void reset() { + isFilling = false; + length = 0; + sequenceExtensionPosition = 0; + } + + /** + * Called when a start code is encountered in the stream. + * + * @param startCodeValue The start code value. + * @param bytesAlreadyPassed The number of bytes of the start code that have been passed to + * {@link #onData(byte[], int, int)}, or 0. + * @return Whether the csd data is now complete. If true is returned, neither + * this method nor {@link #onData(byte[], int, int)} should be called again without an + * interleaving call to {@link #reset()}. + */ + public boolean onStartCode(int startCodeValue, int bytesAlreadyPassed) { + if (isFilling) { + length -= bytesAlreadyPassed; + if (sequenceExtensionPosition == 0 && startCodeValue == START_EXTENSION) { + sequenceExtensionPosition = length; + } else { + isFilling = false; + return true; + } + } else if (startCodeValue == START_SEQUENCE_HEADER) { + isFilling = true; + } + onData(START_CODE, 0, START_CODE.length); + return false; + } + + /** + * Called to pass stream data. + * + * @param newData Holds the data being passed. + * @param offset The offset of the data in {@code data}. + * @param limit The limit (exclusive) of the data in {@code data}. + */ + public void onData(byte[] newData, int offset, int limit) { + if (!isFilling) { + return; + } + int readLength = limit - offset; + if (data.length < length + readLength) { + data = Arrays.copyOf(data, (length + readLength) * 2); + } + System.arraycopy(newData, offset, data, length, readLength); + length += readLength; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H264Reader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H264Reader.java new file mode 100644 index 0000000000..164c115159 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H264Reader.java @@ -0,0 +1,567 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR; + +import android.util.SparseArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.CodecSpecificDataUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil.SpsData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableNalUnitBitArray; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * Parses a continuous H264 byte stream and extracts individual frames. + */ +public final class H264Reader implements ElementaryStreamReader { + + private static final int NAL_UNIT_TYPE_SEI = 6; // Supplemental enhancement information + private static final int NAL_UNIT_TYPE_SPS = 7; // Sequence parameter set + private static final int NAL_UNIT_TYPE_PPS = 8; // Picture parameter set + + private final SeiReader seiReader; + private final boolean allowNonIdrKeyframes; + private final boolean detectAccessUnits; + private final NalUnitTargetBuffer sps; + private final NalUnitTargetBuffer pps; + private final NalUnitTargetBuffer sei; + private long totalBytesWritten; + private final boolean[] prefixFlags; + + private String formatId; + private TrackOutput output; + private SampleReader sampleReader; + + // State that should not be reset on seek. + private boolean hasOutputFormat; + + // Per PES packet state that gets reset at the start of each PES packet. + private long pesTimeUs; + + // State inherited from the TS packet header. + private boolean randomAccessIndicator; + + // Scratch variables to avoid allocations. + private final ParsableByteArray seiWrapper; + + /** + * @param seiReader An SEI reader for consuming closed caption channels. + * @param allowNonIdrKeyframes Whether to treat samples consisting of non-IDR I slices as + * synchronization samples (key-frames). + * @param detectAccessUnits Whether to split the input stream into access units (samples) based on + * slice headers. Pass {@code false} if the stream contains access unit delimiters (AUDs). + */ + public H264Reader(SeiReader seiReader, boolean allowNonIdrKeyframes, boolean detectAccessUnits) { + this.seiReader = seiReader; + this.allowNonIdrKeyframes = allowNonIdrKeyframes; + this.detectAccessUnits = detectAccessUnits; + prefixFlags = new boolean[3]; + sps = new NalUnitTargetBuffer(NAL_UNIT_TYPE_SPS, 128); + pps = new NalUnitTargetBuffer(NAL_UNIT_TYPE_PPS, 128); + sei = new NalUnitTargetBuffer(NAL_UNIT_TYPE_SEI, 128); + seiWrapper = new ParsableByteArray(); + } + + @Override + public void seek() { + NalUnitUtil.clearPrefixFlags(prefixFlags); + sps.reset(); + pps.reset(); + sei.reset(); + sampleReader.reset(); + totalBytesWritten = 0; + randomAccessIndicator = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_VIDEO); + sampleReader = new SampleReader(output, allowNonIdrKeyframes, detectAccessUnits); + seiReader.createTracks(extractorOutput, idGenerator); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + this.pesTimeUs = pesTimeUs; + randomAccessIndicator |= (flags & FLAG_RANDOM_ACCESS_INDICATOR) != 0; + } + + @Override + public void consume(ParsableByteArray data) { + int offset = data.getPosition(); + int limit = data.limit(); + byte[] dataArray = data.data; + + // Append the data to the buffer. + totalBytesWritten += data.bytesLeft(); + output.sampleData(data, data.bytesLeft()); + + // Scan the appended data, processing NAL units as they are encountered + while (true) { + int nalUnitOffset = NalUnitUtil.findNalUnit(dataArray, offset, limit, prefixFlags); + + if (nalUnitOffset == limit) { + // We've scanned to the end of the data without finding the start of another NAL unit. + nalUnitData(dataArray, offset, limit); + return; + } + + // We've seen the start of a NAL unit of the following type. + int nalUnitType = NalUnitUtil.getNalUnitType(dataArray, nalUnitOffset); + + // This is the number of bytes from the current offset to the start of the next NAL unit. + // It may be negative if the NAL unit started in the previously consumed data. + int lengthToNalUnit = nalUnitOffset - offset; + if (lengthToNalUnit > 0) { + nalUnitData(dataArray, offset, nalUnitOffset); + } + int bytesWrittenPastPosition = limit - nalUnitOffset; + long absolutePosition = totalBytesWritten - bytesWrittenPastPosition; + // Indicate the end of the previous NAL unit. If the length to the start of the next unit + // is negative then we wrote too many bytes to the NAL buffers. Discard the excess bytes + // when notifying that the unit has ended. + endNalUnit(absolutePosition, bytesWrittenPastPosition, + lengthToNalUnit < 0 ? -lengthToNalUnit : 0, pesTimeUs); + // Indicate the start of the next NAL unit. + startNalUnit(absolutePosition, nalUnitType, pesTimeUs); + // Continue scanning the data. + offset = nalUnitOffset + 3; + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + private void startNalUnit(long position, int nalUnitType, long pesTimeUs) { + if (!hasOutputFormat || sampleReader.needsSpsPps()) { + sps.startNalUnit(nalUnitType); + pps.startNalUnit(nalUnitType); + } + sei.startNalUnit(nalUnitType); + sampleReader.startNalUnit(position, nalUnitType, pesTimeUs); + } + + private void nalUnitData(byte[] dataArray, int offset, int limit) { + if (!hasOutputFormat || sampleReader.needsSpsPps()) { + sps.appendToNalUnit(dataArray, offset, limit); + pps.appendToNalUnit(dataArray, offset, limit); + } + sei.appendToNalUnit(dataArray, offset, limit); + sampleReader.appendToNalUnit(dataArray, offset, limit); + } + + private void endNalUnit(long position, int offset, int discardPadding, long pesTimeUs) { + if (!hasOutputFormat || sampleReader.needsSpsPps()) { + sps.endNalUnit(discardPadding); + pps.endNalUnit(discardPadding); + if (!hasOutputFormat) { + if (sps.isCompleted() && pps.isCompleted()) { + List<byte[]> initializationData = new ArrayList<>(); + initializationData.add(Arrays.copyOf(sps.nalData, sps.nalLength)); + initializationData.add(Arrays.copyOf(pps.nalData, pps.nalLength)); + NalUnitUtil.SpsData spsData = NalUnitUtil.parseSpsNalUnit(sps.nalData, 3, sps.nalLength); + NalUnitUtil.PpsData ppsData = NalUnitUtil.parsePpsNalUnit(pps.nalData, 3, pps.nalLength); + output.format( + Format.createVideoSampleFormat( + formatId, + MimeTypes.VIDEO_H264, + CodecSpecificDataUtil.buildAvcCodecString( + spsData.profileIdc, + spsData.constraintsFlagsAndReservedZero2Bits, + spsData.levelIdc), + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + spsData.width, + spsData.height, + /* frameRate= */ Format.NO_VALUE, + initializationData, + /* rotationDegrees= */ Format.NO_VALUE, + spsData.pixelWidthAspectRatio, + /* drmInitData= */ null)); + hasOutputFormat = true; + sampleReader.putSps(spsData); + sampleReader.putPps(ppsData); + sps.reset(); + pps.reset(); + } + } else if (sps.isCompleted()) { + NalUnitUtil.SpsData spsData = NalUnitUtil.parseSpsNalUnit(sps.nalData, 3, sps.nalLength); + sampleReader.putSps(spsData); + sps.reset(); + } else if (pps.isCompleted()) { + NalUnitUtil.PpsData ppsData = NalUnitUtil.parsePpsNalUnit(pps.nalData, 3, pps.nalLength); + sampleReader.putPps(ppsData); + pps.reset(); + } + } + if (sei.endNalUnit(discardPadding)) { + int unescapedLength = NalUnitUtil.unescapeStream(sei.nalData, sei.nalLength); + seiWrapper.reset(sei.nalData, unescapedLength); + seiWrapper.setPosition(4); // NAL prefix and nal_unit() header. + seiReader.consume(pesTimeUs, seiWrapper); + } + boolean sampleIsKeyFrame = + sampleReader.endNalUnit(position, offset, hasOutputFormat, randomAccessIndicator); + if (sampleIsKeyFrame) { + // This is either an IDR frame or the first I-frame since the random access indicator, so mark + // it as a keyframe. Clear the flag so that subsequent non-IDR I-frames are not marked as + // keyframes until we see another random access indicator. + randomAccessIndicator = false; + } + } + + /** Consumes a stream of NAL units and outputs samples. */ + private static final class SampleReader { + + private static final int DEFAULT_BUFFER_SIZE = 128; + + private static final int NAL_UNIT_TYPE_NON_IDR = 1; // Coded slice of a non-IDR picture + private static final int NAL_UNIT_TYPE_PARTITION_A = 2; // Coded slice data partition A + private static final int NAL_UNIT_TYPE_IDR = 5; // Coded slice of an IDR picture + private static final int NAL_UNIT_TYPE_AUD = 9; // Access unit delimiter + + private final TrackOutput output; + private final boolean allowNonIdrKeyframes; + private final boolean detectAccessUnits; + private final SparseArray<NalUnitUtil.SpsData> sps; + private final SparseArray<NalUnitUtil.PpsData> pps; + private final ParsableNalUnitBitArray bitArray; + + private byte[] buffer; + private int bufferLength; + + // Per NAL unit state. A sample consists of one or more NAL units. + private int nalUnitType; + private long nalUnitStartPosition; + private boolean isFilling; + private long nalUnitTimeUs; + private SliceHeaderData previousSliceHeader; + private SliceHeaderData sliceHeader; + + // Per sample state that gets reset at the start of each sample. + private boolean readingSample; + private long samplePosition; + private long sampleTimeUs; + private boolean sampleIsKeyframe; + + public SampleReader(TrackOutput output, boolean allowNonIdrKeyframes, + boolean detectAccessUnits) { + this.output = output; + this.allowNonIdrKeyframes = allowNonIdrKeyframes; + this.detectAccessUnits = detectAccessUnits; + sps = new SparseArray<>(); + pps = new SparseArray<>(); + previousSliceHeader = new SliceHeaderData(); + sliceHeader = new SliceHeaderData(); + buffer = new byte[DEFAULT_BUFFER_SIZE]; + bitArray = new ParsableNalUnitBitArray(buffer, 0, 0); + reset(); + } + + public boolean needsSpsPps() { + return detectAccessUnits; + } + + public void putSps(NalUnitUtil.SpsData spsData) { + sps.append(spsData.seqParameterSetId, spsData); + } + + public void putPps(NalUnitUtil.PpsData ppsData) { + pps.append(ppsData.picParameterSetId, ppsData); + } + + public void reset() { + isFilling = false; + readingSample = false; + sliceHeader.clear(); + } + + public void startNalUnit(long position, int type, long pesTimeUs) { + nalUnitType = type; + nalUnitTimeUs = pesTimeUs; + nalUnitStartPosition = position; + if ((allowNonIdrKeyframes && nalUnitType == NAL_UNIT_TYPE_NON_IDR) + || (detectAccessUnits && (nalUnitType == NAL_UNIT_TYPE_IDR + || nalUnitType == NAL_UNIT_TYPE_NON_IDR + || nalUnitType == NAL_UNIT_TYPE_PARTITION_A))) { + // Store the previous header and prepare to populate the new one. + SliceHeaderData newSliceHeader = previousSliceHeader; + previousSliceHeader = sliceHeader; + sliceHeader = newSliceHeader; + sliceHeader.clear(); + bufferLength = 0; + isFilling = true; + } + } + + /** + * Called to pass stream data. The data passed should not include the 3 byte start code. + * + * @param data Holds the data being passed. + * @param offset The offset of the data in {@code data}. + * @param limit The limit (exclusive) of the data in {@code data}. + */ + public void appendToNalUnit(byte[] data, int offset, int limit) { + if (!isFilling) { + return; + } + int readLength = limit - offset; + if (buffer.length < bufferLength + readLength) { + buffer = Arrays.copyOf(buffer, (bufferLength + readLength) * 2); + } + System.arraycopy(data, offset, buffer, bufferLength, readLength); + bufferLength += readLength; + + bitArray.reset(buffer, 0, bufferLength); + if (!bitArray.canReadBits(8)) { + return; + } + bitArray.skipBit(); // forbidden_zero_bit + int nalRefIdc = bitArray.readBits(2); + bitArray.skipBits(5); // nal_unit_type + + // Read the slice header using the syntax defined in ITU-T Recommendation H.264 (2013) + // subsection 7.3.3. + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + bitArray.readUnsignedExpGolombCodedInt(); // first_mb_in_slice + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + int sliceType = bitArray.readUnsignedExpGolombCodedInt(); + if (!detectAccessUnits) { + // There are AUDs in the stream so the rest of the header can be ignored. + isFilling = false; + sliceHeader.setSliceType(sliceType); + return; + } + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + int picParameterSetId = bitArray.readUnsignedExpGolombCodedInt(); + if (pps.indexOfKey(picParameterSetId) < 0) { + // We have not seen the PPS yet, so don't try to decode the slice header. + isFilling = false; + return; + } + NalUnitUtil.PpsData ppsData = pps.get(picParameterSetId); + NalUnitUtil.SpsData spsData = sps.get(ppsData.seqParameterSetId); + if (spsData.separateColorPlaneFlag) { + if (!bitArray.canReadBits(2)) { + return; + } + bitArray.skipBits(2); // colour_plane_id + } + if (!bitArray.canReadBits(spsData.frameNumLength)) { + return; + } + boolean fieldPicFlag = false; + boolean bottomFieldFlagPresent = false; + boolean bottomFieldFlag = false; + int frameNum = bitArray.readBits(spsData.frameNumLength); + if (!spsData.frameMbsOnlyFlag) { + if (!bitArray.canReadBits(1)) { + return; + } + fieldPicFlag = bitArray.readBit(); + if (fieldPicFlag) { + if (!bitArray.canReadBits(1)) { + return; + } + bottomFieldFlag = bitArray.readBit(); + bottomFieldFlagPresent = true; + } + } + boolean idrPicFlag = nalUnitType == NAL_UNIT_TYPE_IDR; + int idrPicId = 0; + if (idrPicFlag) { + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + idrPicId = bitArray.readUnsignedExpGolombCodedInt(); + } + int picOrderCntLsb = 0; + int deltaPicOrderCntBottom = 0; + int deltaPicOrderCnt0 = 0; + int deltaPicOrderCnt1 = 0; + if (spsData.picOrderCountType == 0) { + if (!bitArray.canReadBits(spsData.picOrderCntLsbLength)) { + return; + } + picOrderCntLsb = bitArray.readBits(spsData.picOrderCntLsbLength); + if (ppsData.bottomFieldPicOrderInFramePresentFlag && !fieldPicFlag) { + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + deltaPicOrderCntBottom = bitArray.readSignedExpGolombCodedInt(); + } + } else if (spsData.picOrderCountType == 1 + && !spsData.deltaPicOrderAlwaysZeroFlag) { + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + deltaPicOrderCnt0 = bitArray.readSignedExpGolombCodedInt(); + if (ppsData.bottomFieldPicOrderInFramePresentFlag && !fieldPicFlag) { + if (!bitArray.canReadExpGolombCodedNum()) { + return; + } + deltaPicOrderCnt1 = bitArray.readSignedExpGolombCodedInt(); + } + } + sliceHeader.setAll(spsData, nalRefIdc, sliceType, frameNum, picParameterSetId, fieldPicFlag, + bottomFieldFlagPresent, bottomFieldFlag, idrPicFlag, idrPicId, picOrderCntLsb, + deltaPicOrderCntBottom, deltaPicOrderCnt0, deltaPicOrderCnt1); + isFilling = false; + } + + public boolean endNalUnit( + long position, int offset, boolean hasOutputFormat, boolean randomAccessIndicator) { + if (nalUnitType == NAL_UNIT_TYPE_AUD + || (detectAccessUnits && sliceHeader.isFirstVclNalUnitOfPicture(previousSliceHeader))) { + // If the NAL unit ending is the start of a new sample, output the previous one. + if (hasOutputFormat && readingSample) { + int nalUnitLength = (int) (position - nalUnitStartPosition); + outputSample(offset + nalUnitLength); + } + samplePosition = nalUnitStartPosition; + sampleTimeUs = nalUnitTimeUs; + sampleIsKeyframe = false; + readingSample = true; + } + boolean treatIFrameAsKeyframe = + allowNonIdrKeyframes ? sliceHeader.isISlice() : randomAccessIndicator; + sampleIsKeyframe |= + nalUnitType == NAL_UNIT_TYPE_IDR + || (treatIFrameAsKeyframe && nalUnitType == NAL_UNIT_TYPE_NON_IDR); + return sampleIsKeyframe; + } + + private void outputSample(int offset) { + @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; + int size = (int) (nalUnitStartPosition - samplePosition); + output.sampleMetadata(sampleTimeUs, flags, size, offset, null); + } + + private static final class SliceHeaderData { + + private static final int SLICE_TYPE_I = 2; + private static final int SLICE_TYPE_ALL_I = 7; + + private boolean isComplete; + private boolean hasSliceType; + + private SpsData spsData; + private int nalRefIdc; + private int sliceType; + private int frameNum; + private int picParameterSetId; + private boolean fieldPicFlag; + private boolean bottomFieldFlagPresent; + private boolean bottomFieldFlag; + private boolean idrPicFlag; + private int idrPicId; + private int picOrderCntLsb; + private int deltaPicOrderCntBottom; + private int deltaPicOrderCnt0; + private int deltaPicOrderCnt1; + + public void clear() { + hasSliceType = false; + isComplete = false; + } + + public void setSliceType(int sliceType) { + this.sliceType = sliceType; + hasSliceType = true; + } + + public void setAll( + SpsData spsData, + int nalRefIdc, + int sliceType, + int frameNum, + int picParameterSetId, + boolean fieldPicFlag, + boolean bottomFieldFlagPresent, + boolean bottomFieldFlag, + boolean idrPicFlag, + int idrPicId, + int picOrderCntLsb, + int deltaPicOrderCntBottom, + int deltaPicOrderCnt0, + int deltaPicOrderCnt1) { + this.spsData = spsData; + this.nalRefIdc = nalRefIdc; + this.sliceType = sliceType; + this.frameNum = frameNum; + this.picParameterSetId = picParameterSetId; + this.fieldPicFlag = fieldPicFlag; + this.bottomFieldFlagPresent = bottomFieldFlagPresent; + this.bottomFieldFlag = bottomFieldFlag; + this.idrPicFlag = idrPicFlag; + this.idrPicId = idrPicId; + this.picOrderCntLsb = picOrderCntLsb; + this.deltaPicOrderCntBottom = deltaPicOrderCntBottom; + this.deltaPicOrderCnt0 = deltaPicOrderCnt0; + this.deltaPicOrderCnt1 = deltaPicOrderCnt1; + isComplete = true; + hasSliceType = true; + } + + public boolean isISlice() { + return hasSliceType && (sliceType == SLICE_TYPE_ALL_I || sliceType == SLICE_TYPE_I); + } + + private boolean isFirstVclNalUnitOfPicture(SliceHeaderData other) { + // See ISO 14496-10 subsection 7.4.1.2.4. + return isComplete + && (!other.isComplete + || frameNum != other.frameNum + || picParameterSetId != other.picParameterSetId + || fieldPicFlag != other.fieldPicFlag + || (bottomFieldFlagPresent + && other.bottomFieldFlagPresent + && bottomFieldFlag != other.bottomFieldFlag) + || (nalRefIdc != other.nalRefIdc && (nalRefIdc == 0 || other.nalRefIdc == 0)) + || (spsData.picOrderCountType == 0 + && other.spsData.picOrderCountType == 0 + && (picOrderCntLsb != other.picOrderCntLsb + || deltaPicOrderCntBottom != other.deltaPicOrderCntBottom)) + || (spsData.picOrderCountType == 1 + && other.spsData.picOrderCountType == 1 + && (deltaPicOrderCnt0 != other.deltaPicOrderCnt0 + || deltaPicOrderCnt1 != other.deltaPicOrderCnt1)) + || idrPicFlag != other.idrPicFlag + || (idrPicFlag && other.idrPicFlag && idrPicId != other.idrPicId)); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H265Reader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H265Reader.java new file mode 100644 index 0000000000..6aa7c5d71d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/H265Reader.java @@ -0,0 +1,494 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableNalUnitBitArray; +import java.util.Collections; + +/** + * Parses a continuous H.265 byte stream and extracts individual frames. + */ +public final class H265Reader implements ElementaryStreamReader { + + private static final String TAG = "H265Reader"; + + // nal_unit_type values from H.265/HEVC (2014) Table 7-1. + private static final int RASL_R = 9; + private static final int BLA_W_LP = 16; + private static final int CRA_NUT = 21; + private static final int VPS_NUT = 32; + private static final int SPS_NUT = 33; + private static final int PPS_NUT = 34; + private static final int PREFIX_SEI_NUT = 39; + private static final int SUFFIX_SEI_NUT = 40; + + private final SeiReader seiReader; + + private String formatId; + private TrackOutput output; + private SampleReader sampleReader; + + // State that should not be reset on seek. + private boolean hasOutputFormat; + + // State that should be reset on seek. + private final boolean[] prefixFlags; + private final NalUnitTargetBuffer vps; + private final NalUnitTargetBuffer sps; + private final NalUnitTargetBuffer pps; + private final NalUnitTargetBuffer prefixSei; + private final NalUnitTargetBuffer suffixSei; // TODO: Are both needed? + private long totalBytesWritten; + + // Per packet state that gets reset at the start of each packet. + private long pesTimeUs; + + // Scratch variables to avoid allocations. + private final ParsableByteArray seiWrapper; + + /** + * @param seiReader An SEI reader for consuming closed caption channels. + */ + public H265Reader(SeiReader seiReader) { + this.seiReader = seiReader; + prefixFlags = new boolean[3]; + vps = new NalUnitTargetBuffer(VPS_NUT, 128); + sps = new NalUnitTargetBuffer(SPS_NUT, 128); + pps = new NalUnitTargetBuffer(PPS_NUT, 128); + prefixSei = new NalUnitTargetBuffer(PREFIX_SEI_NUT, 128); + suffixSei = new NalUnitTargetBuffer(SUFFIX_SEI_NUT, 128); + seiWrapper = new ParsableByteArray(); + } + + @Override + public void seek() { + NalUnitUtil.clearPrefixFlags(prefixFlags); + vps.reset(); + sps.reset(); + pps.reset(); + prefixSei.reset(); + suffixSei.reset(); + sampleReader.reset(); + totalBytesWritten = 0; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_VIDEO); + sampleReader = new SampleReader(output); + seiReader.createTracks(extractorOutput, idGenerator); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + // TODO (Internal b/32267012): Consider using random access indicator. + this.pesTimeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + while (data.bytesLeft() > 0) { + int offset = data.getPosition(); + int limit = data.limit(); + byte[] dataArray = data.data; + + // Append the data to the buffer. + totalBytesWritten += data.bytesLeft(); + output.sampleData(data, data.bytesLeft()); + + // Scan the appended data, processing NAL units as they are encountered + while (offset < limit) { + int nalUnitOffset = NalUnitUtil.findNalUnit(dataArray, offset, limit, prefixFlags); + + if (nalUnitOffset == limit) { + // We've scanned to the end of the data without finding the start of another NAL unit. + nalUnitData(dataArray, offset, limit); + return; + } + + // We've seen the start of a NAL unit of the following type. + int nalUnitType = NalUnitUtil.getH265NalUnitType(dataArray, nalUnitOffset); + + // This is the number of bytes from the current offset to the start of the next NAL unit. + // It may be negative if the NAL unit started in the previously consumed data. + int lengthToNalUnit = nalUnitOffset - offset; + if (lengthToNalUnit > 0) { + nalUnitData(dataArray, offset, nalUnitOffset); + } + + int bytesWrittenPastPosition = limit - nalUnitOffset; + long absolutePosition = totalBytesWritten - bytesWrittenPastPosition; + // Indicate the end of the previous NAL unit. If the length to the start of the next unit + // is negative then we wrote too many bytes to the NAL buffers. Discard the excess bytes + // when notifying that the unit has ended. + endNalUnit(absolutePosition, bytesWrittenPastPosition, + lengthToNalUnit < 0 ? -lengthToNalUnit : 0, pesTimeUs); + // Indicate the start of the next NAL unit. + startNalUnit(absolutePosition, bytesWrittenPastPosition, nalUnitType, pesTimeUs); + // Continue scanning the data. + offset = nalUnitOffset + 3; + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + private void startNalUnit(long position, int offset, int nalUnitType, long pesTimeUs) { + if (hasOutputFormat) { + sampleReader.startNalUnit(position, offset, nalUnitType, pesTimeUs); + } else { + vps.startNalUnit(nalUnitType); + sps.startNalUnit(nalUnitType); + pps.startNalUnit(nalUnitType); + } + prefixSei.startNalUnit(nalUnitType); + suffixSei.startNalUnit(nalUnitType); + } + + private void nalUnitData(byte[] dataArray, int offset, int limit) { + if (hasOutputFormat) { + sampleReader.readNalUnitData(dataArray, offset, limit); + } else { + vps.appendToNalUnit(dataArray, offset, limit); + sps.appendToNalUnit(dataArray, offset, limit); + pps.appendToNalUnit(dataArray, offset, limit); + } + prefixSei.appendToNalUnit(dataArray, offset, limit); + suffixSei.appendToNalUnit(dataArray, offset, limit); + } + + private void endNalUnit(long position, int offset, int discardPadding, long pesTimeUs) { + if (hasOutputFormat) { + sampleReader.endNalUnit(position, offset); + } else { + vps.endNalUnit(discardPadding); + sps.endNalUnit(discardPadding); + pps.endNalUnit(discardPadding); + if (vps.isCompleted() && sps.isCompleted() && pps.isCompleted()) { + output.format(parseMediaFormat(formatId, vps, sps, pps)); + hasOutputFormat = true; + } + } + if (prefixSei.endNalUnit(discardPadding)) { + int unescapedLength = NalUnitUtil.unescapeStream(prefixSei.nalData, prefixSei.nalLength); + seiWrapper.reset(prefixSei.nalData, unescapedLength); + + // Skip the NAL prefix and type. + seiWrapper.skipBytes(5); + seiReader.consume(pesTimeUs, seiWrapper); + } + if (suffixSei.endNalUnit(discardPadding)) { + int unescapedLength = NalUnitUtil.unescapeStream(suffixSei.nalData, suffixSei.nalLength); + seiWrapper.reset(suffixSei.nalData, unescapedLength); + + // Skip the NAL prefix and type. + seiWrapper.skipBytes(5); + seiReader.consume(pesTimeUs, seiWrapper); + } + } + + private static Format parseMediaFormat(String formatId, NalUnitTargetBuffer vps, + NalUnitTargetBuffer sps, NalUnitTargetBuffer pps) { + // Build codec-specific data. + byte[] csd = new byte[vps.nalLength + sps.nalLength + pps.nalLength]; + System.arraycopy(vps.nalData, 0, csd, 0, vps.nalLength); + System.arraycopy(sps.nalData, 0, csd, vps.nalLength, sps.nalLength); + System.arraycopy(pps.nalData, 0, csd, vps.nalLength + sps.nalLength, pps.nalLength); + + // Parse the SPS NAL unit, as per H.265/HEVC (2014) 7.3.2.2.1. + ParsableNalUnitBitArray bitArray = new ParsableNalUnitBitArray(sps.nalData, 0, sps.nalLength); + bitArray.skipBits(40 + 4); // NAL header, sps_video_parameter_set_id + int maxSubLayersMinus1 = bitArray.readBits(3); + bitArray.skipBit(); // sps_temporal_id_nesting_flag + + // profile_tier_level(1, sps_max_sub_layers_minus1) + bitArray.skipBits(88); // if (profilePresentFlag) {...} + bitArray.skipBits(8); // general_level_idc + int toSkip = 0; + for (int i = 0; i < maxSubLayersMinus1; i++) { + if (bitArray.readBit()) { // sub_layer_profile_present_flag[i] + toSkip += 89; + } + if (bitArray.readBit()) { // sub_layer_level_present_flag[i] + toSkip += 8; + } + } + bitArray.skipBits(toSkip); + if (maxSubLayersMinus1 > 0) { + bitArray.skipBits(2 * (8 - maxSubLayersMinus1)); + } + + bitArray.readUnsignedExpGolombCodedInt(); // sps_seq_parameter_set_id + int chromaFormatIdc = bitArray.readUnsignedExpGolombCodedInt(); + if (chromaFormatIdc == 3) { + bitArray.skipBit(); // separate_colour_plane_flag + } + int picWidthInLumaSamples = bitArray.readUnsignedExpGolombCodedInt(); + int picHeightInLumaSamples = bitArray.readUnsignedExpGolombCodedInt(); + if (bitArray.readBit()) { // conformance_window_flag + int confWinLeftOffset = bitArray.readUnsignedExpGolombCodedInt(); + int confWinRightOffset = bitArray.readUnsignedExpGolombCodedInt(); + int confWinTopOffset = bitArray.readUnsignedExpGolombCodedInt(); + int confWinBottomOffset = bitArray.readUnsignedExpGolombCodedInt(); + // H.265/HEVC (2014) Table 6-1 + int subWidthC = chromaFormatIdc == 1 || chromaFormatIdc == 2 ? 2 : 1; + int subHeightC = chromaFormatIdc == 1 ? 2 : 1; + picWidthInLumaSamples -= subWidthC * (confWinLeftOffset + confWinRightOffset); + picHeightInLumaSamples -= subHeightC * (confWinTopOffset + confWinBottomOffset); + } + bitArray.readUnsignedExpGolombCodedInt(); // bit_depth_luma_minus8 + bitArray.readUnsignedExpGolombCodedInt(); // bit_depth_chroma_minus8 + int log2MaxPicOrderCntLsbMinus4 = bitArray.readUnsignedExpGolombCodedInt(); + // for (i = sps_sub_layer_ordering_info_present_flag ? 0 : sps_max_sub_layers_minus1; ...) + for (int i = bitArray.readBit() ? 0 : maxSubLayersMinus1; i <= maxSubLayersMinus1; i++) { + bitArray.readUnsignedExpGolombCodedInt(); // sps_max_dec_pic_buffering_minus1[i] + bitArray.readUnsignedExpGolombCodedInt(); // sps_max_num_reorder_pics[i] + bitArray.readUnsignedExpGolombCodedInt(); // sps_max_latency_increase_plus1[i] + } + bitArray.readUnsignedExpGolombCodedInt(); // log2_min_luma_coding_block_size_minus3 + bitArray.readUnsignedExpGolombCodedInt(); // log2_diff_max_min_luma_coding_block_size + bitArray.readUnsignedExpGolombCodedInt(); // log2_min_luma_transform_block_size_minus2 + bitArray.readUnsignedExpGolombCodedInt(); // log2_diff_max_min_luma_transform_block_size + bitArray.readUnsignedExpGolombCodedInt(); // max_transform_hierarchy_depth_inter + bitArray.readUnsignedExpGolombCodedInt(); // max_transform_hierarchy_depth_intra + // if (scaling_list_enabled_flag) { if (sps_scaling_list_data_present_flag) {...}} + boolean scalingListEnabled = bitArray.readBit(); + if (scalingListEnabled && bitArray.readBit()) { + skipScalingList(bitArray); + } + bitArray.skipBits(2); // amp_enabled_flag (1), sample_adaptive_offset_enabled_flag (1) + if (bitArray.readBit()) { // pcm_enabled_flag + // pcm_sample_bit_depth_luma_minus1 (4), pcm_sample_bit_depth_chroma_minus1 (4) + bitArray.skipBits(8); + bitArray.readUnsignedExpGolombCodedInt(); // log2_min_pcm_luma_coding_block_size_minus3 + bitArray.readUnsignedExpGolombCodedInt(); // log2_diff_max_min_pcm_luma_coding_block_size + bitArray.skipBit(); // pcm_loop_filter_disabled_flag + } + // Skips all short term reference picture sets. + skipShortTermRefPicSets(bitArray); + if (bitArray.readBit()) { // long_term_ref_pics_present_flag + // num_long_term_ref_pics_sps + for (int i = 0; i < bitArray.readUnsignedExpGolombCodedInt(); i++) { + int ltRefPicPocLsbSpsLength = log2MaxPicOrderCntLsbMinus4 + 4; + // lt_ref_pic_poc_lsb_sps[i], used_by_curr_pic_lt_sps_flag[i] + bitArray.skipBits(ltRefPicPocLsbSpsLength + 1); + } + } + bitArray.skipBits(2); // sps_temporal_mvp_enabled_flag, strong_intra_smoothing_enabled_flag + float pixelWidthHeightRatio = 1; + if (bitArray.readBit()) { // vui_parameters_present_flag + if (bitArray.readBit()) { // aspect_ratio_info_present_flag + int aspectRatioIdc = bitArray.readBits(8); + if (aspectRatioIdc == NalUnitUtil.EXTENDED_SAR) { + int sarWidth = bitArray.readBits(16); + int sarHeight = bitArray.readBits(16); + if (sarWidth != 0 && sarHeight != 0) { + pixelWidthHeightRatio = (float) sarWidth / sarHeight; + } + } else if (aspectRatioIdc < NalUnitUtil.ASPECT_RATIO_IDC_VALUES.length) { + pixelWidthHeightRatio = NalUnitUtil.ASPECT_RATIO_IDC_VALUES[aspectRatioIdc]; + } else { + Log.w(TAG, "Unexpected aspect_ratio_idc value: " + aspectRatioIdc); + } + } + } + + return Format.createVideoSampleFormat(formatId, MimeTypes.VIDEO_H265, null, Format.NO_VALUE, + Format.NO_VALUE, picWidthInLumaSamples, picHeightInLumaSamples, Format.NO_VALUE, + Collections.singletonList(csd), Format.NO_VALUE, pixelWidthHeightRatio, null); + } + + /** + * Skips scaling_list_data(). See H.265/HEVC (2014) 7.3.4. + */ + private static void skipScalingList(ParsableNalUnitBitArray bitArray) { + for (int sizeId = 0; sizeId < 4; sizeId++) { + for (int matrixId = 0; matrixId < 6; matrixId += sizeId == 3 ? 3 : 1) { + if (!bitArray.readBit()) { // scaling_list_pred_mode_flag[sizeId][matrixId] + // scaling_list_pred_matrix_id_delta[sizeId][matrixId] + bitArray.readUnsignedExpGolombCodedInt(); + } else { + int coefNum = Math.min(64, 1 << (4 + (sizeId << 1))); + if (sizeId > 1) { + // scaling_list_dc_coef_minus8[sizeId - 2][matrixId] + bitArray.readSignedExpGolombCodedInt(); + } + for (int i = 0; i < coefNum; i++) { + bitArray.readSignedExpGolombCodedInt(); // scaling_list_delta_coef + } + } + } + } + } + + /** + * Reads the number of short term reference picture sets in a SPS as ue(v), then skips all of + * them. See H.265/HEVC (2014) 7.3.7. + */ + private static void skipShortTermRefPicSets(ParsableNalUnitBitArray bitArray) { + int numShortTermRefPicSets = bitArray.readUnsignedExpGolombCodedInt(); + boolean interRefPicSetPredictionFlag = false; + int numNegativePics; + int numPositivePics; + // As this method applies in a SPS, the only element of NumDeltaPocs accessed is the previous + // one, so we just keep track of that rather than storing the whole array. + // RefRpsIdx = stRpsIdx - (delta_idx_minus1 + 1) and delta_idx_minus1 is always zero in SPS. + int previousNumDeltaPocs = 0; + for (int stRpsIdx = 0; stRpsIdx < numShortTermRefPicSets; stRpsIdx++) { + if (stRpsIdx != 0) { + interRefPicSetPredictionFlag = bitArray.readBit(); + } + if (interRefPicSetPredictionFlag) { + bitArray.skipBit(); // delta_rps_sign + bitArray.readUnsignedExpGolombCodedInt(); // abs_delta_rps_minus1 + for (int j = 0; j <= previousNumDeltaPocs; j++) { + if (bitArray.readBit()) { // used_by_curr_pic_flag[j] + bitArray.skipBit(); // use_delta_flag[j] + } + } + } else { + numNegativePics = bitArray.readUnsignedExpGolombCodedInt(); + numPositivePics = bitArray.readUnsignedExpGolombCodedInt(); + previousNumDeltaPocs = numNegativePics + numPositivePics; + for (int i = 0; i < numNegativePics; i++) { + bitArray.readUnsignedExpGolombCodedInt(); // delta_poc_s0_minus1[i] + bitArray.skipBit(); // used_by_curr_pic_s0_flag[i] + } + for (int i = 0; i < numPositivePics; i++) { + bitArray.readUnsignedExpGolombCodedInt(); // delta_poc_s1_minus1[i] + bitArray.skipBit(); // used_by_curr_pic_s1_flag[i] + } + } + } + } + + private static final class SampleReader { + + /** + * Offset in bytes of the first_slice_segment_in_pic_flag in a NAL unit containing a + * slice_segment_layer_rbsp. + */ + private static final int FIRST_SLICE_FLAG_OFFSET = 2; + + private final TrackOutput output; + + // Per NAL unit state. A sample consists of one or more NAL units. + private long nalUnitStartPosition; + private boolean nalUnitHasKeyframeData; + private int nalUnitBytesRead; + private long nalUnitTimeUs; + private boolean lookingForFirstSliceFlag; + private boolean isFirstSlice; + private boolean isFirstParameterSet; + + // Per sample state that gets reset at the start of each sample. + private boolean readingSample; + private boolean writingParameterSets; + private long samplePosition; + private long sampleTimeUs; + private boolean sampleIsKeyframe; + + public SampleReader(TrackOutput output) { + this.output = output; + } + + public void reset() { + lookingForFirstSliceFlag = false; + isFirstSlice = false; + isFirstParameterSet = false; + readingSample = false; + writingParameterSets = false; + } + + public void startNalUnit(long position, int offset, int nalUnitType, long pesTimeUs) { + isFirstSlice = false; + isFirstParameterSet = false; + nalUnitTimeUs = pesTimeUs; + nalUnitBytesRead = 0; + nalUnitStartPosition = position; + + if (nalUnitType >= VPS_NUT) { + if (!writingParameterSets && readingSample) { + // This is a non-VCL NAL unit, so flush the previous sample. + outputSample(offset); + readingSample = false; + } + if (nalUnitType <= PPS_NUT) { + // This sample will have parameter sets at the start. + isFirstParameterSet = !writingParameterSets; + writingParameterSets = true; + } + } + + // Look for the flag if this NAL unit contains a slice_segment_layer_rbsp. + nalUnitHasKeyframeData = (nalUnitType >= BLA_W_LP && nalUnitType <= CRA_NUT); + lookingForFirstSliceFlag = nalUnitHasKeyframeData || nalUnitType <= RASL_R; + } + + public void readNalUnitData(byte[] data, int offset, int limit) { + if (lookingForFirstSliceFlag) { + int headerOffset = offset + FIRST_SLICE_FLAG_OFFSET - nalUnitBytesRead; + if (headerOffset < limit) { + isFirstSlice = (data[headerOffset] & 0x80) != 0; + lookingForFirstSliceFlag = false; + } else { + nalUnitBytesRead += limit - offset; + } + } + } + + public void endNalUnit(long position, int offset) { + if (writingParameterSets && isFirstSlice) { + // This sample has parameter sets. Reset the key-frame flag based on the first slice. + sampleIsKeyframe = nalUnitHasKeyframeData; + writingParameterSets = false; + } else if (isFirstParameterSet || isFirstSlice) { + // This NAL unit is at the start of a new sample (access unit). + if (readingSample) { + // Output the sample ending before this NAL unit. + int nalUnitLength = (int) (position - nalUnitStartPosition); + outputSample(offset + nalUnitLength); + } + samplePosition = nalUnitStartPosition; + sampleTimeUs = nalUnitTimeUs; + readingSample = true; + sampleIsKeyframe = nalUnitHasKeyframeData; + } + } + + private void outputSample(int offset) { + @C.BufferFlags int flags = sampleIsKeyframe ? C.BUFFER_FLAG_KEY_FRAME : 0; + int size = (int) (nalUnitStartPosition - samplePosition); + output.sampleMetadata(sampleTimeUs, flags, size, offset, null); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Id3Reader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Id3Reader.java new file mode 100644 index 0000000000..da63e143c2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/Id3Reader.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder.ID3_HEADER_LENGTH; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Parses ID3 data and extracts individual text information frames. + */ +public final class Id3Reader implements ElementaryStreamReader { + + private static final String TAG = "Id3Reader"; + + private final ParsableByteArray id3Header; + + private TrackOutput output; + + // State that should be reset on seek. + private boolean writingSample; + + // Per sample state that gets reset at the start of each sample. + private long sampleTimeUs; + private int sampleSize; + private int sampleBytesRead; + + public Id3Reader() { + id3Header = new ParsableByteArray(ID3_HEADER_LENGTH); + } + + @Override + public void seek() { + writingSample = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_METADATA); + output.format(Format.createSampleFormat(idGenerator.getFormatId(), MimeTypes.APPLICATION_ID3, + null, Format.NO_VALUE, null)); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + if ((flags & FLAG_DATA_ALIGNMENT_INDICATOR) == 0) { + return; + } + writingSample = true; + sampleTimeUs = pesTimeUs; + sampleSize = 0; + sampleBytesRead = 0; + } + + @Override + public void consume(ParsableByteArray data) { + if (!writingSample) { + return; + } + int bytesAvailable = data.bytesLeft(); + if (sampleBytesRead < ID3_HEADER_LENGTH) { + // We're still reading the ID3 header. + int headerBytesAvailable = Math.min(bytesAvailable, ID3_HEADER_LENGTH - sampleBytesRead); + System.arraycopy(data.data, data.getPosition(), id3Header.data, sampleBytesRead, + headerBytesAvailable); + if (sampleBytesRead + headerBytesAvailable == ID3_HEADER_LENGTH) { + // We've finished reading the ID3 header. Extract the sample size. + id3Header.setPosition(0); + if ('I' != id3Header.readUnsignedByte() || 'D' != id3Header.readUnsignedByte() + || '3' != id3Header.readUnsignedByte()) { + Log.w(TAG, "Discarding invalid ID3 tag"); + writingSample = false; + return; + } + id3Header.skipBytes(3); // version (2) + flags (1) + sampleSize = ID3_HEADER_LENGTH + id3Header.readSynchSafeInt(); + } + } + // Write data to the output. + int bytesToWrite = Math.min(bytesAvailable, sampleSize - sampleBytesRead); + output.sampleData(data, bytesToWrite); + sampleBytesRead += bytesToWrite; + } + + @Override + public void packetFinished() { + if (!writingSample || sampleSize == 0 || sampleBytesRead != sampleSize) { + return; + } + output.sampleMetadata(sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + writingSample = false; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/LatmReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/LatmReader.java new file mode 100644 index 0000000000..1a41adfa69 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/LatmReader.java @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.CodecSpecificDataUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Collections; + +/** + * Parses and extracts samples from an AAC/LATM elementary stream. + */ +public final class LatmReader implements ElementaryStreamReader { + + private static final int STATE_FINDING_SYNC_1 = 0; + private static final int STATE_FINDING_SYNC_2 = 1; + private static final int STATE_READING_HEADER = 2; + private static final int STATE_READING_SAMPLE = 3; + + private static final int INITIAL_BUFFER_SIZE = 1024; + private static final int SYNC_BYTE_FIRST = 0x56; + private static final int SYNC_BYTE_SECOND = 0xE0; + + private final String language; + private final ParsableByteArray sampleDataBuffer; + private final ParsableBitArray sampleBitArray; + + // Track output info. + private TrackOutput output; + private Format format; + private String formatId; + + // Parser state info. + private int state; + private int bytesRead; + private int sampleSize; + private int secondHeaderByte; + private long timeUs; + + // Container data. + private boolean streamMuxRead; + private int audioMuxVersionA; + private int numSubframes; + private int frameLengthType; + private boolean otherDataPresent; + private long otherDataLenBits; + private int sampleRateHz; + private long sampleDurationUs; + private int channelCount; + + /** + * @param language Track language. + */ + public LatmReader(@Nullable String language) { + this.language = language; + sampleDataBuffer = new ParsableByteArray(INITIAL_BUFFER_SIZE); + sampleBitArray = new ParsableBitArray(sampleDataBuffer.data); + } + + @Override + public void seek() { + state = STATE_FINDING_SYNC_1; + streamMuxRead = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_AUDIO); + formatId = idGenerator.getFormatId(); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + timeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) throws ParserException { + int bytesToRead; + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_SYNC_1: + if (data.readUnsignedByte() == SYNC_BYTE_FIRST) { + state = STATE_FINDING_SYNC_2; + } + break; + case STATE_FINDING_SYNC_2: + int secondByte = data.readUnsignedByte(); + if ((secondByte & SYNC_BYTE_SECOND) == SYNC_BYTE_SECOND) { + secondHeaderByte = secondByte; + state = STATE_READING_HEADER; + } else if (secondByte != SYNC_BYTE_FIRST) { + state = STATE_FINDING_SYNC_1; + } + break; + case STATE_READING_HEADER: + sampleSize = ((secondHeaderByte & ~SYNC_BYTE_SECOND) << 8) | data.readUnsignedByte(); + if (sampleSize > sampleDataBuffer.data.length) { + resetBufferForSize(sampleSize); + } + bytesRead = 0; + state = STATE_READING_SAMPLE; + break; + case STATE_READING_SAMPLE: + bytesToRead = Math.min(data.bytesLeft(), sampleSize - bytesRead); + data.readBytes(sampleBitArray.data, bytesRead, bytesToRead); + bytesRead += bytesToRead; + if (bytesRead == sampleSize) { + sampleBitArray.setPosition(0); + parseAudioMuxElement(sampleBitArray); + state = STATE_FINDING_SYNC_1; + } + break; + default: + throw new IllegalStateException(); + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Parses an AudioMuxElement as defined in 14496-3:2009, Section 1.7.3.1, Table 1.41. + * + * @param data A {@link ParsableBitArray} containing the AudioMuxElement's bytes. + */ + private void parseAudioMuxElement(ParsableBitArray data) throws ParserException { + boolean useSameStreamMux = data.readBit(); + if (!useSameStreamMux) { + streamMuxRead = true; + parseStreamMuxConfig(data); + } else if (!streamMuxRead) { + return; // Parsing cannot continue without StreamMuxConfig information. + } + + if (audioMuxVersionA == 0) { + if (numSubframes != 0) { + throw new ParserException(); + } + int muxSlotLengthBytes = parsePayloadLengthInfo(data); + parsePayloadMux(data, muxSlotLengthBytes); + if (otherDataPresent) { + data.skipBits((int) otherDataLenBits); + } + } else { + throw new ParserException(); // Not defined by ISO/IEC 14496-3:2009. + } + } + + /** + * Parses a StreamMuxConfig as defined in ISO/IEC 14496-3:2009 Section 1.7.3.1, Table 1.42. + */ + private void parseStreamMuxConfig(ParsableBitArray data) throws ParserException { + int audioMuxVersion = data.readBits(1); + audioMuxVersionA = audioMuxVersion == 1 ? data.readBits(1) : 0; + if (audioMuxVersionA == 0) { + if (audioMuxVersion == 1) { + latmGetValue(data); // Skip taraBufferFullness. + } + if (!data.readBit()) { + throw new ParserException(); + } + numSubframes = data.readBits(6); + int numProgram = data.readBits(4); + int numLayer = data.readBits(3); + if (numProgram != 0 || numLayer != 0) { + throw new ParserException(); + } + if (audioMuxVersion == 0) { + int startPosition = data.getPosition(); + int readBits = parseAudioSpecificConfig(data); + data.setPosition(startPosition); + byte[] initData = new byte[(readBits + 7) / 8]; + data.readBits(initData, 0, readBits); + Format format = Format.createAudioSampleFormat(formatId, MimeTypes.AUDIO_AAC, null, + Format.NO_VALUE, Format.NO_VALUE, channelCount, sampleRateHz, + Collections.singletonList(initData), null, 0, language); + if (!format.equals(this.format)) { + this.format = format; + sampleDurationUs = (C.MICROS_PER_SECOND * 1024) / format.sampleRate; + output.format(format); + } + } else { + int ascLen = (int) latmGetValue(data); + int bitsRead = parseAudioSpecificConfig(data); + data.skipBits(ascLen - bitsRead); // fillBits. + } + parseFrameLength(data); + otherDataPresent = data.readBit(); + otherDataLenBits = 0; + if (otherDataPresent) { + if (audioMuxVersion == 1) { + otherDataLenBits = latmGetValue(data); + } else { + boolean otherDataLenEsc; + do { + otherDataLenEsc = data.readBit(); + otherDataLenBits = (otherDataLenBits << 8) + data.readBits(8); + } while (otherDataLenEsc); + } + } + boolean crcCheckPresent = data.readBit(); + if (crcCheckPresent) { + data.skipBits(8); // crcCheckSum. + } + } else { + throw new ParserException(); // This is not defined by ISO/IEC 14496-3:2009. + } + } + + private void parseFrameLength(ParsableBitArray data) { + frameLengthType = data.readBits(3); + switch (frameLengthType) { + case 0: + data.skipBits(8); // latmBufferFullness. + break; + case 1: + data.skipBits(9); // frameLength. + break; + case 3: + case 4: + case 5: + data.skipBits(6); // CELPframeLengthTableIndex. + break; + case 6: + case 7: + data.skipBits(1); // HVXCframeLengthTableIndex. + break; + default: + throw new IllegalStateException(); + } + } + + private int parseAudioSpecificConfig(ParsableBitArray data) throws ParserException { + int bitsLeft = data.bitsLeft(); + Pair<Integer, Integer> config = CodecSpecificDataUtil.parseAacAudioSpecificConfig(data, true); + sampleRateHz = config.first; + channelCount = config.second; + return bitsLeft - data.bitsLeft(); + } + + private int parsePayloadLengthInfo(ParsableBitArray data) throws ParserException { + int muxSlotLengthBytes = 0; + // Assuming single program and single layer. + if (frameLengthType == 0) { + int tmp; + do { + tmp = data.readBits(8); + muxSlotLengthBytes += tmp; + } while (tmp == 255); + return muxSlotLengthBytes; + } else { + throw new ParserException(); + } + } + + private void parsePayloadMux(ParsableBitArray data, int muxLengthBytes) { + // The start of sample data in + int bitPosition = data.getPosition(); + if ((bitPosition & 0x07) == 0) { + // Sample data is byte-aligned. We can output it directly. + sampleDataBuffer.setPosition(bitPosition >> 3); + } else { + // Sample data is not byte-aligned and we need align it ourselves before outputting. + // Byte alignment is needed because LATM framing is not supported by MediaCodec. + data.readBits(sampleDataBuffer.data, 0, muxLengthBytes * 8); + sampleDataBuffer.setPosition(0); + } + output.sampleData(sampleDataBuffer, muxLengthBytes); + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, muxLengthBytes, 0, null); + timeUs += sampleDurationUs; + } + + private void resetBufferForSize(int newSize) { + sampleDataBuffer.reset(newSize); + sampleBitArray.reset(sampleDataBuffer.data); + } + + private static long latmGetValue(ParsableBitArray data) { + int bytesForValue = data.readBits(2); + return data.readBits((bytesForValue + 1) * 8); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java new file mode 100644 index 0000000000..6fefab6314 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/MpegAudioReader.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.MpegAudioHeader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** + * Parses a continuous MPEG Audio byte stream and extracts individual frames. + */ +public final class MpegAudioReader implements ElementaryStreamReader { + + private static final int STATE_FINDING_HEADER = 0; + private static final int STATE_READING_HEADER = 1; + private static final int STATE_READING_FRAME = 2; + + private static final int HEADER_SIZE = 4; + + private final ParsableByteArray headerScratch; + private final MpegAudioHeader header; + private final String language; + + private String formatId; + private TrackOutput output; + + private int state; + private int frameBytesRead; + private boolean hasOutputFormat; + + // Used when finding the frame header. + private boolean lastByteWasFF; + + // Parsed from the frame header. + private long frameDurationUs; + private int frameSize; + + // The timestamp to attach to the next sample in the current packet. + private long timeUs; + + public MpegAudioReader() { + this(null); + } + + public MpegAudioReader(String language) { + state = STATE_FINDING_HEADER; + // The first byte of an MPEG Audio frame header is always 0xFF. + headerScratch = new ParsableByteArray(4); + headerScratch.data[0] = (byte) 0xFF; + header = new MpegAudioHeader(); + this.language = language; + } + + @Override + public void seek() { + state = STATE_FINDING_HEADER; + frameBytesRead = 0; + lastByteWasFF = false; + } + + @Override + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + idGenerator.generateNewId(); + formatId = idGenerator.getFormatId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_AUDIO); + } + + @Override + public void packetStarted(long pesTimeUs, @TsPayloadReader.Flags int flags) { + timeUs = pesTimeUs; + } + + @Override + public void consume(ParsableByteArray data) { + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_HEADER: + findHeader(data); + break; + case STATE_READING_HEADER: + readHeaderRemainder(data); + break; + case STATE_READING_FRAME: + readFrameRemainder(data); + break; + default: + throw new IllegalStateException(); + } + } + } + + @Override + public void packetFinished() { + // Do nothing. + } + + /** + * Attempts to locate the start of the next frame header. + * <p> + * If a frame header is located then the state is changed to {@link #STATE_READING_HEADER}, the + * first two bytes of the header are written into {@link #headerScratch}, and the position of the + * source is advanced to the byte that immediately follows these two bytes. + * <p> + * If a frame header is not located then the position of the source is advanced to the limit, and + * the method should be called again with the next source to continue the search. + * + * @param source The source from which to read. + */ + private void findHeader(ParsableByteArray source) { + byte[] data = source.data; + int startOffset = source.getPosition(); + int endOffset = source.limit(); + for (int i = startOffset; i < endOffset; i++) { + boolean byteIsFF = (data[i] & 0xFF) == 0xFF; + boolean found = lastByteWasFF && (data[i] & 0xE0) == 0xE0; + lastByteWasFF = byteIsFF; + if (found) { + source.setPosition(i + 1); + // Reset lastByteWasFF for next time. + lastByteWasFF = false; + headerScratch.data[1] = data[i]; + frameBytesRead = 2; + state = STATE_READING_HEADER; + return; + } + } + source.setPosition(endOffset); + } + + /** + * Attempts to read the remaining two bytes of the frame header. + * <p> + * If a frame header is read in full then the state is changed to {@link #STATE_READING_FRAME}, + * the media format is output if this has not previously occurred, the four header bytes are + * output as sample data, and the position of the source is advanced to the byte that immediately + * follows the header. + * <p> + * If a frame header is read in full but cannot be parsed then the state is changed to + * {@link #STATE_READING_HEADER}. + * <p> + * If a frame header is not read in full then the position of the source is advanced to the limit, + * and the method should be called again with the next source to continue the read. + * + * @param source The source from which to read. + */ + private void readHeaderRemainder(ParsableByteArray source) { + int bytesToRead = Math.min(source.bytesLeft(), HEADER_SIZE - frameBytesRead); + source.readBytes(headerScratch.data, frameBytesRead, bytesToRead); + frameBytesRead += bytesToRead; + if (frameBytesRead < HEADER_SIZE) { + // We haven't read the whole header yet. + return; + } + + headerScratch.setPosition(0); + boolean parsedHeader = MpegAudioHeader.populateHeader(headerScratch.readInt(), header); + if (!parsedHeader) { + // We thought we'd located a frame header, but we hadn't. + frameBytesRead = 0; + state = STATE_READING_HEADER; + return; + } + + frameSize = header.frameSize; + if (!hasOutputFormat) { + frameDurationUs = (C.MICROS_PER_SECOND * header.samplesPerFrame) / header.sampleRate; + Format format = Format.createAudioSampleFormat(formatId, header.mimeType, null, + Format.NO_VALUE, MpegAudioHeader.MAX_FRAME_SIZE_BYTES, header.channels, header.sampleRate, + null, null, 0, language); + output.format(format); + hasOutputFormat = true; + } + + headerScratch.setPosition(0); + output.sampleData(headerScratch, HEADER_SIZE); + state = STATE_READING_FRAME; + } + + /** + * Attempts to read the remainder of the frame. + * <p> + * If a frame is read in full then true is returned. The frame will have been output, and the + * position of the source will have been advanced to the byte that immediately follows the end of + * the frame. + * <p> + * If a frame is not read in full then the position of the source will have been advanced to the + * limit, and the method should be called again with the next source to continue the read. + * + * @param source The source from which to read. + */ + private void readFrameRemainder(ParsableByteArray source) { + int bytesToRead = Math.min(source.bytesLeft(), frameSize - frameBytesRead); + output.sampleData(source, bytesToRead); + frameBytesRead += bytesToRead; + if (frameBytesRead < frameSize) { + // We haven't read the whole of the frame yet. + return; + } + + output.sampleMetadata(timeUs, C.BUFFER_FLAG_KEY_FRAME, frameSize, 0, null); + timeUs += frameDurationUs; + frameBytesRead = 0; + state = STATE_FINDING_HEADER; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/NalUnitTargetBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/NalUnitTargetBuffer.java new file mode 100644 index 0000000000..4941aa29a0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/NalUnitTargetBuffer.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Arrays; + +/** + * A buffer that fills itself with data corresponding to a specific NAL unit, as it is + * encountered in the stream. + */ +/* package */ final class NalUnitTargetBuffer { + + private final int targetType; + + private boolean isFilling; + private boolean isCompleted; + + public byte[] nalData; + public int nalLength; + + public NalUnitTargetBuffer(int targetType, int initialCapacity) { + this.targetType = targetType; + + // Initialize data with a start code in the first three bytes. + nalData = new byte[3 + initialCapacity]; + nalData[2] = 1; + } + + /** + * Resets the buffer, clearing any data that it holds. + */ + public void reset() { + isFilling = false; + isCompleted = false; + } + + /** + * Returns whether the buffer currently holds a complete NAL unit of the target type. + */ + public boolean isCompleted() { + return isCompleted; + } + + /** + * Called to indicate that a NAL unit has started. + * + * @param type The type of the NAL unit. + */ + public void startNalUnit(int type) { + Assertions.checkState(!isFilling); + isFilling = type == targetType; + if (isFilling) { + // Skip the three byte start code when writing data. + nalLength = 3; + isCompleted = false; + } + } + + /** + * Called to pass stream data. The data passed should not include the 3 byte start code. + * + * @param data Holds the data being passed. + * @param offset The offset of the data in {@code data}. + * @param limit The limit (exclusive) of the data in {@code data}. + */ + public void appendToNalUnit(byte[] data, int offset, int limit) { + if (!isFilling) { + return; + } + int readLength = limit - offset; + if (nalData.length < nalLength + readLength) { + nalData = Arrays.copyOf(nalData, (nalLength + readLength) * 2); + } + System.arraycopy(data, offset, nalData, nalLength, readLength); + nalLength += readLength; + } + + /** + * Called to indicate that a NAL unit has ended. + * + * @param discardPadding The number of excess bytes that were passed to + * {@link #appendToNalUnit(byte[], int, int)}, which should be discarded. + * @return Whether the ended NAL unit is of the target type. + */ + public boolean endNalUnit(int discardPadding) { + if (!isFilling) { + return false; + } + nalLength -= discardPadding; + isFilling = false; + isCompleted = true; + return true; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PesReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PesReader.java new file mode 100644 index 0000000000..86afe22563 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PesReader.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; + +/** + * Parses PES packet data and extracts samples. + */ +public final class PesReader implements TsPayloadReader { + + private static final String TAG = "PesReader"; + + private static final int STATE_FINDING_HEADER = 0; + private static final int STATE_READING_HEADER = 1; + private static final int STATE_READING_HEADER_EXTENSION = 2; + private static final int STATE_READING_BODY = 3; + + private static final int HEADER_SIZE = 9; + private static final int MAX_HEADER_EXTENSION_SIZE = 10; + private static final int PES_SCRATCH_SIZE = 10; // max(HEADER_SIZE, MAX_HEADER_EXTENSION_SIZE) + + private final ElementaryStreamReader reader; + private final ParsableBitArray pesScratch; + + private int state; + private int bytesRead; + + private TimestampAdjuster timestampAdjuster; + private boolean ptsFlag; + private boolean dtsFlag; + private boolean seenFirstDts; + private int extendedHeaderLength; + private int payloadSize; + private boolean dataAlignmentIndicator; + private long timeUs; + + public PesReader(ElementaryStreamReader reader) { + this.reader = reader; + pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]); + state = STATE_FINDING_HEADER; + } + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + this.timestampAdjuster = timestampAdjuster; + reader.createTracks(extractorOutput, idGenerator); + } + + // TsPayloadReader implementation. + + @Override + public final void seek() { + state = STATE_FINDING_HEADER; + bytesRead = 0; + seenFirstDts = false; + reader.seek(); + } + + @Override + public final void consume(ParsableByteArray data, @Flags int flags) throws ParserException { + if ((flags & FLAG_PAYLOAD_UNIT_START_INDICATOR) != 0) { + switch (state) { + case STATE_FINDING_HEADER: + case STATE_READING_HEADER: + // Expected. + break; + case STATE_READING_HEADER_EXTENSION: + Log.w(TAG, "Unexpected start indicator reading extended header"); + break; + case STATE_READING_BODY: + // If payloadSize == -1 then the length of the previous packet was unspecified, and so + // we only know that it's finished now that we've seen the start of the next one. This + // is expected. If payloadSize != -1, then the length of the previous packet was known, + // but we didn't receive that amount of data. This is not expected. + if (payloadSize != -1) { + Log.w(TAG, "Unexpected start indicator: expected " + payloadSize + " more bytes"); + } + // Either way, notify the reader that it has now finished. + reader.packetFinished(); + break; + default: + throw new IllegalStateException(); + } + setState(STATE_READING_HEADER); + } + + while (data.bytesLeft() > 0) { + switch (state) { + case STATE_FINDING_HEADER: + data.skipBytes(data.bytesLeft()); + break; + case STATE_READING_HEADER: + if (continueRead(data, pesScratch.data, HEADER_SIZE)) { + setState(parseHeader() ? STATE_READING_HEADER_EXTENSION : STATE_FINDING_HEADER); + } + break; + case STATE_READING_HEADER_EXTENSION: + int readLength = Math.min(MAX_HEADER_EXTENSION_SIZE, extendedHeaderLength); + // Read as much of the extended header as we're interested in, and skip the rest. + if (continueRead(data, pesScratch.data, readLength) + && continueRead(data, null, extendedHeaderLength)) { + parseHeaderExtension(); + flags |= dataAlignmentIndicator ? FLAG_DATA_ALIGNMENT_INDICATOR : 0; + reader.packetStarted(timeUs, flags); + setState(STATE_READING_BODY); + } + break; + case STATE_READING_BODY: + readLength = data.bytesLeft(); + int padding = payloadSize == -1 ? 0 : readLength - payloadSize; + if (padding > 0) { + readLength -= padding; + data.setLimit(data.getPosition() + readLength); + } + reader.consume(data); + if (payloadSize != -1) { + payloadSize -= readLength; + if (payloadSize == 0) { + reader.packetFinished(); + setState(STATE_READING_HEADER); + } + } + break; + default: + throw new IllegalStateException(); + } + } + } + + private void setState(int state) { + this.state = state; + bytesRead = 0; + } + + /** + * Continues a read from the provided {@code source} into a given {@code target}. It's assumed + * that the data should be written into {@code target} starting from an offset of zero. + * + * @param source The source from which to read. + * @param target The target into which data is to be read, or {@code null} to skip. + * @param targetLength The target length of the read. + * @return Whether the target length has been reached. + */ + private boolean continueRead(ParsableByteArray source, byte[] target, int targetLength) { + int bytesToRead = Math.min(source.bytesLeft(), targetLength - bytesRead); + if (bytesToRead <= 0) { + return true; + } else if (target == null) { + source.skipBytes(bytesToRead); + } else { + source.readBytes(target, bytesRead, bytesToRead); + } + bytesRead += bytesToRead; + return bytesRead == targetLength; + } + + private boolean parseHeader() { + // Note: see ISO/IEC 13818-1, section 2.4.3.6 for detailed information on the format of + // the header. + pesScratch.setPosition(0); + int startCodePrefix = pesScratch.readBits(24); + if (startCodePrefix != 0x000001) { + Log.w(TAG, "Unexpected start code prefix: " + startCodePrefix); + payloadSize = -1; + return false; + } + + pesScratch.skipBits(8); // stream_id. + int packetLength = pesScratch.readBits(16); + pesScratch.skipBits(5); // '10' (2), PES_scrambling_control (2), PES_priority (1) + dataAlignmentIndicator = pesScratch.readBit(); + pesScratch.skipBits(2); // copyright (1), original_or_copy (1) + ptsFlag = pesScratch.readBit(); + dtsFlag = pesScratch.readBit(); + // ESCR_flag (1), ES_rate_flag (1), DSM_trick_mode_flag (1), + // additional_copy_info_flag (1), PES_CRC_flag (1), PES_extension_flag (1) + pesScratch.skipBits(6); + extendedHeaderLength = pesScratch.readBits(8); + + if (packetLength == 0) { + payloadSize = -1; + } else { + payloadSize = packetLength + 6 /* packetLength does not include the first 6 bytes */ + - HEADER_SIZE - extendedHeaderLength; + } + return true; + } + + private void parseHeaderExtension() { + pesScratch.setPosition(0); + timeUs = C.TIME_UNSET; + if (ptsFlag) { + pesScratch.skipBits(4); // '0010' or '0011' + long pts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + if (!seenFirstDts && dtsFlag) { + pesScratch.skipBits(4); // '0011' + long dts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + // Subsequent PES packets may have earlier presentation timestamps than this one, but they + // should all be greater than or equal to this packet's decode timestamp. We feed the + // decode timestamp to the adjuster here so that in the case that this is the first to be + // fed, the adjuster will be able to compute an offset to apply such that the adjusted + // presentation timestamps of all future packets are non-negative. + timestampAdjuster.adjustTsTimestamp(dts); + seenFirstDts = true; + } + timeUs = timestampAdjuster.adjustTsTimestamp(pts); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java new file mode 100644 index 0000000000..acd08a2f12 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsBinarySearchSeeker.java @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.BinarySearchSeeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A seeker that supports seeking within PS stream using binary search. + * + * <p>This seeker uses the first and last SCR values within the stream, as well as the stream + * duration to interpolate the SCR value of the seeking position. Then it performs binary search + * within the stream to find a packets whose SCR value is with in {@link #SEEK_TOLERANCE_US} from + * the target SCR. + */ +/* package */ final class PsBinarySearchSeeker extends BinarySearchSeeker { + + private static final long SEEK_TOLERANCE_US = 100_000; + private static final int MINIMUM_SEARCH_RANGE_BYTES = 1000; + private static final int TIMESTAMP_SEARCH_BYTES = 20000; + + public PsBinarySearchSeeker( + TimestampAdjuster scrTimestampAdjuster, long streamDurationUs, long inputLength) { + super( + new DefaultSeekTimestampConverter(), + new PsScrSeeker(scrTimestampAdjuster), + streamDurationUs, + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamDurationUs + 1, + /* floorBytePosition= */ 0, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE, + MINIMUM_SEARCH_RANGE_BYTES); + } + + /** + * A seeker that looks for a given SCR timestamp at a given position in a PS stream. + * + * <p>Given a SCR timestamp, and a position within a PS stream, this seeker will peek up to {@link + * #TIMESTAMP_SEARCH_BYTES} bytes from that stream position, look for all packs in that range, and + * then compare the SCR timestamps (if available) of these packets to the target timestamp. + */ + private static final class PsScrSeeker implements TimestampSeeker { + + private final TimestampAdjuster scrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private PsScrSeeker(TimestampAdjuster scrTimestampAdjuster) { + this.scrTimestampAdjuster = scrTimestampAdjuster; + packetBuffer = new ParsableByteArray(); + } + + @Override + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) + throws IOException, InterruptedException { + long inputPosition = input.getPosition(); + int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); + + packetBuffer.reset(bytesToSearch); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + + return searchForScrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); + } + + @Override + public void onSeekFinished() { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + } + + private TimestampSearchResult searchForScrValueInBuffer( + ParsableByteArray packetBuffer, long targetScrTimeUs, long bufferStartOffset) { + int startOfLastPacketPosition = C.POSITION_UNSET; + int endOfLastPacketPosition = C.POSITION_UNSET; + long lastScrTimeUsInRange = C.TIME_UNSET; + + while (packetBuffer.bytesLeft() >= 4) { + int nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + if (nextStartCode != PsExtractor.PACK_START_CODE) { + packetBuffer.skipBytes(1); + continue; + } else { + packetBuffer.skipBytes(4); + } + + // We found a pack. + long scrValue = PsDurationReader.readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + long scrTimeUs = scrTimestampAdjuster.adjustTsTimestamp(scrValue); + if (scrTimeUs > targetScrTimeUs) { + if (lastScrTimeUsInRange == C.TIME_UNSET) { + // First SCR timestamp is already over target. + return TimestampSearchResult.overestimatedResult(scrTimeUs, bufferStartOffset); + } else { + // Last SCR timestamp < target timestamp < this timestamp. + return TimestampSearchResult.targetFoundResult( + bufferStartOffset + startOfLastPacketPosition); + } + } else if (scrTimeUs + SEEK_TOLERANCE_US > targetScrTimeUs) { + long startOfPacketInStream = bufferStartOffset + packetBuffer.getPosition(); + return TimestampSearchResult.targetFoundResult(startOfPacketInStream); + } + + lastScrTimeUsInRange = scrTimeUs; + startOfLastPacketPosition = packetBuffer.getPosition(); + } + skipToEndOfCurrentPack(packetBuffer); + endOfLastPacketPosition = packetBuffer.getPosition(); + } + + if (lastScrTimeUsInRange != C.TIME_UNSET) { + long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition; + return TimestampSearchResult.underestimatedResult( + lastScrTimeUsInRange, endOfLastPacketPositionInStream); + } else { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + } + + /** + * Skips the buffer position to the position after the end of the current PS pack in the buffer, + * given the byte position right after the {@link PsExtractor#PACK_START_CODE} of the pack in + * the buffer. If the pack ends after the end of the buffer, skips to the end of the buffer. + */ + private static void skipToEndOfCurrentPack(ParsableByteArray packetBuffer) { + int limit = packetBuffer.limit(); + + if (packetBuffer.bytesLeft() < 10) { + // We require at least 9 bytes for pack header to read SCR value + 1 byte for pack_stuffing + // length. + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(9); + + int packStuffingLength = packetBuffer.readUnsignedByte() & 0x07; + if (packetBuffer.bytesLeft() < packStuffingLength) { + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(packStuffingLength); + + if (packetBuffer.bytesLeft() < 4) { + packetBuffer.setPosition(limit); + return; + } + + int nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + if (nextStartCode == PsExtractor.SYSTEM_HEADER_START_CODE) { + packetBuffer.skipBytes(4); + int systemHeaderLength = packetBuffer.readUnsignedShort(); + if (packetBuffer.bytesLeft() < systemHeaderLength) { + packetBuffer.setPosition(limit); + return; + } + packetBuffer.skipBytes(systemHeaderLength); + } + + // Find the position of the next PACK_START_CODE or MPEG_PROGRAM_END_CODE, which is right + // after the end position of this pack. + // If we couldn't find these codes within the buffer, return the buffer limit, or return + // the first position which PES packets pattern does not match (some malformed packets). + while (packetBuffer.bytesLeft() >= 4) { + nextStartCode = peekIntAtPosition(packetBuffer.data, packetBuffer.getPosition()); + if (nextStartCode == PsExtractor.PACK_START_CODE + || nextStartCode == PsExtractor.MPEG_PROGRAM_END_CODE) { + break; + } + if (nextStartCode >>> 8 != PsExtractor.PACKET_START_CODE_PREFIX) { + break; + } + packetBuffer.skipBytes(4); + + if (packetBuffer.bytesLeft() < 2) { + // 2 bytes for PES_packet length. + packetBuffer.setPosition(limit); + return; + } + int pesPacketLength = packetBuffer.readUnsignedShort(); + packetBuffer.setPosition( + Math.min(packetBuffer.limit(), packetBuffer.getPosition() + pesPacketLength)); + } + } + } + + private static int peekIntAtPosition(byte[] data, int position) { + return (data[position] & 0xFF) << 24 + | (data[position + 1] & 0xFF) << 16 + | (data[position + 2] & 0xFF) << 8 + | (data[position + 3] & 0xFF); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java new file mode 100644 index 0000000000..a5960fbe15 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsDurationReader.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A reader that can extract the approximate duration from a given MPEG program stream (PS). + * + * <p>This reader extracts the duration by reading system clock reference (SCR) values from the + * header of a pack at the start and at the end of the stream, calculating the difference, and + * converting that into stream duration. This reader also handles the case when a single SCR + * wraparound takes place within the stream, which can make SCR values at the beginning of the + * stream larger than SCR values at the end. This class can only be used once to read duration from + * a given stream, and the usage of the class is not thread-safe, so all calls should be made from + * the same thread. + * + * <p>Note: See ISO/IEC 13818-1, Table 2-33 for details of the SCR field in pack_header. + */ +/* package */ final class PsDurationReader { + + private static final int TIMESTAMP_SEARCH_BYTES = 20000; + + private final TimestampAdjuster scrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private boolean isDurationRead; + private boolean isFirstScrValueRead; + private boolean isLastScrValueRead; + + private long firstScrValue; + private long lastScrValue; + private long durationUs; + + /* package */ PsDurationReader() { + scrTimestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + firstScrValue = C.TIME_UNSET; + lastScrValue = C.TIME_UNSET; + durationUs = C.TIME_UNSET; + packetBuffer = new ParsableByteArray(); + } + + /** Returns true if a PS duration has been read. */ + public boolean isDurationReadFinished() { + return isDurationRead; + } + + public TimestampAdjuster getScrTimestampAdjuster() { + return scrTimestampAdjuster; + } + + /** + * Reads a PS duration from the input. + * + * <p>This reader reads the duration by reading SCR values from the header of a pack at the start + * and at the end of the stream, calculating the difference, and converting that into stream + * duration. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated + * to hold the position of the required seek. + * @return One of the {@code RESULT_} values defined in {@link Extractor}. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + public @Extractor.ReadResult int readDuration( + ExtractorInput input, PositionHolder seekPositionHolder) + throws IOException, InterruptedException { + if (!isLastScrValueRead) { + return readLastScrValue(input, seekPositionHolder); + } + if (lastScrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + if (!isFirstScrValueRead) { + return readFirstScrValue(input, seekPositionHolder); + } + if (firstScrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + + long minScrPositionUs = scrTimestampAdjuster.adjustTsTimestamp(firstScrValue); + long maxScrPositionUs = scrTimestampAdjuster.adjustTsTimestamp(lastScrValue); + durationUs = maxScrPositionUs - minScrPositionUs; + return finishReadDuration(input); + } + + /** Returns the duration last read from {@link #readDuration(ExtractorInput, PositionHolder)}. */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the SCR value read from the next pack in the stream, given the buffer at the pack + * header start position (just behind the pack start code). + */ + public static long readScrValueFromPack(ParsableByteArray packetBuffer) { + int originalPosition = packetBuffer.getPosition(); + if (packetBuffer.bytesLeft() < 9) { + // We require at 9 bytes for pack header to read scr value + return C.TIME_UNSET; + } + byte[] scrBytes = new byte[9]; + packetBuffer.readBytes(scrBytes, /* offset= */ 0, scrBytes.length); + packetBuffer.setPosition(originalPosition); + if (!checkMarkerBits(scrBytes)) { + return C.TIME_UNSET; + } + return readScrValueFromPackHeader(scrBytes); + } + + private int finishReadDuration(ExtractorInput input) { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + isDurationRead = true; + input.resetPeekPosition(); + return Extractor.RESULT_CONTINUE; + } + + private int readFirstScrValue(ExtractorInput input, PositionHolder seekPositionHolder) + throws IOException, InterruptedException { + int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength()); + int searchStartPosition = 0; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + + firstScrValue = readFirstScrValueFromBuffer(packetBuffer); + isFirstScrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readFirstScrValueFromBuffer(ParsableByteArray packetBuffer) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchStartPosition; + searchPosition < searchEndPosition - 3; + searchPosition++) { + int nextStartCode = peekIntAtPosition(packetBuffer.data, searchPosition); + if (nextStartCode == PsExtractor.PACK_START_CODE) { + packetBuffer.setPosition(searchPosition + 4); + long scrValue = readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + return scrValue; + } + } + } + return C.TIME_UNSET; + } + + private int readLastScrValue(ExtractorInput input, PositionHolder seekPositionHolder) + throws IOException, InterruptedException { + long inputLength = input.getLength(); + int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, inputLength); + long searchStartPosition = inputLength - bytesToSearch; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + + lastScrValue = readLastScrValueFromBuffer(packetBuffer); + isLastScrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readLastScrValueFromBuffer(ParsableByteArray packetBuffer) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchEndPosition - 4; + searchPosition >= searchStartPosition; + searchPosition--) { + int nextStartCode = peekIntAtPosition(packetBuffer.data, searchPosition); + if (nextStartCode == PsExtractor.PACK_START_CODE) { + packetBuffer.setPosition(searchPosition + 4); + long scrValue = readScrValueFromPack(packetBuffer); + if (scrValue != C.TIME_UNSET) { + return scrValue; + } + } + } + return C.TIME_UNSET; + } + + private int peekIntAtPosition(byte[] data, int position) { + return (data[position] & 0xFF) << 24 + | (data[position + 1] & 0xFF) << 16 + | (data[position + 2] & 0xFF) << 8 + | (data[position + 3] & 0xFF); + } + + private static boolean checkMarkerBits(byte[] scrBytes) { + // Verify the 01xxx1xx marker on the 0th byte + if ((scrBytes[0] & 0xC4) != 0x44) { + return false; + } + // 1st byte belongs to scr field. + // Verify the xxxxx1xx marker on the 2nd byte + if ((scrBytes[2] & 0x04) != 0x04) { + return false; + } + // 3rd byte belongs to scr field. + // Verify the xxxxx1xx marker on the 4rd byte + if ((scrBytes[4] & 0x04) != 0x04) { + return false; + } + // Verify the xxxxxxx1 marker on the 5th byte + if ((scrBytes[5] & 0x01) != 0x01) { + return false; + } + // 6th and 7th bytes belongs to program_max_rate field. + // Verify the xxxxxx11 marker on the 8th byte + return (scrBytes[8] & 0x03) == 0x03; + } + + /** + * Returns the value of SCR base - 33 bits in big endian order from the PS pack header, ignoring + * the marker bits. Note: See ISO/IEC 13818-1, Table 2-33 for details of the SCR field in + * pack_header. + * + * <p>We ignore SCR Ext, because it's too small to have any significance. + */ + private static long readScrValueFromPackHeader(byte[] scrBytes) { + return ((scrBytes[0] & 0b00111000L) >> 3) << 30 + | (scrBytes[0] & 0b00000011L) << 28 + | (scrBytes[1] & 0xFFL) << 20 + | ((scrBytes[2] & 0b11111000L) >> 3) << 15 + | (scrBytes[2] & 0b00000011L) << 13 + | (scrBytes[3] & 0xFFL) << 5 + | (scrBytes[4] & 0b11111000L) >> 3; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsExtractor.java new file mode 100644 index 0000000000..8dcccbe459 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/PsExtractor.java @@ -0,0 +1,397 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import android.util.SparseArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; + +/** + * Extracts data from the MPEG-2 PS container format. + */ +public final class PsExtractor implements Extractor { + + /** Factory for {@link PsExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new PsExtractor()}; + + /* package */ static final int PACK_START_CODE = 0x000001BA; + /* package */ static final int SYSTEM_HEADER_START_CODE = 0x000001BB; + /* package */ static final int PACKET_START_CODE_PREFIX = 0x000001; + /* package */ static final int MPEG_PROGRAM_END_CODE = 0x000001B9; + private static final int MAX_STREAM_ID_PLUS_ONE = 0x100; + + // Max search length for first audio and video track in input data. + private static final long MAX_SEARCH_LENGTH = 1024 * 1024; + // Max search length for additional audio and video tracks in input data after at least one audio + // and video track has been found. + private static final long MAX_SEARCH_LENGTH_AFTER_AUDIO_AND_VIDEO_FOUND = 8 * 1024; + + public static final int PRIVATE_STREAM_1 = 0xBD; + public static final int AUDIO_STREAM = 0xC0; + public static final int AUDIO_STREAM_MASK = 0xE0; + public static final int VIDEO_STREAM = 0xE0; + public static final int VIDEO_STREAM_MASK = 0xF0; + + private final TimestampAdjuster timestampAdjuster; + private final SparseArray<PesReader> psPayloadReaders; // Indexed by pid + private final ParsableByteArray psPacketBuffer; + private final PsDurationReader durationReader; + + private boolean foundAllTracks; + private boolean foundAudioTrack; + private boolean foundVideoTrack; + private long lastTrackPosition; + + // Accessed only by the loading thread. + private PsBinarySearchSeeker psBinarySearchSeeker; + private ExtractorOutput output; + private boolean hasOutputSeekMap; + + public PsExtractor() { + this(new TimestampAdjuster(0)); + } + + public PsExtractor(TimestampAdjuster timestampAdjuster) { + this.timestampAdjuster = timestampAdjuster; + psPacketBuffer = new ParsableByteArray(4096); + psPayloadReaders = new SparseArray<>(); + durationReader = new PsDurationReader(); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + byte[] scratch = new byte[14]; + input.peekFully(scratch, 0, 14); + + // Verify the PACK_START_CODE for the first 4 bytes + if (PACK_START_CODE != (((scratch[0] & 0xFF) << 24) | ((scratch[1] & 0xFF) << 16) + | ((scratch[2] & 0xFF) << 8) | (scratch[3] & 0xFF))) { + return false; + } + // Verify the 01xxx1xx marker on the 5th byte + if ((scratch[4] & 0xC4) != 0x44) { + return false; + } + // Verify the xxxxx1xx marker on the 7th byte + if ((scratch[6] & 0x04) != 0x04) { + return false; + } + // Verify the xxxxx1xx marker on the 9th byte + if ((scratch[8] & 0x04) != 0x04) { + return false; + } + // Verify the xxxxxxx1 marker on the 10th byte + if ((scratch[9] & 0x01) != 0x01) { + return false; + } + // Verify the xxxxxx11 marker on the 13th byte + if ((scratch[12] & 0x03) != 0x03) { + return false; + } + // Read the stuffing length from the 14th byte (last 3 bits) + int packStuffingLength = scratch[13] & 0x07; + input.advancePeekPosition(packStuffingLength); + // Now check that the next 3 bytes are the beginning of an MPEG start code + input.peekFully(scratch, 0, 3); + return (PACKET_START_CODE_PREFIX == (((scratch[0] & 0xFF) << 16) | ((scratch[1] & 0xFF) << 8) + | (scratch[2] & 0xFF))); + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + } + + @Override + public void seek(long position, long timeUs) { + boolean hasNotEncounteredFirstTimestamp = + timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET; + if (hasNotEncounteredFirstTimestamp + || (timestampAdjuster.getFirstSampleTimestampUs() != 0 + && timestampAdjuster.getFirstSampleTimestampUs() != timeUs)) { + // - If the timestamp adjuster in the PS stream has not encountered any sample, it's going to + // treat the first timestamp encountered as sample time 0, which is incorrect. In this case, + // we have to set the first sample timestamp manually. + // - If the timestamp adjuster has its timestamp set manually before, and now we seek to a + // different position, we need to set the first sample timestamp manually again. + timestampAdjuster.reset(); + timestampAdjuster.setFirstSampleTimestampUs(timeUs); + } + + if (psBinarySearchSeeker != null) { + psBinarySearchSeeker.setSeekTargetUs(timeUs); + } + for (int i = 0; i < psPayloadReaders.size(); i++) { + psPayloadReaders.valueAt(i).seek(); + } + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + + long inputLength = input.getLength(); + boolean canReadDuration = inputLength != C.LENGTH_UNSET; + if (canReadDuration && !durationReader.isDurationReadFinished()) { + return durationReader.readDuration(input, seekPosition); + } + maybeOutputSeekMap(inputLength); + if (psBinarySearchSeeker != null && psBinarySearchSeeker.isSeeking()) { + return psBinarySearchSeeker.handlePendingSeek(input, seekPosition); + } + + input.resetPeekPosition(); + long peekBytesLeft = + inputLength != C.LENGTH_UNSET ? inputLength - input.getPeekPosition() : C.LENGTH_UNSET; + if (peekBytesLeft != C.LENGTH_UNSET && peekBytesLeft < 4) { + return RESULT_END_OF_INPUT; + } + // First peek and check what type of start code is next. + if (!input.peekFully(psPacketBuffer.data, 0, 4, true)) { + return RESULT_END_OF_INPUT; + } + + psPacketBuffer.setPosition(0); + int nextStartCode = psPacketBuffer.readInt(); + if (nextStartCode == MPEG_PROGRAM_END_CODE) { + return RESULT_END_OF_INPUT; + } else if (nextStartCode == PACK_START_CODE) { + // Now peek the rest of the pack_header. + input.peekFully(psPacketBuffer.data, 0, 10); + + // We only care about the pack_stuffing_length in here, skip the first 77 bits. + psPacketBuffer.setPosition(9); + + // Last 3 bits is the length. + int packStuffingLength = psPacketBuffer.readUnsignedByte() & 0x07; + + // Now skip the stuffing and the pack header. + input.skipFully(packStuffingLength + 14); + return RESULT_CONTINUE; + } else if (nextStartCode == SYSTEM_HEADER_START_CODE) { + // We just skip all this, but we need to get the length first. + input.peekFully(psPacketBuffer.data, 0, 2); + + // Length is the next 2 bytes. + psPacketBuffer.setPosition(0); + int systemHeaderLength = psPacketBuffer.readUnsignedShort(); + input.skipFully(systemHeaderLength + 6); + return RESULT_CONTINUE; + } else if (((nextStartCode & 0xFFFFFF00) >> 8) != PACKET_START_CODE_PREFIX) { + input.skipFully(1); // Skip bytes until we see a valid start code again. + return RESULT_CONTINUE; + } + + // We're at the start of a regular PES packet now. + // Get the stream ID off the last byte of the start code. + int streamId = nextStartCode & 0xFF; + + // Check to see if we have this one in our map yet, and if not, then add it. + PesReader payloadReader = psPayloadReaders.get(streamId); + if (!foundAllTracks) { + if (payloadReader == null) { + ElementaryStreamReader elementaryStreamReader = null; + if (streamId == PRIVATE_STREAM_1) { + // Private stream, used for AC3 audio. + // NOTE: This may need further parsing to determine if its DTS, but that's likely only + // valid for DVDs. + elementaryStreamReader = new Ac3Reader(); + foundAudioTrack = true; + lastTrackPosition = input.getPosition(); + } else if ((streamId & AUDIO_STREAM_MASK) == AUDIO_STREAM) { + elementaryStreamReader = new MpegAudioReader(); + foundAudioTrack = true; + lastTrackPosition = input.getPosition(); + } else if ((streamId & VIDEO_STREAM_MASK) == VIDEO_STREAM) { + elementaryStreamReader = new H262Reader(); + foundVideoTrack = true; + lastTrackPosition = input.getPosition(); + } + if (elementaryStreamReader != null) { + TrackIdGenerator idGenerator = new TrackIdGenerator(streamId, MAX_STREAM_ID_PLUS_ONE); + elementaryStreamReader.createTracks(output, idGenerator); + payloadReader = new PesReader(elementaryStreamReader, timestampAdjuster); + psPayloadReaders.put(streamId, payloadReader); + } + } + long maxSearchPosition = + foundAudioTrack && foundVideoTrack + ? lastTrackPosition + MAX_SEARCH_LENGTH_AFTER_AUDIO_AND_VIDEO_FOUND + : MAX_SEARCH_LENGTH; + if (input.getPosition() > maxSearchPosition) { + foundAllTracks = true; + output.endTracks(); + } + } + + // The next 2 bytes are the length. Once we have that we can consume the complete packet. + input.peekFully(psPacketBuffer.data, 0, 2); + psPacketBuffer.setPosition(0); + int payloadLength = psPacketBuffer.readUnsignedShort(); + int pesLength = payloadLength + 6; + + if (payloadReader == null) { + // Just skip this data. + input.skipFully(pesLength); + } else { + psPacketBuffer.reset(pesLength); + // Read the whole packet and the header for consumption. + input.readFully(psPacketBuffer.data, 0, pesLength); + psPacketBuffer.setPosition(6); + payloadReader.consume(psPacketBuffer); + psPacketBuffer.setLimit(psPacketBuffer.capacity()); + } + + return RESULT_CONTINUE; + } + + // Internals. + + private void maybeOutputSeekMap(long inputLength) { + if (!hasOutputSeekMap) { + hasOutputSeekMap = true; + if (durationReader.getDurationUs() != C.TIME_UNSET) { + psBinarySearchSeeker = + new PsBinarySearchSeeker( + durationReader.getScrTimestampAdjuster(), + durationReader.getDurationUs(), + inputLength); + output.seekMap(psBinarySearchSeeker.getSeekMap()); + } else { + output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + } + } + } + + /** + * Parses PES packet data and extracts samples. + */ + private static final class PesReader { + + private static final int PES_SCRATCH_SIZE = 64; + + private final ElementaryStreamReader pesPayloadReader; + private final TimestampAdjuster timestampAdjuster; + private final ParsableBitArray pesScratch; + + private boolean ptsFlag; + private boolean dtsFlag; + private boolean seenFirstDts; + private int extendedHeaderLength; + private long timeUs; + + public PesReader(ElementaryStreamReader pesPayloadReader, TimestampAdjuster timestampAdjuster) { + this.pesPayloadReader = pesPayloadReader; + this.timestampAdjuster = timestampAdjuster; + pesScratch = new ParsableBitArray(new byte[PES_SCRATCH_SIZE]); + } + + /** + * Notifies the reader that a seek has occurred. + * <p> + * Following a call to this method, the data passed to the next invocation of + * {@link #consume(ParsableByteArray)} will not be a continuation of the data that was + * previously passed. Hence the reader should reset any internal state. + */ + public void seek() { + seenFirstDts = false; + pesPayloadReader.seek(); + } + + /** + * Consumes the payload of a PS packet. + * + * @param data The PES packet. The position will be set to the start of the payload. + * @throws ParserException If the payload could not be parsed. + */ + public void consume(ParsableByteArray data) throws ParserException { + data.readBytes(pesScratch.data, 0, 3); + pesScratch.setPosition(0); + parseHeader(); + data.readBytes(pesScratch.data, 0, extendedHeaderLength); + pesScratch.setPosition(0); + parseHeaderExtension(); + pesPayloadReader.packetStarted(timeUs, TsPayloadReader.FLAG_DATA_ALIGNMENT_INDICATOR); + pesPayloadReader.consume(data); + // We always have complete PES packets with program stream. + pesPayloadReader.packetFinished(); + } + + private void parseHeader() { + // Note: see ISO/IEC 13818-1, section 2.4.3.6 for detailed information on the format of + // the header. + // First 8 bits are skipped: '10' (2), PES_scrambling_control (2), PES_priority (1), + // data_alignment_indicator (1), copyright (1), original_or_copy (1) + pesScratch.skipBits(8); + ptsFlag = pesScratch.readBit(); + dtsFlag = pesScratch.readBit(); + // ESCR_flag (1), ES_rate_flag (1), DSM_trick_mode_flag (1), + // additional_copy_info_flag (1), PES_CRC_flag (1), PES_extension_flag (1) + pesScratch.skipBits(6); + extendedHeaderLength = pesScratch.readBits(8); + } + + private void parseHeaderExtension() { + timeUs = 0; + if (ptsFlag) { + pesScratch.skipBits(4); // '0010' or '0011' + long pts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + pts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + if (!seenFirstDts && dtsFlag) { + pesScratch.skipBits(4); // '0011' + long dts = (long) pesScratch.readBits(3) << 30; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15) << 15; + pesScratch.skipBits(1); // marker_bit + dts |= pesScratch.readBits(15); + pesScratch.skipBits(1); // marker_bit + // Subsequent PES packets may have earlier presentation timestamps than this one, but they + // should all be greater than or equal to this packet's decode timestamp. We feed the + // decode timestamp to the adjuster here so that in the case that this is the first to be + // fed, the adjuster will be able to compute an offset to apply such that the adjusted + // presentation timestamps of all future packets are non-negative. + timestampAdjuster.adjustTsTimestamp(dts); + seenFirstDts = true; + } + timeUs = timestampAdjuster.adjustTsTimestamp(pts); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SectionPayloadReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SectionPayloadReader.java new file mode 100644 index 0000000000..b5942b8bcc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SectionPayloadReader.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; + +/** + * Reads section data. + */ +public interface SectionPayloadReader { + + /** + * Initializes the section payload reader. + * + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param extractorOutput The {@link ExtractorOutput} that receives the extracted data. + * @param idGenerator A {@link PesReader.TrackIdGenerator} that generates unique track ids for the + * {@link TrackOutput}s. + */ + void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator); + + /** + * Called by a {@link SectionReader} when a full section is received. + * + * @param sectionData The data belonging to a section starting from the table_id. If + * section_syntax_indicator is set to '1', {@code sectionData} excludes the CRC_32 field. + * Otherwise, all bytes belonging to the table section are included. + */ + void consume(ParsableByteArray sectionData); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SectionReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SectionReader.java new file mode 100644 index 0000000000..61b53cfa72 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SectionReader.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Reads section data packets and feeds the whole sections to a given {@link SectionPayloadReader}. + * Useful information on PSI sections can be found in ISO/IEC 13818-1, section 2.4.4. + */ +public final class SectionReader implements TsPayloadReader { + + private static final int SECTION_HEADER_LENGTH = 3; + private static final int DEFAULT_SECTION_BUFFER_LENGTH = 32; + private static final int MAX_SECTION_LENGTH = 4098; + + private final SectionPayloadReader reader; + private final ParsableByteArray sectionData; + + private int totalSectionLength; + private int bytesRead; + private boolean sectionSyntaxIndicator; + private boolean waitingForPayloadStart; + + public SectionReader(SectionPayloadReader reader) { + this.reader = reader; + sectionData = new ParsableByteArray(DEFAULT_SECTION_BUFFER_LENGTH); + } + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + reader.init(timestampAdjuster, extractorOutput, idGenerator); + waitingForPayloadStart = true; + } + + @Override + public void seek() { + waitingForPayloadStart = true; + } + + @Override + public void consume(ParsableByteArray data, @Flags int flags) { + boolean payloadUnitStartIndicator = (flags & FLAG_PAYLOAD_UNIT_START_INDICATOR) != 0; + int payloadStartPosition = C.POSITION_UNSET; + if (payloadUnitStartIndicator) { + int payloadStartOffset = data.readUnsignedByte(); + payloadStartPosition = data.getPosition() + payloadStartOffset; + } + + if (waitingForPayloadStart) { + if (!payloadUnitStartIndicator) { + return; + } + waitingForPayloadStart = false; + data.setPosition(payloadStartPosition); + bytesRead = 0; + } + + while (data.bytesLeft() > 0) { + if (bytesRead < SECTION_HEADER_LENGTH) { + // Note: see ISO/IEC 13818-1, section 2.4.4.3 for detailed information on the format of + // the header. + if (bytesRead == 0) { + int tableId = data.readUnsignedByte(); + data.setPosition(data.getPosition() - 1); + if (tableId == 0xFF /* forbidden value */) { + // No more sections in this ts packet. + waitingForPayloadStart = true; + return; + } + } + int headerBytesToRead = Math.min(data.bytesLeft(), SECTION_HEADER_LENGTH - bytesRead); + data.readBytes(sectionData.data, bytesRead, headerBytesToRead); + bytesRead += headerBytesToRead; + if (bytesRead == SECTION_HEADER_LENGTH) { + sectionData.reset(SECTION_HEADER_LENGTH); + sectionData.skipBytes(1); // Skip table id (8). + int secondHeaderByte = sectionData.readUnsignedByte(); + int thirdHeaderByte = sectionData.readUnsignedByte(); + sectionSyntaxIndicator = (secondHeaderByte & 0x80) != 0; + totalSectionLength = + (((secondHeaderByte & 0x0F) << 8) | thirdHeaderByte) + SECTION_HEADER_LENGTH; + if (sectionData.capacity() < totalSectionLength) { + // Ensure there is enough space to keep the whole section. + byte[] bytes = sectionData.data; + sectionData.reset( + Math.min(MAX_SECTION_LENGTH, Math.max(totalSectionLength, bytes.length * 2))); + System.arraycopy(bytes, 0, sectionData.data, 0, SECTION_HEADER_LENGTH); + } + } + } else { + // Reading the body. + int bodyBytesToRead = Math.min(data.bytesLeft(), totalSectionLength - bytesRead); + data.readBytes(sectionData.data, bytesRead, bodyBytesToRead); + bytesRead += bodyBytesToRead; + if (bytesRead == totalSectionLength) { + if (sectionSyntaxIndicator) { + // This section has common syntax as defined in ISO/IEC 13818-1, section 2.4.4.11. + if (Util.crc32(sectionData.data, 0, totalSectionLength, 0xFFFFFFFF) != 0) { + // The CRC is invalid so discard the section. + waitingForPayloadStart = true; + return; + } + sectionData.reset(totalSectionLength - 4); // Exclude the CRC_32 field. + } else { + // This is a private section with private defined syntax. + sectionData.reset(totalSectionLength); + } + reader.consume(sectionData); + bytesRead = 0; + } + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SeiReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SeiReader.java new file mode 100644 index 0000000000..88ea482be4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SeiReader.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.CeaUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.List; + +/** Consumes SEI buffers, outputting contained CEA-608 messages to a {@link TrackOutput}. */ +public final class SeiReader { + + private final List<Format> closedCaptionFormats; + private final TrackOutput[] outputs; + + /** + * @param closedCaptionFormats A list of formats for the closed caption channels to expose. + */ + public SeiReader(List<Format> closedCaptionFormats) { + this.closedCaptionFormats = closedCaptionFormats; + outputs = new TrackOutput[closedCaptionFormats.size()]; + } + + public void createTracks(ExtractorOutput extractorOutput, TrackIdGenerator idGenerator) { + for (int i = 0; i < outputs.length; i++) { + idGenerator.generateNewId(); + TrackOutput output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_TEXT); + Format channelFormat = closedCaptionFormats.get(i); + String channelMimeType = channelFormat.sampleMimeType; + Assertions.checkArgument(MimeTypes.APPLICATION_CEA608.equals(channelMimeType) + || MimeTypes.APPLICATION_CEA708.equals(channelMimeType), + "Invalid closed caption mime type provided: " + channelMimeType); + String formatId = channelFormat.id != null ? channelFormat.id : idGenerator.getFormatId(); + output.format( + Format.createTextSampleFormat( + formatId, + channelMimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + channelFormat.selectionFlags, + channelFormat.language, + channelFormat.accessibilityChannel, + /* drmInitData= */ null, + Format.OFFSET_SAMPLE_RELATIVE, + channelFormat.initializationData)); + outputs[i] = output; + } + } + + public void consume(long pesTimeUs, ParsableByteArray seiBuffer) { + CeaUtil.consume(pesTimeUs, seiBuffer, outputs); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java new file mode 100644 index 0000000000..17223bad7c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/SpliceInfoSectionReader.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; + +/** + * Parses splice info sections as defined by SCTE35. + */ +public final class SpliceInfoSectionReader implements SectionPayloadReader { + + private TimestampAdjuster timestampAdjuster; + private TrackOutput output; + private boolean formatDeclared; + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TsPayloadReader.TrackIdGenerator idGenerator) { + this.timestampAdjuster = timestampAdjuster; + idGenerator.generateNewId(); + output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_METADATA); + output.format(Format.createSampleFormat(idGenerator.getFormatId(), MimeTypes.APPLICATION_SCTE35, + null, Format.NO_VALUE, null)); + } + + @Override + public void consume(ParsableByteArray sectionData) { + if (!formatDeclared) { + if (timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET) { + // There is not enough information to initialize the timestamp adjuster. + return; + } + output.format(Format.createSampleFormat(null, MimeTypes.APPLICATION_SCTE35, + timestampAdjuster.getTimestampOffsetUs())); + formatDeclared = true; + } + int sampleSize = sectionData.bytesLeft(); + output.sampleData(sectionData, sampleSize); + output.sampleMetadata(timestampAdjuster.getLastAdjustedTimestampUs(), C.BUFFER_FLAG_KEY_FRAME, + sampleSize, 0, null); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java new file mode 100644 index 0000000000..136691bdaf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsBinarySearchSeeker.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.BinarySearchSeeker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A seeker that supports seeking within TS stream using binary search. + * + * <p>This seeker uses the first and last PCR values within the stream, as well as the stream + * duration to interpolate the PCR value of the seeking position. Then it performs binary search + * within the stream to find a packets whose PCR value is within {@link #SEEK_TOLERANCE_US} from the + * target PCR. + */ +/* package */ final class TsBinarySearchSeeker extends BinarySearchSeeker { + + private static final long SEEK_TOLERANCE_US = 100_000; + private static final int MINIMUM_SEARCH_RANGE_BYTES = 5 * TsExtractor.TS_PACKET_SIZE; + private static final int TIMESTAMP_SEARCH_BYTES = 600 * TsExtractor.TS_PACKET_SIZE; + + public TsBinarySearchSeeker( + TimestampAdjuster pcrTimestampAdjuster, long streamDurationUs, long inputLength, int pcrPid) { + super( + new DefaultSeekTimestampConverter(), + new TsPcrSeeker(pcrPid, pcrTimestampAdjuster), + streamDurationUs, + /* floorTimePosition= */ 0, + /* ceilingTimePosition= */ streamDurationUs + 1, + /* floorBytePosition= */ 0, + /* ceilingBytePosition= */ inputLength, + /* approxBytesPerFrame= */ TsExtractor.TS_PACKET_SIZE, + MINIMUM_SEARCH_RANGE_BYTES); + } + + /** + * A {@link TimestampSeeker} implementation that looks for a given PCR timestamp at a given + * position in a TS stream. + * + * <p>Given a PCR timestamp, and a position within a TS stream, this seeker will peek up to {@link + * #TIMESTAMP_SEARCH_BYTES} from that stream position, look for all packets with PID equal to + * PCR_PID, and then compare the PCR timestamps (if available) of these packets to the target + * timestamp. + */ + private static final class TsPcrSeeker implements TimestampSeeker { + + private final TimestampAdjuster pcrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + private final int pcrPid; + + public TsPcrSeeker(int pcrPid, TimestampAdjuster pcrTimestampAdjuster) { + this.pcrPid = pcrPid; + this.pcrTimestampAdjuster = pcrTimestampAdjuster; + packetBuffer = new ParsableByteArray(); + } + + @Override + public TimestampSearchResult searchForTimestamp(ExtractorInput input, long targetTimestamp) + throws IOException, InterruptedException { + long inputPosition = input.getPosition(); + int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength() - inputPosition); + + packetBuffer.reset(bytesToSearch); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + + return searchForPcrValueInBuffer(packetBuffer, targetTimestamp, inputPosition); + } + + private TimestampSearchResult searchForPcrValueInBuffer( + ParsableByteArray packetBuffer, long targetPcrTimeUs, long bufferStartOffset) { + int limit = packetBuffer.limit(); + + long startOfLastPacketPosition = C.POSITION_UNSET; + long endOfLastPacketPosition = C.POSITION_UNSET; + long lastPcrTimeUsInRange = C.TIME_UNSET; + + while (packetBuffer.bytesLeft() >= TsExtractor.TS_PACKET_SIZE) { + int startOfPacket = + TsUtil.findSyncBytePosition(packetBuffer.data, packetBuffer.getPosition(), limit); + int endOfPacket = startOfPacket + TsExtractor.TS_PACKET_SIZE; + if (endOfPacket > limit) { + break; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, startOfPacket, pcrPid); + if (pcrValue != C.TIME_UNSET) { + long pcrTimeUs = pcrTimestampAdjuster.adjustTsTimestamp(pcrValue); + if (pcrTimeUs > targetPcrTimeUs) { + if (lastPcrTimeUsInRange == C.TIME_UNSET) { + // First PCR timestamp is already over target. + return TimestampSearchResult.overestimatedResult(pcrTimeUs, bufferStartOffset); + } else { + // Last PCR timestamp < target timestamp < this timestamp. + return TimestampSearchResult.targetFoundResult( + bufferStartOffset + startOfLastPacketPosition); + } + } else if (pcrTimeUs + SEEK_TOLERANCE_US > targetPcrTimeUs) { + long startOfPacketInStream = bufferStartOffset + startOfPacket; + return TimestampSearchResult.targetFoundResult(startOfPacketInStream); + } + + lastPcrTimeUsInRange = pcrTimeUs; + startOfLastPacketPosition = startOfPacket; + } + packetBuffer.setPosition(endOfPacket); + endOfLastPacketPosition = endOfPacket; + } + + if (lastPcrTimeUsInRange != C.TIME_UNSET) { + long endOfLastPacketPositionInStream = bufferStartOffset + endOfLastPacketPosition; + return TimestampSearchResult.underestimatedResult( + lastPcrTimeUsInRange, endOfLastPacketPositionInStream); + } else { + return TimestampSearchResult.NO_TIMESTAMP_IN_RANGE_RESULT; + } + } + + @Override + public void onSeekFinished() { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java new file mode 100644 index 0000000000..ed4b66a7e4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsDurationReader.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A reader that can extract the approximate duration from a given MPEG transport stream (TS). + * + * <p>This reader extracts the duration by reading PCR values of the PCR PID packets at the start + * and at the end of the stream, calculating the difference, and converting that into stream + * duration. This reader also handles the case when a single PCR wraparound takes place within the + * stream, which can make PCR values at the beginning of the stream larger than PCR values at the + * end. This class can only be used once to read duration from a given stream, and the usage of the + * class is not thread-safe, so all calls should be made from the same thread. + */ +/* package */ final class TsDurationReader { + + private static final int TIMESTAMP_SEARCH_BYTES = 600 * TsExtractor.TS_PACKET_SIZE; + + private final TimestampAdjuster pcrTimestampAdjuster; + private final ParsableByteArray packetBuffer; + + private boolean isDurationRead; + private boolean isFirstPcrValueRead; + private boolean isLastPcrValueRead; + + private long firstPcrValue; + private long lastPcrValue; + private long durationUs; + + /* package */ TsDurationReader() { + pcrTimestampAdjuster = new TimestampAdjuster(/* firstSampleTimestampUs= */ 0); + firstPcrValue = C.TIME_UNSET; + lastPcrValue = C.TIME_UNSET; + durationUs = C.TIME_UNSET; + packetBuffer = new ParsableByteArray(); + } + + /** Returns true if a TS duration has been read. */ + public boolean isDurationReadFinished() { + return isDurationRead; + } + + /** + * Reads a TS duration from the input, using the given PCR PID. + * + * <p>This reader reads the duration by reading PCR values of the PCR PID packets at the start and + * at the end of the stream, calculating the difference, and converting that into stream duration. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param seekPositionHolder If {@link Extractor#RESULT_SEEK} is returned, this holder is updated + * to hold the position of the required seek. + * @param pcrPid The PID of the packet stream within this TS stream that contains PCR values. + * @return One of the {@code RESULT_} values defined in {@link Extractor}. + * @throws IOException If an error occurred reading from the input. + * @throws InterruptedException If the thread was interrupted. + */ + public @Extractor.ReadResult int readDuration( + ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) + throws IOException, InterruptedException { + if (pcrPid <= 0) { + return finishReadDuration(input); + } + if (!isLastPcrValueRead) { + return readLastPcrValue(input, seekPositionHolder, pcrPid); + } + if (lastPcrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + if (!isFirstPcrValueRead) { + return readFirstPcrValue(input, seekPositionHolder, pcrPid); + } + if (firstPcrValue == C.TIME_UNSET) { + return finishReadDuration(input); + } + + long minPcrPositionUs = pcrTimestampAdjuster.adjustTsTimestamp(firstPcrValue); + long maxPcrPositionUs = pcrTimestampAdjuster.adjustTsTimestamp(lastPcrValue); + durationUs = maxPcrPositionUs - minPcrPositionUs; + return finishReadDuration(input); + } + + /** + * Returns the duration last read from {@link #readDuration(ExtractorInput, PositionHolder, int)}. + */ + public long getDurationUs() { + return durationUs; + } + + /** + * Returns the {@link TimestampAdjuster} that this class uses to adjust timestamps read from the + * input TS stream. + */ + public TimestampAdjuster getPcrTimestampAdjuster() { + return pcrTimestampAdjuster; + } + + private int finishReadDuration(ExtractorInput input) { + packetBuffer.reset(Util.EMPTY_BYTE_ARRAY); + isDurationRead = true; + input.resetPeekPosition(); + return Extractor.RESULT_CONTINUE; + } + + private int readFirstPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) + throws IOException, InterruptedException { + int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, input.getLength()); + int searchStartPosition = 0; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + + firstPcrValue = readFirstPcrValueFromBuffer(packetBuffer, pcrPid); + isFirstPcrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readFirstPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcrPid) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchStartPosition; + searchPosition < searchEndPosition; + searchPosition++) { + if (packetBuffer.data[searchPosition] != TsExtractor.TS_SYNC_BYTE) { + continue; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); + if (pcrValue != C.TIME_UNSET) { + return pcrValue; + } + } + return C.TIME_UNSET; + } + + private int readLastPcrValue(ExtractorInput input, PositionHolder seekPositionHolder, int pcrPid) + throws IOException, InterruptedException { + long inputLength = input.getLength(); + int bytesToSearch = (int) Math.min(TIMESTAMP_SEARCH_BYTES, inputLength); + long searchStartPosition = inputLength - bytesToSearch; + if (input.getPosition() != searchStartPosition) { + seekPositionHolder.position = searchStartPosition; + return Extractor.RESULT_SEEK; + } + + packetBuffer.reset(bytesToSearch); + input.resetPeekPosition(); + input.peekFully(packetBuffer.data, /* offset= */ 0, bytesToSearch); + + lastPcrValue = readLastPcrValueFromBuffer(packetBuffer, pcrPid); + isLastPcrValueRead = true; + return Extractor.RESULT_CONTINUE; + } + + private long readLastPcrValueFromBuffer(ParsableByteArray packetBuffer, int pcrPid) { + int searchStartPosition = packetBuffer.getPosition(); + int searchEndPosition = packetBuffer.limit(); + for (int searchPosition = searchEndPosition - 1; + searchPosition >= searchStartPosition; + searchPosition--) { + if (packetBuffer.data[searchPosition] != TsExtractor.TS_SYNC_BYTE) { + continue; + } + long pcrValue = TsUtil.readPcrFromPacket(packetBuffer, searchPosition, pcrPid); + if (pcrValue != C.TIME_UNSET) { + return pcrValue; + } + } + return C.TIME_UNSET; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsExtractor.java new file mode 100644 index 0000000000..a52e56bd32 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsExtractor.java @@ -0,0 +1,698 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.FLAG_PAYLOAD_UNIT_START_INDICATOR; + +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import android.util.SparseIntArray; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory.Flags; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.DvbSubtitleInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.EsInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsPayloadReader.TrackIdGenerator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Extracts data from the MPEG-2 TS container format. + */ +public final class TsExtractor implements Extractor { + + /** Factory for {@link TsExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new TsExtractor()}; + + /** + * Modes for the extractor. One of {@link #MODE_MULTI_PMT}, {@link #MODE_SINGLE_PMT} or {@link + * #MODE_HLS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({MODE_MULTI_PMT, MODE_SINGLE_PMT, MODE_HLS}) + public @interface Mode {} + + /** + * Behave as defined in ISO/IEC 13818-1. + */ + public static final int MODE_MULTI_PMT = 0; + /** + * Assume only one PMT will be contained in the stream, even if more are declared by the PAT. + */ + public static final int MODE_SINGLE_PMT = 1; + /** + * Enable single PMT mode, map {@link TrackOutput}s by their type (instead of PID) and ignore + * continuity counters. + */ + public static final int MODE_HLS = 2; + + public static final int TS_STREAM_TYPE_MPA = 0x03; + public static final int TS_STREAM_TYPE_MPA_LSF = 0x04; + public static final int TS_STREAM_TYPE_AAC_ADTS = 0x0F; + public static final int TS_STREAM_TYPE_AAC_LATM = 0x11; + public static final int TS_STREAM_TYPE_AC3 = 0x81; + public static final int TS_STREAM_TYPE_DTS = 0x8A; + public static final int TS_STREAM_TYPE_HDMV_DTS = 0x82; + public static final int TS_STREAM_TYPE_E_AC3 = 0x87; + public static final int TS_STREAM_TYPE_AC4 = 0xAC; // DVB/ATSC AC-4 Descriptor + public static final int TS_STREAM_TYPE_H262 = 0x02; + public static final int TS_STREAM_TYPE_H264 = 0x1B; + public static final int TS_STREAM_TYPE_H265 = 0x24; + public static final int TS_STREAM_TYPE_ID3 = 0x15; + public static final int TS_STREAM_TYPE_SPLICE_INFO = 0x86; + public static final int TS_STREAM_TYPE_DVBSUBS = 0x59; + + public static final int TS_PACKET_SIZE = 188; + public static final int TS_SYNC_BYTE = 0x47; // First byte of each TS packet. + + private static final int TS_PAT_PID = 0; + private static final int MAX_PID_PLUS_ONE = 0x2000; + + private static final long AC3_FORMAT_IDENTIFIER = 0x41432d33; + private static final long E_AC3_FORMAT_IDENTIFIER = 0x45414333; + private static final long AC4_FORMAT_IDENTIFIER = 0x41432d34; + private static final long HEVC_FORMAT_IDENTIFIER = 0x48455643; + + private static final int BUFFER_SIZE = TS_PACKET_SIZE * 50; + private static final int SNIFF_TS_PACKET_COUNT = 5; + + private final @Mode int mode; + private final List<TimestampAdjuster> timestampAdjusters; + private final ParsableByteArray tsPacketBuffer; + private final SparseIntArray continuityCounters; + private final TsPayloadReader.Factory payloadReaderFactory; + private final SparseArray<TsPayloadReader> tsPayloadReaders; // Indexed by pid + private final SparseBooleanArray trackIds; + private final SparseBooleanArray trackPids; + private final TsDurationReader durationReader; + + // Accessed only by the loading thread. + private TsBinarySearchSeeker tsBinarySearchSeeker; + private ExtractorOutput output; + private int remainingPmts; + private boolean tracksEnded; + private boolean hasOutputSeekMap; + private boolean pendingSeekToStart; + private TsPayloadReader id3Reader; + private int bytesSinceLastSync; + private int pcrPid; + + public TsExtractor() { + this(0); + } + + /** + * @param defaultTsPayloadReaderFlags A combination of {@link DefaultTsPayloadReaderFactory} + * {@code FLAG_*} values that control the behavior of the payload readers. + */ + public TsExtractor(@Flags int defaultTsPayloadReaderFlags) { + this(MODE_SINGLE_PMT, defaultTsPayloadReaderFlags); + } + + /** + * @param mode Mode for the extractor. One of {@link #MODE_MULTI_PMT}, {@link #MODE_SINGLE_PMT} + * and {@link #MODE_HLS}. + * @param defaultTsPayloadReaderFlags A combination of {@link DefaultTsPayloadReaderFactory} + * {@code FLAG_*} values that control the behavior of the payload readers. + */ + public TsExtractor(@Mode int mode, @Flags int defaultTsPayloadReaderFlags) { + this( + mode, + new TimestampAdjuster(0), + new DefaultTsPayloadReaderFactory(defaultTsPayloadReaderFlags)); + } + + /** + * @param mode Mode for the extractor. One of {@link #MODE_MULTI_PMT}, {@link #MODE_SINGLE_PMT} + * and {@link #MODE_HLS}. + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param payloadReaderFactory Factory for injecting a custom set of payload readers. + */ + public TsExtractor( + @Mode int mode, + TimestampAdjuster timestampAdjuster, + TsPayloadReader.Factory payloadReaderFactory) { + this.payloadReaderFactory = Assertions.checkNotNull(payloadReaderFactory); + this.mode = mode; + if (mode == MODE_SINGLE_PMT || mode == MODE_HLS) { + timestampAdjusters = Collections.singletonList(timestampAdjuster); + } else { + timestampAdjusters = new ArrayList<>(); + timestampAdjusters.add(timestampAdjuster); + } + tsPacketBuffer = new ParsableByteArray(new byte[BUFFER_SIZE], 0); + trackIds = new SparseBooleanArray(); + trackPids = new SparseBooleanArray(); + tsPayloadReaders = new SparseArray<>(); + continuityCounters = new SparseIntArray(); + durationReader = new TsDurationReader(); + pcrPid = -1; + resetPayloadReaders(); + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + byte[] buffer = tsPacketBuffer.data; + input.peekFully(buffer, 0, TS_PACKET_SIZE * SNIFF_TS_PACKET_COUNT); + for (int startPosCandidate = 0; startPosCandidate < TS_PACKET_SIZE; startPosCandidate++) { + // Try to identify at least SNIFF_TS_PACKET_COUNT packets starting with TS_SYNC_BYTE. + boolean isSyncBytePatternCorrect = true; + for (int i = 0; i < SNIFF_TS_PACKET_COUNT; i++) { + if (buffer[startPosCandidate + i * TS_PACKET_SIZE] != TS_SYNC_BYTE) { + isSyncBytePatternCorrect = false; + break; + } + } + if (isSyncBytePatternCorrect) { + input.skipFully(startPosCandidate); + return true; + } + } + return false; + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + } + + @Override + public void seek(long position, long timeUs) { + Assertions.checkState(mode != MODE_HLS); + int timestampAdjustersCount = timestampAdjusters.size(); + for (int i = 0; i < timestampAdjustersCount; i++) { + TimestampAdjuster timestampAdjuster = timestampAdjusters.get(i); + boolean hasNotEncounteredFirstTimestamp = + timestampAdjuster.getTimestampOffsetUs() == C.TIME_UNSET; + if (hasNotEncounteredFirstTimestamp + || (timestampAdjuster.getTimestampOffsetUs() != 0 + && timestampAdjuster.getFirstSampleTimestampUs() != timeUs)) { + // - If a track in the TS stream has not encountered any sample, it's going to treat the + // first sample encountered as timestamp 0, which is incorrect. So we have to set the first + // sample timestamp for that track manually. + // - If the timestamp adjuster has its timestamp set manually before, and now we seek to a + // different position, we need to set the first sample timestamp manually again. + timestampAdjuster.reset(); + timestampAdjuster.setFirstSampleTimestampUs(timeUs); + } + } + if (timeUs != 0 && tsBinarySearchSeeker != null) { + tsBinarySearchSeeker.setSeekTargetUs(timeUs); + } + tsPacketBuffer.reset(); + continuityCounters.clear(); + for (int i = 0; i < tsPayloadReaders.size(); i++) { + tsPayloadReaders.valueAt(i).seek(); + } + bytesSinceLastSync = 0; + } + + @Override + public void release() { + // Do nothing + } + + @Override + public @ReadResult int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + long inputLength = input.getLength(); + if (tracksEnded) { + boolean canReadDuration = inputLength != C.LENGTH_UNSET && mode != MODE_HLS; + if (canReadDuration && !durationReader.isDurationReadFinished()) { + return durationReader.readDuration(input, seekPosition, pcrPid); + } + maybeOutputSeekMap(inputLength); + + if (pendingSeekToStart) { + pendingSeekToStart = false; + seek(/* position= */ 0, /* timeUs= */ 0); + if (input.getPosition() != 0) { + seekPosition.position = 0; + return RESULT_SEEK; + } + } + + if (tsBinarySearchSeeker != null && tsBinarySearchSeeker.isSeeking()) { + return tsBinarySearchSeeker.handlePendingSeek(input, seekPosition); + } + } + + if (!fillBufferWithAtLeastOnePacket(input)) { + return RESULT_END_OF_INPUT; + } + + int endOfPacket = findEndOfFirstTsPacketInBuffer(); + int limit = tsPacketBuffer.limit(); + if (endOfPacket > limit) { + return RESULT_CONTINUE; + } + + @TsPayloadReader.Flags int packetHeaderFlags = 0; + + // Note: See ISO/IEC 13818-1, section 2.4.3.2 for details of the header format. + int tsPacketHeader = tsPacketBuffer.readInt(); + if ((tsPacketHeader & 0x800000) != 0) { // transport_error_indicator + // There are uncorrectable errors in this packet. + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + packetHeaderFlags |= (tsPacketHeader & 0x400000) != 0 ? FLAG_PAYLOAD_UNIT_START_INDICATOR : 0; + // Ignoring transport_priority (tsPacketHeader & 0x200000) + int pid = (tsPacketHeader & 0x1FFF00) >> 8; + // Ignoring transport_scrambling_control (tsPacketHeader & 0xC0) + boolean adaptationFieldExists = (tsPacketHeader & 0x20) != 0; + boolean payloadExists = (tsPacketHeader & 0x10) != 0; + + TsPayloadReader payloadReader = payloadExists ? tsPayloadReaders.get(pid) : null; + if (payloadReader == null) { + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + + // Discontinuity check. + if (mode != MODE_HLS) { + int continuityCounter = tsPacketHeader & 0xF; + int previousCounter = continuityCounters.get(pid, continuityCounter - 1); + continuityCounters.put(pid, continuityCounter); + if (previousCounter == continuityCounter) { + // Duplicate packet found. + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } else if (continuityCounter != ((previousCounter + 1) & 0xF)) { + // Discontinuity found. + payloadReader.seek(); + } + } + + // Skip the adaptation field. + if (adaptationFieldExists) { + int adaptationFieldLength = tsPacketBuffer.readUnsignedByte(); + int adaptationFieldFlags = tsPacketBuffer.readUnsignedByte(); + + packetHeaderFlags |= + (adaptationFieldFlags & 0x40) != 0 // random_access_indicator. + ? TsPayloadReader.FLAG_RANDOM_ACCESS_INDICATOR + : 0; + tsPacketBuffer.skipBytes(adaptationFieldLength - 1 /* flags */); + } + + // Read the payload. + boolean wereTracksEnded = tracksEnded; + if (shouldConsumePacketPayload(pid)) { + tsPacketBuffer.setLimit(endOfPacket); + payloadReader.consume(tsPacketBuffer, packetHeaderFlags); + tsPacketBuffer.setLimit(limit); + } + if (mode != MODE_HLS && !wereTracksEnded && tracksEnded && inputLength != C.LENGTH_UNSET) { + // We have read all tracks from all PMTs in this non-live stream. Now seek to the beginning + // and read again to make sure we output all media, including any contained in packets prior + // to those containing the track information. + pendingSeekToStart = true; + } + + tsPacketBuffer.setPosition(endOfPacket); + return RESULT_CONTINUE; + } + + // Internals. + + private void maybeOutputSeekMap(long inputLength) { + if (!hasOutputSeekMap) { + hasOutputSeekMap = true; + if (durationReader.getDurationUs() != C.TIME_UNSET) { + tsBinarySearchSeeker = + new TsBinarySearchSeeker( + durationReader.getPcrTimestampAdjuster(), + durationReader.getDurationUs(), + inputLength, + pcrPid); + output.seekMap(tsBinarySearchSeeker.getSeekMap()); + } else { + output.seekMap(new SeekMap.Unseekable(durationReader.getDurationUs())); + } + } + } + + private boolean fillBufferWithAtLeastOnePacket(ExtractorInput input) + throws IOException, InterruptedException { + byte[] data = tsPacketBuffer.data; + // Shift bytes to the start of the buffer if there isn't enough space left at the end. + if (BUFFER_SIZE - tsPacketBuffer.getPosition() < TS_PACKET_SIZE) { + int bytesLeft = tsPacketBuffer.bytesLeft(); + if (bytesLeft > 0) { + System.arraycopy(data, tsPacketBuffer.getPosition(), data, 0, bytesLeft); + } + tsPacketBuffer.reset(data, bytesLeft); + } + // Read more bytes until we have at least one packet. + while (tsPacketBuffer.bytesLeft() < TS_PACKET_SIZE) { + int limit = tsPacketBuffer.limit(); + int read = input.read(data, limit, BUFFER_SIZE - limit); + if (read == C.RESULT_END_OF_INPUT) { + return false; + } + tsPacketBuffer.setLimit(limit + read); + } + return true; + } + + /** + * Returns the position of the end of the first TS packet (exclusive) in the packet buffer. + * + * <p>This may be a position beyond the buffer limit if the packet has not been read fully into + * the buffer, or if no packet could be found within the buffer. + */ + private int findEndOfFirstTsPacketInBuffer() throws ParserException { + int searchStart = tsPacketBuffer.getPosition(); + int limit = tsPacketBuffer.limit(); + int syncBytePosition = TsUtil.findSyncBytePosition(tsPacketBuffer.data, searchStart, limit); + // Discard all bytes before the sync byte. + // If sync byte is not found, this means discard the whole buffer. + tsPacketBuffer.setPosition(syncBytePosition); + int endOfPacket = syncBytePosition + TS_PACKET_SIZE; + if (endOfPacket > limit) { + bytesSinceLastSync += syncBytePosition - searchStart; + if (mode == MODE_HLS && bytesSinceLastSync > TS_PACKET_SIZE * 2) { + throw new ParserException("Cannot find sync byte. Most likely not a Transport Stream."); + } + } else { + // We have found a packet within the buffer. + bytesSinceLastSync = 0; + } + return endOfPacket; + } + + private boolean shouldConsumePacketPayload(int packetPid) { + return mode == MODE_HLS + || tracksEnded + || !trackPids.get(packetPid, /* valueIfKeyNotFound= */ false); // It's a PSI packet + } + + private void resetPayloadReaders() { + trackIds.clear(); + tsPayloadReaders.clear(); + SparseArray<TsPayloadReader> initialPayloadReaders = + payloadReaderFactory.createInitialPayloadReaders(); + int initialPayloadReadersSize = initialPayloadReaders.size(); + for (int i = 0; i < initialPayloadReadersSize; i++) { + tsPayloadReaders.put(initialPayloadReaders.keyAt(i), initialPayloadReaders.valueAt(i)); + } + tsPayloadReaders.put(TS_PAT_PID, new SectionReader(new PatReader())); + id3Reader = null; + } + + /** + * Parses Program Association Table data. + */ + private class PatReader implements SectionPayloadReader { + + private final ParsableBitArray patScratch; + + public PatReader() { + patScratch = new ParsableBitArray(new byte[4]); + } + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + // Do nothing. + } + + @Override + public void consume(ParsableByteArray sectionData) { + int tableId = sectionData.readUnsignedByte(); + if (tableId != 0x00 /* program_association_section */) { + // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. + return; + } + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12), + // transport_stream_id (16), reserved (2), version_number (5), current_next_indicator (1), + // section_number (8), last_section_number (8) + sectionData.skipBytes(7); + + int programCount = sectionData.bytesLeft() / 4; + for (int i = 0; i < programCount; i++) { + sectionData.readBytes(patScratch, 4); + int programNumber = patScratch.readBits(16); + patScratch.skipBits(3); // reserved (3) + if (programNumber == 0) { + patScratch.skipBits(13); // network_PID (13) + } else { + int pid = patScratch.readBits(13); + tsPayloadReaders.put(pid, new SectionReader(new PmtReader(pid))); + remainingPmts++; + } + } + if (mode != MODE_HLS) { + tsPayloadReaders.remove(TS_PAT_PID); + } + } + + } + + /** + * Parses Program Map Table. + */ + private class PmtReader implements SectionPayloadReader { + + private static final int TS_PMT_DESC_REGISTRATION = 0x05; + private static final int TS_PMT_DESC_ISO639_LANG = 0x0A; + private static final int TS_PMT_DESC_AC3 = 0x6A; + private static final int TS_PMT_DESC_EAC3 = 0x7A; + private static final int TS_PMT_DESC_DTS = 0x7B; + private static final int TS_PMT_DESC_DVB_EXT = 0x7F; + private static final int TS_PMT_DESC_DVBSUBS = 0x59; + + private static final int TS_PMT_DESC_DVB_EXT_AC4 = 0x15; + + private final ParsableBitArray pmtScratch; + private final SparseArray<TsPayloadReader> trackIdToReaderScratch; + private final SparseIntArray trackIdToPidScratch; + private final int pid; + + public PmtReader(int pid) { + pmtScratch = new ParsableBitArray(new byte[5]); + trackIdToReaderScratch = new SparseArray<>(); + trackIdToPidScratch = new SparseIntArray(); + this.pid = pid; + } + + @Override + public void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator) { + // Do nothing. + } + + @Override + public void consume(ParsableByteArray sectionData) { + int tableId = sectionData.readUnsignedByte(); + if (tableId != 0x02 /* TS_program_map_section */) { + // See ISO/IEC 13818-1, section 2.4.4.4 for more information on table id assignment. + return; + } + // TimestampAdjuster assignment. + TimestampAdjuster timestampAdjuster; + if (mode == MODE_SINGLE_PMT || mode == MODE_HLS || remainingPmts == 1) { + timestampAdjuster = timestampAdjusters.get(0); + } else { + timestampAdjuster = new TimestampAdjuster( + timestampAdjusters.get(0).getFirstSampleTimestampUs()); + timestampAdjusters.add(timestampAdjuster); + } + + // section_syntax_indicator(1), '0'(1), reserved(2), section_length(12) + sectionData.skipBytes(2); + int programNumber = sectionData.readUnsignedShort(); + + // Skip 3 bytes (24 bits), including: + // reserved (2), version_number (5), current_next_indicator (1), section_number (8), + // last_section_number (8) + sectionData.skipBytes(3); + + sectionData.readBytes(pmtScratch, 2); + // reserved (3), PCR_PID (13) + pmtScratch.skipBits(3); + pcrPid = pmtScratch.readBits(13); + + // Read program_info_length. + sectionData.readBytes(pmtScratch, 2); + pmtScratch.skipBits(4); + int programInfoLength = pmtScratch.readBits(12); + + // Skip the descriptors. + sectionData.skipBytes(programInfoLength); + + if (mode == MODE_HLS && id3Reader == null) { + // Setup an ID3 track regardless of whether there's a corresponding entry, in case one + // appears intermittently during playback. See [Internal: b/20261500]. + EsInfo dummyEsInfo = new EsInfo(TS_STREAM_TYPE_ID3, null, null, Util.EMPTY_BYTE_ARRAY); + id3Reader = payloadReaderFactory.createPayloadReader(TS_STREAM_TYPE_ID3, dummyEsInfo); + id3Reader.init(timestampAdjuster, output, + new TrackIdGenerator(programNumber, TS_STREAM_TYPE_ID3, MAX_PID_PLUS_ONE)); + } + + trackIdToReaderScratch.clear(); + trackIdToPidScratch.clear(); + int remainingEntriesLength = sectionData.bytesLeft(); + while (remainingEntriesLength > 0) { + sectionData.readBytes(pmtScratch, 5); + int streamType = pmtScratch.readBits(8); + pmtScratch.skipBits(3); // reserved + int elementaryPid = pmtScratch.readBits(13); + pmtScratch.skipBits(4); // reserved + int esInfoLength = pmtScratch.readBits(12); // ES_info_length. + EsInfo esInfo = readEsInfo(sectionData, esInfoLength); + if (streamType == 0x06) { + streamType = esInfo.streamType; + } + remainingEntriesLength -= esInfoLength + 5; + + int trackId = mode == MODE_HLS ? streamType : elementaryPid; + if (trackIds.get(trackId)) { + continue; + } + + TsPayloadReader reader = mode == MODE_HLS && streamType == TS_STREAM_TYPE_ID3 ? id3Reader + : payloadReaderFactory.createPayloadReader(streamType, esInfo); + if (mode != MODE_HLS + || elementaryPid < trackIdToPidScratch.get(trackId, MAX_PID_PLUS_ONE)) { + trackIdToPidScratch.put(trackId, elementaryPid); + trackIdToReaderScratch.put(trackId, reader); + } + } + + int trackIdCount = trackIdToPidScratch.size(); + for (int i = 0; i < trackIdCount; i++) { + int trackId = trackIdToPidScratch.keyAt(i); + int trackPid = trackIdToPidScratch.valueAt(i); + trackIds.put(trackId, true); + trackPids.put(trackPid, true); + TsPayloadReader reader = trackIdToReaderScratch.valueAt(i); + if (reader != null) { + if (reader != id3Reader) { + reader.init(timestampAdjuster, output, + new TrackIdGenerator(programNumber, trackId, MAX_PID_PLUS_ONE)); + } + tsPayloadReaders.put(trackPid, reader); + } + } + + if (mode == MODE_HLS) { + if (!tracksEnded) { + output.endTracks(); + remainingPmts = 0; + tracksEnded = true; + } + } else { + tsPayloadReaders.remove(pid); + remainingPmts = mode == MODE_SINGLE_PMT ? 0 : remainingPmts - 1; + if (remainingPmts == 0) { + output.endTracks(); + tracksEnded = true; + } + } + } + + /** + * Returns the stream info read from the available descriptors. Sets {@code data}'s position to + * the end of the descriptors. + * + * @param data A buffer with its position set to the start of the first descriptor. + * @param length The length of descriptors to read from the current position in {@code data}. + * @return The stream info read from the available descriptors. + */ + private EsInfo readEsInfo(ParsableByteArray data, int length) { + int descriptorsStartPosition = data.getPosition(); + int descriptorsEndPosition = descriptorsStartPosition + length; + int streamType = -1; + String language = null; + List<DvbSubtitleInfo> dvbSubtitleInfos = null; + while (data.getPosition() < descriptorsEndPosition) { + int descriptorTag = data.readUnsignedByte(); + int descriptorLength = data.readUnsignedByte(); + int positionOfNextDescriptor = data.getPosition() + descriptorLength; + if (descriptorTag == TS_PMT_DESC_REGISTRATION) { // registration_descriptor + long formatIdentifier = data.readUnsignedInt(); + if (formatIdentifier == AC3_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_AC3; + } else if (formatIdentifier == E_AC3_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_E_AC3; + } else if (formatIdentifier == AC4_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_AC4; + } else if (formatIdentifier == HEVC_FORMAT_IDENTIFIER) { + streamType = TS_STREAM_TYPE_H265; + } + } else if (descriptorTag == TS_PMT_DESC_AC3) { // AC-3_descriptor in DVB (ETSI EN 300 468) + streamType = TS_STREAM_TYPE_AC3; + } else if (descriptorTag == TS_PMT_DESC_EAC3) { // enhanced_AC-3_descriptor + streamType = TS_STREAM_TYPE_E_AC3; + } else if (descriptorTag == TS_PMT_DESC_DVB_EXT) { + // Extension descriptor in DVB (ETSI EN 300 468). + int descriptorTagExt = data.readUnsignedByte(); + if (descriptorTagExt == TS_PMT_DESC_DVB_EXT_AC4) { + // AC-4_descriptor in DVB (ETSI EN 300 468). + streamType = TS_STREAM_TYPE_AC4; + } + } else if (descriptorTag == TS_PMT_DESC_DTS) { // DTS_descriptor + streamType = TS_STREAM_TYPE_DTS; + } else if (descriptorTag == TS_PMT_DESC_ISO639_LANG) { + language = data.readString(3).trim(); + // Audio type is ignored. + } else if (descriptorTag == TS_PMT_DESC_DVBSUBS) { + streamType = TS_STREAM_TYPE_DVBSUBS; + dvbSubtitleInfos = new ArrayList<>(); + while (data.getPosition() < positionOfNextDescriptor) { + String dvbLanguage = data.readString(3).trim(); + int dvbSubtitlingType = data.readUnsignedByte(); + byte[] initializationData = new byte[4]; + data.readBytes(initializationData, 0, 4); + dvbSubtitleInfos.add(new DvbSubtitleInfo(dvbLanguage, dvbSubtitlingType, + initializationData)); + } + } + // Skip unused bytes of current descriptor. + data.skipBytes(positionOfNextDescriptor - data.getPosition()); + } + data.setPosition(descriptorsEndPosition); + return new EsInfo(streamType, language, dvbSubtitleInfos, + Arrays.copyOfRange(data.data, descriptorsStartPosition, descriptorsEndPosition)); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java new file mode 100644 index 0000000000..940c1c7937 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsPayloadReader.java @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import android.util.SparseArray; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; + +/** + * Parses TS packet payload data. + */ +public interface TsPayloadReader { + + /** + * Factory of {@link TsPayloadReader} instances. + */ + interface Factory { + + /** + * Returns the initial mapping from PIDs to payload readers. + * <p> + * This method allows the injection of payload readers for reserved PIDs, excluding PID 0. + * + * @return A {@link SparseArray} that maps PIDs to payload readers. + */ + SparseArray<TsPayloadReader> createInitialPayloadReaders(); + + /** + * Returns a {@link TsPayloadReader} for a given stream type and elementary stream information. + * May return null if the stream type is not supported. + * + * @param streamType Stream type value as defined in the PMT entry or associated descriptors. + * @param esInfo Information associated to the elementary stream provided in the PMT. + * @return A {@link TsPayloadReader} for the packet stream carried by the provided pid. + * {@code null} if the stream is not supported. + */ + TsPayloadReader createPayloadReader(int streamType, EsInfo esInfo); + + } + + /** + * Holds information associated with a PMT entry. + */ + final class EsInfo { + + public final int streamType; + public final String language; + public final List<DvbSubtitleInfo> dvbSubtitleInfos; + public final byte[] descriptorBytes; + + /** + * @param streamType The type of the stream as defined by the + * {@link TsExtractor}{@code .TS_STREAM_TYPE_*}. + * @param language The language of the stream, as defined by ISO/IEC 13818-1, section 2.6.18. + * @param dvbSubtitleInfos Information about DVB subtitles associated to the stream. + * @param descriptorBytes The descriptor bytes associated to the stream. + */ + public EsInfo(int streamType, String language, List<DvbSubtitleInfo> dvbSubtitleInfos, + byte[] descriptorBytes) { + this.streamType = streamType; + this.language = language; + this.dvbSubtitleInfos = + dvbSubtitleInfos == null + ? Collections.emptyList() + : Collections.unmodifiableList(dvbSubtitleInfos); + this.descriptorBytes = descriptorBytes; + } + + } + + /** + * Holds information about a DVB subtitle, as defined in ETSI EN 300 468 V1.11.1 section 6.2.41. + */ + final class DvbSubtitleInfo { + + public final String language; + public final int type; + public final byte[] initializationData; + + /** + * @param language The ISO 639-2 three-letter language code. + * @param type The subtitling type. + * @param initializationData The composition and ancillary page ids. + */ + public DvbSubtitleInfo(String language, int type, byte[] initializationData) { + this.language = language; + this.type = type; + this.initializationData = initializationData; + } + + } + + /** + * Generates track ids for initializing {@link TsPayloadReader}s' {@link TrackOutput}s. + */ + final class TrackIdGenerator { + + private static final int ID_UNSET = Integer.MIN_VALUE; + + private final String formatIdPrefix; + private final int firstTrackId; + private final int trackIdIncrement; + private int trackId; + private String formatId; + + public TrackIdGenerator(int firstTrackId, int trackIdIncrement) { + this(ID_UNSET, firstTrackId, trackIdIncrement); + } + + public TrackIdGenerator(int programNumber, int firstTrackId, int trackIdIncrement) { + this.formatIdPrefix = programNumber != ID_UNSET ? programNumber + "/" : ""; + this.firstTrackId = firstTrackId; + this.trackIdIncrement = trackIdIncrement; + trackId = ID_UNSET; + } + + /** + * Generates a new set of track and track format ids. Must be called before {@code get*} + * methods. + */ + public void generateNewId() { + trackId = trackId == ID_UNSET ? firstTrackId : trackId + trackIdIncrement; + formatId = formatIdPrefix + trackId; + } + + /** + * Returns the last generated track id. Must be called after the first {@link #generateNewId()} + * call. + * + * @return The last generated track id. + */ + public int getTrackId() { + maybeThrowUninitializedError(); + return trackId; + } + + /** + * Returns the last generated format id, with the format {@code "programNumber/trackId"}. If no + * {@code programNumber} was provided, the {@code trackId} alone is used as format id. Must be + * called after the first {@link #generateNewId()} call. + * + * @return The last generated format id, with the format {@code "programNumber/trackId"}. If no + * {@code programNumber} was provided, the {@code trackId} alone is used as + * format id. + */ + public String getFormatId() { + maybeThrowUninitializedError(); + return formatId; + } + + private void maybeThrowUninitializedError() { + if (trackId == ID_UNSET) { + throw new IllegalStateException("generateNewId() must be called before retrieving ids."); + } + } + + } + + /** + * Contextual flags indicating the presence of indicators in the TS packet or PES packet headers. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FLAG_PAYLOAD_UNIT_START_INDICATOR, + FLAG_RANDOM_ACCESS_INDICATOR, + FLAG_DATA_ALIGNMENT_INDICATOR + }) + @interface Flags {} + + /** Indicates the presence of the payload_unit_start_indicator in the TS packet header. */ + int FLAG_PAYLOAD_UNIT_START_INDICATOR = 1; + /** + * Indicates the presence of the random_access_indicator in the TS packet header adaptation field. + */ + int FLAG_RANDOM_ACCESS_INDICATOR = 1 << 1; + /** Indicates the presence of the data_alignment_indicator in the PES header. */ + int FLAG_DATA_ALIGNMENT_INDICATOR = 1 << 2; + + /** + * Initializes the payload reader. + * + * @param timestampAdjuster A timestamp adjuster for offsetting and scaling sample timestamps. + * @param extractorOutput The {@link ExtractorOutput} that receives the extracted data. + * @param idGenerator A {@link PesReader.TrackIdGenerator} that generates unique track ids for the + * {@link TrackOutput}s. + */ + void init(TimestampAdjuster timestampAdjuster, ExtractorOutput extractorOutput, + TrackIdGenerator idGenerator); + + /** + * Notifies the reader that a seek has occurred. + * + * <p>Following a call to this method, the data passed to the next invocation of {@link #consume} + * will not be a continuation of the data that was previously passed. Hence the reader should + * reset any internal state. + */ + void seek(); + + /** + * Consumes the payload of a TS packet. + * + * @param data The TS packet. The position will be set to the start of the payload. + * @param flags See {@link Flags}. + * @throws ParserException If the payload could not be parsed. + */ + void consume(ParsableByteArray data, @Flags int flags) throws ParserException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsUtil.java new file mode 100644 index 0000000000..8cd24ff1e9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/TsUtil.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** Utilities method for extracting MPEG-TS streams. */ +public final class TsUtil { + /** + * Returns the position of the first TS_SYNC_BYTE within the range [startPosition, limitPosition) + * from the provided data array, or returns limitPosition if sync byte could not be found. + */ + public static int findSyncBytePosition(byte[] data, int startPosition, int limitPosition) { + int position = startPosition; + while (position < limitPosition && data[position] != TsExtractor.TS_SYNC_BYTE) { + position++; + } + return position; + } + + /** + * Returns the PCR value read from a given TS packet. + * + * @param packetBuffer The buffer that holds the packet. + * @param startOfPacket The starting position of the packet in the buffer. + * @param pcrPid The PID for valid packets that contain PCR values. + * @return The PCR value read from the packet, if its PID is equal to {@code pcrPid} and it + * contains a valid PCR value. Returns {@link C#TIME_UNSET} otherwise. + */ + public static long readPcrFromPacket( + ParsableByteArray packetBuffer, int startOfPacket, int pcrPid) { + packetBuffer.setPosition(startOfPacket); + if (packetBuffer.bytesLeft() < 5) { + // Header = 4 bytes, adaptationFieldLength = 1 byte. + return C.TIME_UNSET; + } + // Note: See ISO/IEC 13818-1, section 2.4.3.2 for details of the header format. + int tsPacketHeader = packetBuffer.readInt(); + if ((tsPacketHeader & 0x800000) != 0) { + // transport_error_indicator != 0 means there are uncorrectable errors in this packet. + return C.TIME_UNSET; + } + int pid = (tsPacketHeader & 0x1FFF00) >> 8; + if (pid != pcrPid) { + return C.TIME_UNSET; + } + boolean adaptationFieldExists = (tsPacketHeader & 0x20) != 0; + if (!adaptationFieldExists) { + return C.TIME_UNSET; + } + + int adaptationFieldLength = packetBuffer.readUnsignedByte(); + if (adaptationFieldLength >= 7 && packetBuffer.bytesLeft() >= 7) { + int flags = packetBuffer.readUnsignedByte(); + boolean pcrFlagSet = (flags & 0x10) == 0x10; + if (pcrFlagSet) { + byte[] pcrBytes = new byte[6]; + packetBuffer.readBytes(pcrBytes, /* offset= */ 0, pcrBytes.length); + return readPcrValueFromPcrBytes(pcrBytes); + } + } + return C.TIME_UNSET; + } + + /** + * Returns the value of PCR base - first 33 bits in big endian order from the PCR bytes. + * + * <p>We ignore PCR Ext, because it's too small to have any significance. + */ + private static long readPcrValueFromPcrBytes(byte[] pcrBytes) { + return (pcrBytes[0] & 0xFFL) << 25 + | (pcrBytes[1] & 0xFFL) << 17 + | (pcrBytes[2] & 0xFFL) << 9 + | (pcrBytes[3] & 0xFFL) << 1 + | (pcrBytes[4] & 0xFFL) >> 7; + } + + private TsUtil() { + // Prevent instantiation. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/UserDataReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/UserDataReader.java new file mode 100644 index 0000000000..fb56fe379c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/ts/UserDataReader.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.CeaUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.List; + +/** Consumes user data, outputting contained CEA-608/708 messages to a {@link TrackOutput}. */ +/* package */ final class UserDataReader { + + private static final int USER_DATA_START_CODE = 0x0001B2; + + private final List<Format> closedCaptionFormats; + private final TrackOutput[] outputs; + + public UserDataReader(List<Format> closedCaptionFormats) { + this.closedCaptionFormats = closedCaptionFormats; + outputs = new TrackOutput[closedCaptionFormats.size()]; + } + + public void createTracks( + ExtractorOutput extractorOutput, TsPayloadReader.TrackIdGenerator idGenerator) { + for (int i = 0; i < outputs.length; i++) { + idGenerator.generateNewId(); + TrackOutput output = extractorOutput.track(idGenerator.getTrackId(), C.TRACK_TYPE_TEXT); + Format channelFormat = closedCaptionFormats.get(i); + String channelMimeType = channelFormat.sampleMimeType; + Assertions.checkArgument( + MimeTypes.APPLICATION_CEA608.equals(channelMimeType) + || MimeTypes.APPLICATION_CEA708.equals(channelMimeType), + "Invalid closed caption mime type provided: " + channelMimeType); + output.format( + Format.createTextSampleFormat( + idGenerator.getFormatId(), + channelMimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + channelFormat.selectionFlags, + channelFormat.language, + channelFormat.accessibilityChannel, + /* drmInitData= */ null, + Format.OFFSET_SAMPLE_RELATIVE, + channelFormat.initializationData)); + outputs[i] = output; + } + } + + public void consume(long pesTimeUs, ParsableByteArray userDataPayload) { + if (userDataPayload.bytesLeft() < 9) { + return; + } + int userDataStartCode = userDataPayload.readInt(); + int userDataIdentifier = userDataPayload.readInt(); + int userDataTypeCode = userDataPayload.readUnsignedByte(); + if (userDataStartCode == USER_DATA_START_CODE + && userDataIdentifier == CeaUtil.USER_DATA_IDENTIFIER_GA94 + && userDataTypeCode == CeaUtil.USER_DATA_TYPE_CODE_MPEG_CC) { + CeaUtil.consumeCcData(pesTimeUs, userDataPayload, outputs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavExtractor.java new file mode 100644 index 0000000000..d4ac3ef8e1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavExtractor.java @@ -0,0 +1,562 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.wav; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.WavUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Extracts data from WAV byte streams. + */ +public final class WavExtractor implements Extractor { + + /** + * When outputting PCM data to a {@link TrackOutput}, we can choose how many frames are grouped + * into each sample, and hence each sample's duration. This is the target number of samples to + * output for each second of media, meaning that each sample will have a duration of ~100ms. + */ + private static final int TARGET_SAMPLES_PER_SECOND = 10; + + /** Factory for {@link WavExtractor} instances. */ + public static final ExtractorsFactory FACTORY = () -> new Extractor[] {new WavExtractor()}; + + @MonotonicNonNull private ExtractorOutput extractorOutput; + @MonotonicNonNull private TrackOutput trackOutput; + @MonotonicNonNull private OutputWriter outputWriter; + private int dataStartPosition; + private long dataEndPosition; + + public WavExtractor() { + dataStartPosition = C.POSITION_UNSET; + dataEndPosition = C.POSITION_UNSET; + } + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + return WavHeaderReader.peek(input) != null; + } + + @Override + public void init(ExtractorOutput output) { + extractorOutput = output; + trackOutput = output.track(0, C.TRACK_TYPE_AUDIO); + output.endTracks(); + } + + @Override + public void seek(long position, long timeUs) { + if (outputWriter != null) { + outputWriter.reset(timeUs); + } + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + assertInitialized(); + if (outputWriter == null) { + WavHeader header = WavHeaderReader.peek(input); + if (header == null) { + // Should only happen if the media wasn't sniffed. + throw new ParserException("Unsupported or unrecognized wav header."); + } + + if (header.formatType == WavUtil.TYPE_IMA_ADPCM) { + outputWriter = new ImaAdPcmOutputWriter(extractorOutput, trackOutput, header); + } else if (header.formatType == WavUtil.TYPE_ALAW) { + outputWriter = + new PassthroughOutputWriter( + extractorOutput, + trackOutput, + header, + MimeTypes.AUDIO_ALAW, + /* pcmEncoding= */ Format.NO_VALUE); + } else if (header.formatType == WavUtil.TYPE_MLAW) { + outputWriter = + new PassthroughOutputWriter( + extractorOutput, + trackOutput, + header, + MimeTypes.AUDIO_MLAW, + /* pcmEncoding= */ Format.NO_VALUE); + } else { + @C.PcmEncoding + int pcmEncoding = WavUtil.getPcmEncodingForType(header.formatType, header.bitsPerSample); + if (pcmEncoding == C.ENCODING_INVALID) { + throw new ParserException("Unsupported WAV format type: " + header.formatType); + } + outputWriter = + new PassthroughOutputWriter( + extractorOutput, trackOutput, header, MimeTypes.AUDIO_RAW, pcmEncoding); + } + } + + if (dataStartPosition == C.POSITION_UNSET) { + Pair<Long, Long> dataBounds = WavHeaderReader.skipToData(input); + dataStartPosition = dataBounds.first.intValue(); + dataEndPosition = dataBounds.second; + outputWriter.init(dataStartPosition, dataEndPosition); + } else if (input.getPosition() == 0) { + input.skipFully(dataStartPosition); + } + + Assertions.checkState(dataEndPosition != C.POSITION_UNSET); + long bytesLeft = dataEndPosition - input.getPosition(); + return outputWriter.sampleData(input, bytesLeft) ? RESULT_END_OF_INPUT : RESULT_CONTINUE; + } + + @EnsuresNonNull({"extractorOutput", "trackOutput"}) + private void assertInitialized() { + Assertions.checkStateNotNull(trackOutput); + Util.castNonNull(extractorOutput); + } + + /** Writes to the extractor's output. */ + private interface OutputWriter { + + /** + * Resets the writer. + * + * @param timeUs The new start position in microseconds. + */ + void reset(long timeUs); + + /** + * Initializes the writer. + * + * <p>Must be called once, before any calls to {@link #sampleData(ExtractorInput, long)}. + * + * @param dataStartPosition The byte position (inclusive) in the stream at which data starts. + * @param dataEndPosition The end position (exclusive) in the stream at which data ends. + * @throws ParserException If an error occurs initializing the writer. + */ + void init(int dataStartPosition, long dataEndPosition) throws ParserException; + + /** + * Consumes sample data from {@code input}, writing corresponding samples to the extractor's + * output. + * + * <p>Must not be called until after {@link #init(int, long)} has been called. + * + * @param input The input from which to read. + * @param bytesLeft The number of sample data bytes left to be read from the input. + * @return Whether the end of the sample data has been reached. + * @throws IOException If an error occurs reading from the input. + * @throws InterruptedException If the thread has been interrupted. + */ + boolean sampleData(ExtractorInput input, long bytesLeft) + throws IOException, InterruptedException; + } + + private static final class PassthroughOutputWriter implements OutputWriter { + + private final ExtractorOutput extractorOutput; + private final TrackOutput trackOutput; + private final WavHeader header; + private final Format format; + /** The target size of each output sample, in bytes. */ + private final int targetSampleSizeBytes; + + /** The time at which the writer was last {@link #reset}. */ + private long startTimeUs; + /** + * The number of bytes that have been written to {@link #trackOutput} but have yet to be + * included as part of a sample (i.e. the corresponding call to {@link + * TrackOutput#sampleMetadata} has yet to be made). + */ + private int pendingOutputBytes; + /** + * The total number of frames in samples that have been written to the trackOutput since the + * last call to {@link #reset}. + */ + private long outputFrameCount; + + public PassthroughOutputWriter( + ExtractorOutput extractorOutput, + TrackOutput trackOutput, + WavHeader header, + String mimeType, + @C.PcmEncoding int pcmEncoding) + throws ParserException { + this.extractorOutput = extractorOutput; + this.trackOutput = trackOutput; + this.header = header; + + int bytesPerFrame = header.numChannels * header.bitsPerSample / 8; + // Validate the header. Blocks are expected to correspond to single frames. + if (header.blockSize != bytesPerFrame) { + throw new ParserException( + "Expected block size: " + bytesPerFrame + "; got: " + header.blockSize); + } + + targetSampleSizeBytes = + Math.max(bytesPerFrame, header.frameRateHz * bytesPerFrame / TARGET_SAMPLES_PER_SECOND); + format = + Format.createAudioSampleFormat( + /* id= */ null, + mimeType, + /* codecs= */ null, + /* bitrate= */ header.frameRateHz * bytesPerFrame * 8, + /* maxInputSize= */ targetSampleSizeBytes, + header.numChannels, + header.frameRateHz, + pcmEncoding, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + } + + @Override + public void reset(long timeUs) { + startTimeUs = timeUs; + pendingOutputBytes = 0; + outputFrameCount = 0; + } + + @Override + public void init(int dataStartPosition, long dataEndPosition) { + extractorOutput.seekMap( + new WavSeekMap(header, /* framesPerBlock= */ 1, dataStartPosition, dataEndPosition)); + trackOutput.format(format); + } + + @Override + public boolean sampleData(ExtractorInput input, long bytesLeft) + throws IOException, InterruptedException { + // Write sample data until we've reached the target sample size, or the end of the data. + while (bytesLeft > 0 && pendingOutputBytes < targetSampleSizeBytes) { + int bytesToRead = (int) Math.min(targetSampleSizeBytes - pendingOutputBytes, bytesLeft); + int bytesAppended = trackOutput.sampleData(input, bytesToRead, true); + if (bytesAppended == RESULT_END_OF_INPUT) { + bytesLeft = 0; + } else { + pendingOutputBytes += bytesAppended; + bytesLeft -= bytesAppended; + } + } + + // Write the corresponding sample metadata. Samples must be a whole number of frames. It's + // possible that the number of pending output bytes is not a whole number of frames if the + // stream ended unexpectedly. + int bytesPerFrame = header.blockSize; + int pendingFrames = pendingOutputBytes / bytesPerFrame; + if (pendingFrames > 0) { + long timeUs = + startTimeUs + + Util.scaleLargeTimestamp( + outputFrameCount, C.MICROS_PER_SECOND, header.frameRateHz); + int size = pendingFrames * bytesPerFrame; + int offset = pendingOutputBytes - size; + trackOutput.sampleMetadata( + timeUs, C.BUFFER_FLAG_KEY_FRAME, size, offset, /* encryptionData= */ null); + outputFrameCount += pendingFrames; + pendingOutputBytes = offset; + } + + return bytesLeft <= 0; + } + } + + private static final class ImaAdPcmOutputWriter implements OutputWriter { + + private static final int[] INDEX_TABLE = { + -1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8 + }; + + private static final int[] STEP_TABLE = { + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, + 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, + 449, 494, 544, 598, 658, 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, + 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, + 9493, 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, + 32767 + }; + + private final ExtractorOutput extractorOutput; + private final TrackOutput trackOutput; + private final WavHeader header; + + /** Number of frames per block of the input (yet to be decoded) data. */ + private final int framesPerBlock; + /** Target for the input (yet to be decoded) data. */ + private final byte[] inputData; + /** Target for decoded (yet to be output) data. */ + private final ParsableByteArray decodedData; + /** The target size of each output sample, in frames. */ + private final int targetSampleSizeFrames; + /** The output format. */ + private final Format format; + + /** The number of pending bytes in {@link #inputData}. */ + private int pendingInputBytes; + /** The time at which the writer was last {@link #reset}. */ + private long startTimeUs; + /** + * The number of bytes that have been written to {@link #trackOutput} but have yet to be + * included as part of a sample (i.e. the corresponding call to {@link + * TrackOutput#sampleMetadata} has yet to be made). + */ + private int pendingOutputBytes; + /** + * The total number of frames in samples that have been written to the trackOutput since the + * last call to {@link #reset}. + */ + private long outputFrameCount; + + public ImaAdPcmOutputWriter( + ExtractorOutput extractorOutput, TrackOutput trackOutput, WavHeader header) + throws ParserException { + this.extractorOutput = extractorOutput; + this.trackOutput = trackOutput; + this.header = header; + targetSampleSizeFrames = Math.max(1, header.frameRateHz / TARGET_SAMPLES_PER_SECOND); + + ParsableByteArray scratch = new ParsableByteArray(header.extraData); + scratch.readLittleEndianUnsignedShort(); + framesPerBlock = scratch.readLittleEndianUnsignedShort(); + + int numChannels = header.numChannels; + // Validate the header. This calculation is defined in "Microsoft Multimedia Standards Update + // - New Multimedia Types and Data Techniques" (1994). See the "IMA ADPCM Wave Type" and "DVI + // ADPCM Wave Type" sections, and the calculation of wSamplesPerBlock in the latter. + int expectedFramesPerBlock = + (((header.blockSize - (4 * numChannels)) * 8) / (header.bitsPerSample * numChannels)) + 1; + if (framesPerBlock != expectedFramesPerBlock) { + throw new ParserException( + "Expected frames per block: " + expectedFramesPerBlock + "; got: " + framesPerBlock); + } + + // Calculate the number of blocks we'll need to decode to obtain an output sample of the + // target sample size, and allocate suitably sized buffers for input and decoded data. + int maxBlocksToDecode = Util.ceilDivide(targetSampleSizeFrames, framesPerBlock); + inputData = new byte[maxBlocksToDecode * header.blockSize]; + decodedData = + new ParsableByteArray( + maxBlocksToDecode * numOutputFramesToBytes(framesPerBlock, numChannels)); + + // Create the format. We calculate the bitrate of the data before decoding, since this is the + // bitrate of the stream itself. + int bitrate = header.frameRateHz * header.blockSize * 8 / framesPerBlock; + format = + Format.createAudioSampleFormat( + /* id= */ null, + MimeTypes.AUDIO_RAW, + /* codecs= */ null, + bitrate, + /* maxInputSize= */ numOutputFramesToBytes(targetSampleSizeFrames, numChannels), + header.numChannels, + header.frameRateHz, + C.ENCODING_PCM_16BIT, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + } + + @Override + public void reset(long timeUs) { + pendingInputBytes = 0; + startTimeUs = timeUs; + pendingOutputBytes = 0; + outputFrameCount = 0; + } + + @Override + public void init(int dataStartPosition, long dataEndPosition) { + extractorOutput.seekMap( + new WavSeekMap(header, framesPerBlock, dataStartPosition, dataEndPosition)); + trackOutput.format(format); + } + + @Override + public boolean sampleData(ExtractorInput input, long bytesLeft) + throws IOException, InterruptedException { + // Calculate the number of additional frames that we need on the output side to complete a + // sample of the target size. + int targetFramesRemaining = + targetSampleSizeFrames - numOutputBytesToFrames(pendingOutputBytes); + // Calculate the whole number of blocks that we need to decode to obtain this many frames. + int blocksToDecode = Util.ceilDivide(targetFramesRemaining, framesPerBlock); + int targetReadBytes = blocksToDecode * header.blockSize; + + // Read input data until we've reached the target number of blocks, or the end of the data. + boolean endOfSampleData = bytesLeft == 0; + while (!endOfSampleData && pendingInputBytes < targetReadBytes) { + int bytesToRead = (int) Math.min(targetReadBytes - pendingInputBytes, bytesLeft); + int bytesAppended = input.read(inputData, pendingInputBytes, bytesToRead); + if (bytesAppended == RESULT_END_OF_INPUT) { + endOfSampleData = true; + } else { + pendingInputBytes += bytesAppended; + } + } + + int pendingBlockCount = pendingInputBytes / header.blockSize; + if (pendingBlockCount > 0) { + // We have at least one whole block to decode. + decode(inputData, pendingBlockCount, decodedData); + pendingInputBytes -= pendingBlockCount * header.blockSize; + + // Write all of the decoded data to the track output. + int decodedDataSize = decodedData.limit(); + trackOutput.sampleData(decodedData, decodedDataSize); + pendingOutputBytes += decodedDataSize; + + // Output the next sample at the target size. + int pendingOutputFrames = numOutputBytesToFrames(pendingOutputBytes); + if (pendingOutputFrames >= targetSampleSizeFrames) { + writeSampleMetadata(targetSampleSizeFrames); + } + } + + // If we've reached the end of the data, we might need to output a final partial sample. + if (endOfSampleData) { + int pendingOutputFrames = numOutputBytesToFrames(pendingOutputBytes); + if (pendingOutputFrames > 0) { + writeSampleMetadata(pendingOutputFrames); + } + } + + return endOfSampleData; + } + + private void writeSampleMetadata(int sampleFrames) { + long timeUs = + startTimeUs + + Util.scaleLargeTimestamp(outputFrameCount, C.MICROS_PER_SECOND, header.frameRateHz); + int size = numOutputFramesToBytes(sampleFrames); + int offset = pendingOutputBytes - size; + trackOutput.sampleMetadata( + timeUs, C.BUFFER_FLAG_KEY_FRAME, size, offset, /* encryptionData= */ null); + outputFrameCount += sampleFrames; + pendingOutputBytes -= size; + } + + /** + * Decodes IMA ADPCM data to 16 bit PCM. + * + * @param input The input data to decode. + * @param blockCount The number of blocks to decode. + * @param output The output into which the decoded data will be written. + */ + private void decode(byte[] input, int blockCount, ParsableByteArray output) { + for (int blockIndex = 0; blockIndex < blockCount; blockIndex++) { + for (int channelIndex = 0; channelIndex < header.numChannels; channelIndex++) { + decodeBlockForChannel(input, blockIndex, channelIndex, output.data); + } + } + int decodedDataSize = numOutputFramesToBytes(framesPerBlock * blockCount); + output.reset(decodedDataSize); + } + + private void decodeBlockForChannel( + byte[] input, int blockIndex, int channelIndex, byte[] output) { + int blockSize = header.blockSize; + int numChannels = header.numChannels; + + // The input data consists for a four byte header [Ci] for each of the N channels, followed + // by interleaved data segments [Ci-DATAj], each of which are four bytes long. + // + // [C1][C2]...[CN] [C1-Data0][C2-Data0]...[CN-Data0] [C1-Data1][C2-Data1]...[CN-Data1] etc + // + // Compute the start indices for the [Ci] and [Ci-Data0] for the current channel, as well as + // the number of data bytes for the channel in the block. + int blockStartIndex = blockIndex * blockSize; + int headerStartIndex = blockStartIndex + channelIndex * 4; + int dataStartIndex = headerStartIndex + numChannels * 4; + int dataSizeBytes = blockSize / numChannels - 4; + + // Decode initialization. Casting to a short is necessary for the most significant bit to be + // treated as -2^15 rather than 2^15. + int predictedSample = + (short) (((input[headerStartIndex + 1] & 0xFF) << 8) | (input[headerStartIndex] & 0xFF)); + int stepIndex = Math.min(input[headerStartIndex + 2] & 0xFF, 88); + int step = STEP_TABLE[stepIndex]; + + // Output the initial 16 bit PCM sample from the header. + int outputIndex = (blockIndex * framesPerBlock * numChannels + channelIndex) * 2; + output[outputIndex] = (byte) (predictedSample & 0xFF); + output[outputIndex + 1] = (byte) (predictedSample >> 8); + + // We examine each data byte twice during decode. + for (int i = 0; i < dataSizeBytes * 2; i++) { + int dataSegmentIndex = i / 8; + int dataSegmentOffset = (i / 2) % 4; + int dataIndex = dataStartIndex + (dataSegmentIndex * numChannels * 4) + dataSegmentOffset; + + int originalSample = input[dataIndex] & 0xFF; + if (i % 2 == 0) { + originalSample &= 0x0F; // Bottom four bits. + } else { + originalSample >>= 4; // Top four bits. + } + + int delta = originalSample & 0x07; + int difference = ((2 * delta + 1) * step) >> 3; + + if ((originalSample & 0x08) != 0) { + difference = -difference; + } + + predictedSample += difference; + predictedSample = Util.constrainValue(predictedSample, /* min= */ -32768, /* max= */ 32767); + + // Output the next 16 bit PCM sample to the correct position in the output. + outputIndex += 2 * numChannels; + output[outputIndex] = (byte) (predictedSample & 0xFF); + output[outputIndex + 1] = (byte) (predictedSample >> 8); + + stepIndex += INDEX_TABLE[originalSample]; + stepIndex = Util.constrainValue(stepIndex, /* min= */ 0, /* max= */ STEP_TABLE.length - 1); + step = STEP_TABLE[stepIndex]; + } + } + + private int numOutputBytesToFrames(int bytes) { + return bytes / (2 * header.numChannels); + } + + private int numOutputFramesToBytes(int frames) { + return numOutputFramesToBytes(frames, header.numChannels); + } + + private static int numOutputFramesToBytes(int frames, int numChannels) { + return frames * 2 * numChannels; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavHeader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavHeader.java new file mode 100644 index 0000000000..bc6cf8999b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavHeader.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.wav; + +/** Header for a WAV file. */ +/* package */ final class WavHeader { + + /** + * The format type. Standard format types are the "WAVE form Registration Number" constants + * defined in RFC 2361 Appendix A. + */ + public final int formatType; + /** The number of channels. */ + public final int numChannels; + /** The sample rate in Hertz. */ + public final int frameRateHz; + /** The average bytes per second for the sample data. */ + public final int averageBytesPerSecond; + /** The block size in bytes. */ + public final int blockSize; + /** Bits per sample for a single channel. */ + public final int bitsPerSample; + /** Extra data appended to the format chunk of the header. */ + public final byte[] extraData; + + public WavHeader( + int formatType, + int numChannels, + int frameRateHz, + int averageBytesPerSecond, + int blockSize, + int bitsPerSample, + byte[] extraData) { + this.formatType = formatType; + this.numChannels = numChannels; + this.frameRateHz = frameRateHz; + this.averageBytesPerSecond = averageBytesPerSecond; + this.blockSize = blockSize; + this.bitsPerSample = bitsPerSample; + this.extraData = extraData; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java new file mode 100644 index 0000000000..1c36aaa3c3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavHeaderReader.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.wav; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.WavUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** Reads a {@code WavHeader} from an input stream; supports resuming from input failures. */ +/* package */ final class WavHeaderReader { + + private static final String TAG = "WavHeaderReader"; + + /** + * Peeks and returns a {@code WavHeader}. + * + * @param input Input stream to peek the WAV header from. + * @throws ParserException If the input file is an incorrect RIFF WAV. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + * @return A new {@code WavHeader} peeked from {@code input}, or null if the input is not a + * supported WAV format. + */ + @Nullable + public static WavHeader peek(ExtractorInput input) throws IOException, InterruptedException { + Assertions.checkNotNull(input); + + // Allocate a scratch buffer large enough to store the format chunk. + ParsableByteArray scratch = new ParsableByteArray(16); + + // Attempt to read the RIFF chunk. + ChunkHeader chunkHeader = ChunkHeader.peek(input, scratch); + if (chunkHeader.id != WavUtil.RIFF_FOURCC) { + return null; + } + + input.peekFully(scratch.data, 0, 4); + scratch.setPosition(0); + int riffFormat = scratch.readInt(); + if (riffFormat != WavUtil.WAVE_FOURCC) { + Log.e(TAG, "Unsupported RIFF format: " + riffFormat); + return null; + } + + // Skip chunks until we find the format chunk. + chunkHeader = ChunkHeader.peek(input, scratch); + while (chunkHeader.id != WavUtil.FMT_FOURCC) { + input.advancePeekPosition((int) chunkHeader.size); + chunkHeader = ChunkHeader.peek(input, scratch); + } + + Assertions.checkState(chunkHeader.size >= 16); + input.peekFully(scratch.data, 0, 16); + scratch.setPosition(0); + int audioFormatType = scratch.readLittleEndianUnsignedShort(); + int numChannels = scratch.readLittleEndianUnsignedShort(); + int frameRateHz = scratch.readLittleEndianUnsignedIntToInt(); + int averageBytesPerSecond = scratch.readLittleEndianUnsignedIntToInt(); + int blockSize = scratch.readLittleEndianUnsignedShort(); + int bitsPerSample = scratch.readLittleEndianUnsignedShort(); + + int bytesLeft = (int) chunkHeader.size - 16; + byte[] extraData; + if (bytesLeft > 0) { + extraData = new byte[bytesLeft]; + input.peekFully(extraData, 0, bytesLeft); + } else { + extraData = Util.EMPTY_BYTE_ARRAY; + } + + return new WavHeader( + audioFormatType, + numChannels, + frameRateHz, + averageBytesPerSecond, + blockSize, + bitsPerSample, + extraData); + } + + /** + * Skips to the data in the given WAV input stream, and returns its bounds. After calling, the + * input stream's position will point to the start of sample data in the WAV. If an exception is + * thrown, the input position will be left pointing to a chunk header. + * + * @param input The input stream, whose read position must be pointing to a valid chunk header. + * @return The byte positions at which the data starts (inclusive) and ends (exclusive). + * @throws ParserException If an error occurs parsing chunks. + * @throws IOException If reading from the input fails. + * @throws InterruptedException If interrupted while reading from input. + */ + public static Pair<Long, Long> skipToData(ExtractorInput input) + throws IOException, InterruptedException { + Assertions.checkNotNull(input); + + // Make sure the peek position is set to the read position before we peek the first header. + input.resetPeekPosition(); + + ParsableByteArray scratch = new ParsableByteArray(ChunkHeader.SIZE_IN_BYTES); + // Skip all chunks until we hit the data header. + ChunkHeader chunkHeader = ChunkHeader.peek(input, scratch); + while (chunkHeader.id != WavUtil.DATA_FOURCC) { + if (chunkHeader.id != WavUtil.RIFF_FOURCC && chunkHeader.id != WavUtil.FMT_FOURCC) { + Log.w(TAG, "Ignoring unknown WAV chunk: " + chunkHeader.id); + } + long bytesToSkip = ChunkHeader.SIZE_IN_BYTES + chunkHeader.size; + // Override size of RIFF chunk, since it describes its size as the entire file. + if (chunkHeader.id == WavUtil.RIFF_FOURCC) { + bytesToSkip = ChunkHeader.SIZE_IN_BYTES + 4; + } + if (bytesToSkip > Integer.MAX_VALUE) { + throw new ParserException("Chunk is too large (~2GB+) to skip; id: " + chunkHeader.id); + } + input.skipFully((int) bytesToSkip); + chunkHeader = ChunkHeader.peek(input, scratch); + } + // Skip past the "data" header. + input.skipFully(ChunkHeader.SIZE_IN_BYTES); + + long dataStartPosition = input.getPosition(); + long dataEndPosition = dataStartPosition + chunkHeader.size; + long inputLength = input.getLength(); + if (inputLength != C.LENGTH_UNSET && dataEndPosition > inputLength) { + Log.w(TAG, "Data exceeds input length: " + dataEndPosition + ", " + inputLength); + dataEndPosition = inputLength; + } + return Pair.create(dataStartPosition, dataEndPosition); + } + + private WavHeaderReader() { + // Prevent instantiation. + } + + /** Container for a WAV chunk header. */ + private static final class ChunkHeader { + + /** Size in bytes of a WAV chunk header. */ + public static final int SIZE_IN_BYTES = 8; + + /** 4-character identifier, stored as an integer, for this chunk. */ + public final int id; + /** Size of this chunk in bytes. */ + public final long size; + + private ChunkHeader(int id, long size) { + this.id = id; + this.size = size; + } + + /** + * Peeks and returns a {@link ChunkHeader}. + * + * @param input Input stream to peek the chunk header from. + * @param scratch Buffer for temporary use. + * @throws IOException If peeking from the input fails. + * @throws InterruptedException If interrupted while peeking from input. + * @return A new {@code ChunkHeader} peeked from {@code input}. + */ + public static ChunkHeader peek(ExtractorInput input, ParsableByteArray scratch) + throws IOException, InterruptedException { + input.peekFully(scratch.data, /* offset= */ 0, /* length= */ SIZE_IN_BYTES); + scratch.setPosition(0); + + int id = scratch.readInt(); + long size = scratch.readLittleEndianUnsignedInt(); + + return new ChunkHeader(id, size); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavSeekMap.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavSeekMap.java new file mode 100644 index 0000000000..d14268d120 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/extractor/wav/WavSeekMap.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.wav; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekPoint; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/* package */ final class WavSeekMap implements SeekMap { + + private final WavHeader wavHeader; + private final int framesPerBlock; + private final long firstBlockPosition; + private final long blockCount; + private final long durationUs; + + public WavSeekMap( + WavHeader wavHeader, int framesPerBlock, long dataStartPosition, long dataEndPosition) { + this.wavHeader = wavHeader; + this.framesPerBlock = framesPerBlock; + this.firstBlockPosition = dataStartPosition; + this.blockCount = (dataEndPosition - dataStartPosition) / wavHeader.blockSize; + durationUs = blockIndexToTimeUs(blockCount); + } + + @Override + public boolean isSeekable() { + return true; + } + + @Override + public long getDurationUs() { + return durationUs; + } + + @Override + public SeekPoints getSeekPoints(long timeUs) { + // Calculate the containing block index, constraining to valid indices. + long blockIndex = (timeUs * wavHeader.frameRateHz) / (C.MICROS_PER_SECOND * framesPerBlock); + blockIndex = Util.constrainValue(blockIndex, 0, blockCount - 1); + + long seekPosition = firstBlockPosition + (blockIndex * wavHeader.blockSize); + long seekTimeUs = blockIndexToTimeUs(blockIndex); + SeekPoint seekPoint = new SeekPoint(seekTimeUs, seekPosition); + if (seekTimeUs >= timeUs || blockIndex == blockCount - 1) { + return new SeekPoints(seekPoint); + } else { + long secondBlockIndex = blockIndex + 1; + long secondSeekPosition = firstBlockPosition + (secondBlockIndex * wavHeader.blockSize); + long secondSeekTimeUs = blockIndexToTimeUs(secondBlockIndex); + SeekPoint secondSeekPoint = new SeekPoint(secondSeekTimeUs, secondSeekPosition); + return new SeekPoints(seekPoint, secondSeekPoint); + } + } + + private long blockIndexToTimeUs(long blockIndex) { + return Util.scaleLargeTimestamp( + blockIndex * framesPerBlock, C.MICROS_PER_SECOND, wavHeader.frameRateHz); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java new file mode 100644 index 0000000000..7e38c9a173 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecInfo.java @@ -0,0 +1,617 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec; + +import android.annotation.TargetApi; +import android.graphics.Point; +import android.media.MediaCodec; +import android.media.MediaCodecInfo.AudioCapabilities; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecInfo.CodecProfileLevel; +import android.media.MediaCodecInfo.VideoCapabilities; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** Information about a {@link MediaCodec} for a given mime type. */ +@SuppressWarnings("InlinedApi") +public final class MediaCodecInfo { + + public static final String TAG = "MediaCodecInfo"; + + /** + * The value returned by {@link #getMaxSupportedInstances()} if the upper bound on the maximum + * number of supported instances is unknown. + */ + public static final int MAX_SUPPORTED_INSTANCES_UNKNOWN = -1; + + /** + * The name of the decoder. + * <p> + * May be passed to {@link MediaCodec#createByCodecName(String)} to create an instance of the + * decoder. + */ + public final String name; + + /** The MIME type handled by the codec, or {@code null} if this is a passthrough codec. */ + @Nullable public final String mimeType; + + /** + * The MIME type that the codec uses for media of type {@link #mimeType}, or {@code null} if this + * is a passthrough codec. Equal to {@link #mimeType} unless the codec is known to use a + * non-standard MIME type alias. + */ + @Nullable public final String codecMimeType; + + /** + * The capabilities of the decoder, like the profiles/levels it supports, or {@code null} if not + * known. + */ + @Nullable public final CodecCapabilities capabilities; + + /** + * Whether the decoder supports seamless resolution switches. + * + * @see CodecCapabilities#isFeatureSupported(String) + * @see CodecCapabilities#FEATURE_AdaptivePlayback + */ + public final boolean adaptive; + + /** + * Whether the decoder supports tunneling. + * + * @see CodecCapabilities#isFeatureSupported(String) + * @see CodecCapabilities#FEATURE_TunneledPlayback + */ + public final boolean tunneling; + + /** + * Whether the decoder is secure. + * + * @see CodecCapabilities#isFeatureSupported(String) + * @see CodecCapabilities#FEATURE_SecurePlayback + */ + public final boolean secure; + + /** Whether this instance describes a passthrough codec. */ + public final boolean passthrough; + + /** + * Whether the codec is hardware accelerated. + * + * <p>This could be an approximation as the exact information is only provided in API levels 29+. + * + * @see android.media.MediaCodecInfo#isHardwareAccelerated() + */ + public final boolean hardwareAccelerated; + + /** + * Whether the codec is software only. + * + * <p>This could be an approximation as the exact information is only provided in API levels 29+. + * + * @see android.media.MediaCodecInfo#isSoftwareOnly() + */ + public final boolean softwareOnly; + + /** + * Whether the codec is from the vendor. + * + * <p>This could be an approximation as the exact information is only provided in API levels 29+. + * + * @see android.media.MediaCodecInfo#isVendor() + */ + public final boolean vendor; + + private final boolean isVideo; + + /** + * Creates an instance representing an audio passthrough decoder. + * + * @param name The name of the {@link MediaCodec}. + * @return The created instance. + */ + public static MediaCodecInfo newPassthroughInstance(String name) { + return new MediaCodecInfo( + name, + /* mimeType= */ null, + /* codecMimeType= */ null, + /* capabilities= */ null, + /* passthrough= */ true, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false); + } + + /** + * Creates an instance. + * + * @param name The name of the {@link MediaCodec}. + * @param mimeType A mime type supported by the {@link MediaCodec}. + * @param codecMimeType The MIME type that the codec uses for media of type {@code #mimeType}. + * Equal to {@code mimeType} unless the codec is known to use a non-standard MIME type alias. + * @param capabilities The capabilities of the {@link MediaCodec} for the specified mime type, or + * {@code null} if not known. + * @param hardwareAccelerated Whether the {@link MediaCodec} is hardware accelerated. + * @param softwareOnly Whether the {@link MediaCodec} is software only. + * @param vendor Whether the {@link MediaCodec} is provided by the vendor. + * @param forceDisableAdaptive Whether {@link #adaptive} should be forced to {@code false}. + * @param forceSecure Whether {@link #secure} should be forced to {@code true}. + * @return The created instance. + */ + public static MediaCodecInfo newInstance( + String name, + String mimeType, + String codecMimeType, + @Nullable CodecCapabilities capabilities, + boolean hardwareAccelerated, + boolean softwareOnly, + boolean vendor, + boolean forceDisableAdaptive, + boolean forceSecure) { + return new MediaCodecInfo( + name, + mimeType, + codecMimeType, + capabilities, + /* passthrough= */ false, + hardwareAccelerated, + softwareOnly, + vendor, + forceDisableAdaptive, + forceSecure); + } + + private MediaCodecInfo( + String name, + @Nullable String mimeType, + @Nullable String codecMimeType, + @Nullable CodecCapabilities capabilities, + boolean passthrough, + boolean hardwareAccelerated, + boolean softwareOnly, + boolean vendor, + boolean forceDisableAdaptive, + boolean forceSecure) { + this.name = Assertions.checkNotNull(name); + this.mimeType = mimeType; + this.codecMimeType = codecMimeType; + this.capabilities = capabilities; + this.passthrough = passthrough; + this.hardwareAccelerated = hardwareAccelerated; + this.softwareOnly = softwareOnly; + this.vendor = vendor; + adaptive = !forceDisableAdaptive && capabilities != null && isAdaptive(capabilities); + tunneling = capabilities != null && isTunneling(capabilities); + secure = forceSecure || (capabilities != null && isSecure(capabilities)); + isVideo = MimeTypes.isVideo(mimeType); + } + + @Override + public String toString() { + return name; + } + + /** + * The profile levels supported by the decoder. + * + * @return The profile levels supported by the decoder. + */ + public CodecProfileLevel[] getProfileLevels() { + return capabilities == null || capabilities.profileLevels == null ? new CodecProfileLevel[0] + : capabilities.profileLevels; + } + + /** + * Returns an upper bound on the maximum number of supported instances, or {@link + * #MAX_SUPPORTED_INSTANCES_UNKNOWN} if unknown. Applications should not expect to operate more + * instances than the returned maximum. + * + * @see CodecCapabilities#getMaxSupportedInstances() + */ + public int getMaxSupportedInstances() { + return (Util.SDK_INT < 23 || capabilities == null) + ? MAX_SUPPORTED_INSTANCES_UNKNOWN + : getMaxSupportedInstancesV23(capabilities); + } + + /** + * Returns whether the decoder may support decoding the given {@code format}. + * + * @param format The input media format. + * @return Whether the decoder may support decoding the given {@code format}. + * @throws MediaCodecUtil.DecoderQueryException Thrown if an error occurs while querying decoders. + */ + public boolean isFormatSupported(Format format) throws MediaCodecUtil.DecoderQueryException { + if (!isCodecSupported(format)) { + return false; + } + + if (isVideo) { + if (format.width <= 0 || format.height <= 0) { + return true; + } + if (Util.SDK_INT >= 21) { + return isVideoSizeAndRateSupportedV21(format.width, format.height, format.frameRate); + } else { + boolean isFormatSupported = + format.width * format.height <= MediaCodecUtil.maxH264DecodableFrameSize(); + if (!isFormatSupported) { + logNoSupport("legacyFrameSize, " + format.width + "x" + format.height); + } + return isFormatSupported; + } + } else { // Audio + return Util.SDK_INT < 21 + || ((format.sampleRate == Format.NO_VALUE + || isAudioSampleRateSupportedV21(format.sampleRate)) + && (format.channelCount == Format.NO_VALUE + || isAudioChannelCountSupportedV21(format.channelCount))); + } + } + + /** + * Whether the decoder supports the codec of the given {@code format}. If there is insufficient + * information to decide, returns true. + * + * @param format The input media format. + * @return True if the codec of the given {@code format} is supported by the decoder. + */ + public boolean isCodecSupported(Format format) { + if (format.codecs == null || mimeType == null) { + return true; + } + String codecMimeType = MimeTypes.getMediaMimeType(format.codecs); + if (codecMimeType == null) { + return true; + } + if (!mimeType.equals(codecMimeType)) { + logNoSupport("codec.mime " + format.codecs + ", " + codecMimeType); + return false; + } + Pair<Integer, Integer> codecProfileAndLevel = MediaCodecUtil.getCodecProfileAndLevel(format); + if (codecProfileAndLevel == null) { + // If we don't know any better, we assume that the profile and level are supported. + return true; + } + int profile = codecProfileAndLevel.first; + int level = codecProfileAndLevel.second; + if (!isVideo && profile != CodecProfileLevel.AACObjectXHE) { + // Some devices/builds underreport audio capabilities, so assume support except for xHE-AAC + // which may not be widely supported. See https://github.com/google/ExoPlayer/issues/5145. + return true; + } + for (CodecProfileLevel capabilities : getProfileLevels()) { + if (capabilities.profile == profile && capabilities.level >= level) { + return true; + } + } + logNoSupport("codec.profileLevel, " + format.codecs + ", " + codecMimeType); + return false; + } + + /** Whether the codec handles HDR10+ out-of-band metadata. */ + public boolean isHdr10PlusOutOfBandMetadataSupported() { + if (Util.SDK_INT >= 29 && MimeTypes.VIDEO_VP9.equals(mimeType)) { + for (CodecProfileLevel capabilities : getProfileLevels()) { + if (capabilities.profile == CodecProfileLevel.VP9Profile2HDR10Plus) { + return true; + } + } + } + return false; + } + + /** + * Returns whether it may be possible to adapt to playing a different format when the codec is + * configured to play media in the specified {@code format}. For adaptation to succeed, the codec + * must also be configured with appropriate maximum values and {@link + * #isSeamlessAdaptationSupported(Format, Format, boolean)} must return {@code true} for the + * old/new formats. + * + * @param format The format of media for which the decoder will be configured. + * @return Whether adaptation may be possible + */ + public boolean isSeamlessAdaptationSupported(Format format) { + if (isVideo) { + return adaptive; + } else { + Pair<Integer, Integer> codecProfileLevel = MediaCodecUtil.getCodecProfileAndLevel(format); + return codecProfileLevel != null && codecProfileLevel.first == CodecProfileLevel.AACObjectXHE; + } + } + + /** + * Returns whether it is possible to adapt the decoder seamlessly from {@code oldFormat} to {@code + * newFormat}. If {@code newFormat} may not be completely populated, pass {@code false} for {@code + * isNewFormatComplete}. + * + * @param oldFormat The format being decoded. + * @param newFormat The new format. + * @param isNewFormatComplete Whether {@code newFormat} is populated with format-specific + * metadata. + * @return Whether it is possible to adapt the decoder seamlessly. + */ + public boolean isSeamlessAdaptationSupported( + Format oldFormat, Format newFormat, boolean isNewFormatComplete) { + if (isVideo) { + return oldFormat.sampleMimeType.equals(newFormat.sampleMimeType) + && oldFormat.rotationDegrees == newFormat.rotationDegrees + && (adaptive + || (oldFormat.width == newFormat.width && oldFormat.height == newFormat.height)) + && ((!isNewFormatComplete && newFormat.colorInfo == null) + || Util.areEqual(oldFormat.colorInfo, newFormat.colorInfo)); + } else { + if (!MimeTypes.AUDIO_AAC.equals(mimeType) + || !oldFormat.sampleMimeType.equals(newFormat.sampleMimeType) + || oldFormat.channelCount != newFormat.channelCount + || oldFormat.sampleRate != newFormat.sampleRate) { + return false; + } + // Check the codec profile levels support adaptation. + Pair<Integer, Integer> oldCodecProfileLevel = + MediaCodecUtil.getCodecProfileAndLevel(oldFormat); + Pair<Integer, Integer> newCodecProfileLevel = + MediaCodecUtil.getCodecProfileAndLevel(newFormat); + if (oldCodecProfileLevel == null || newCodecProfileLevel == null) { + return false; + } + int oldProfile = oldCodecProfileLevel.first; + int newProfile = newCodecProfileLevel.first; + return oldProfile == CodecProfileLevel.AACObjectXHE + && newProfile == CodecProfileLevel.AACObjectXHE; + } + } + + /** + * Whether the decoder supports video with a given width, height and frame rate. + * + * <p>Must not be called if the device SDK version is less than 21. + * + * @param width Width in pixels. + * @param height Height in pixels. + * @param frameRate Optional frame rate in frames per second. Ignored if set to {@link + * Format#NO_VALUE} or any value less than or equal to 0. + * @return Whether the decoder supports video with the given width, height and frame rate. + */ + @TargetApi(21) + public boolean isVideoSizeAndRateSupportedV21(int width, int height, double frameRate) { + if (capabilities == null) { + logNoSupport("sizeAndRate.caps"); + return false; + } + VideoCapabilities videoCapabilities = capabilities.getVideoCapabilities(); + if (videoCapabilities == null) { + logNoSupport("sizeAndRate.vCaps"); + return false; + } + if (!areSizeAndRateSupportedV21(videoCapabilities, width, height, frameRate)) { + if (width >= height + || !enableRotatedVerticalResolutionWorkaround(name) + || !areSizeAndRateSupportedV21(videoCapabilities, height, width, frameRate)) { + logNoSupport("sizeAndRate.support, " + width + "x" + height + "x" + frameRate); + return false; + } + logAssumedSupport("sizeAndRate.rotated, " + width + "x" + height + "x" + frameRate); + } + return true; + } + + /** + * Returns the smallest video size greater than or equal to a specified size that also satisfies + * the {@link MediaCodec}'s width and height alignment requirements. + * <p> + * Must not be called if the device SDK version is less than 21. + * + * @param width Width in pixels. + * @param height Height in pixels. + * @return The smallest video size greater than or equal to the specified size that also satisfies + * the {@link MediaCodec}'s width and height alignment requirements, or null if not a video + * codec. + */ + @TargetApi(21) + public Point alignVideoSizeV21(int width, int height) { + if (capabilities == null) { + return null; + } + VideoCapabilities videoCapabilities = capabilities.getVideoCapabilities(); + if (videoCapabilities == null) { + return null; + } + return alignVideoSizeV21(videoCapabilities, width, height); + } + + /** + * Whether the decoder supports audio with a given sample rate. + * <p> + * Must not be called if the device SDK version is less than 21. + * + * @param sampleRate The sample rate in Hz. + * @return Whether the decoder supports audio with the given sample rate. + */ + @TargetApi(21) + public boolean isAudioSampleRateSupportedV21(int sampleRate) { + if (capabilities == null) { + logNoSupport("sampleRate.caps"); + return false; + } + AudioCapabilities audioCapabilities = capabilities.getAudioCapabilities(); + if (audioCapabilities == null) { + logNoSupport("sampleRate.aCaps"); + return false; + } + if (!audioCapabilities.isSampleRateSupported(sampleRate)) { + logNoSupport("sampleRate.support, " + sampleRate); + return false; + } + return true; + } + + /** + * Whether the decoder supports audio with a given channel count. + * <p> + * Must not be called if the device SDK version is less than 21. + * + * @param channelCount The channel count. + * @return Whether the decoder supports audio with the given channel count. + */ + @TargetApi(21) + public boolean isAudioChannelCountSupportedV21(int channelCount) { + if (capabilities == null) { + logNoSupport("channelCount.caps"); + return false; + } + AudioCapabilities audioCapabilities = capabilities.getAudioCapabilities(); + if (audioCapabilities == null) { + logNoSupport("channelCount.aCaps"); + return false; + } + int maxInputChannelCount = adjustMaxInputChannelCount(name, mimeType, + audioCapabilities.getMaxInputChannelCount()); + if (maxInputChannelCount < channelCount) { + logNoSupport("channelCount.support, " + channelCount); + return false; + } + return true; + } + + private void logNoSupport(String message) { + Log.d(TAG, "NoSupport [" + message + "] [" + name + ", " + mimeType + "] [" + + Util.DEVICE_DEBUG_INFO + "]"); + } + + private void logAssumedSupport(String message) { + Log.d(TAG, "AssumedSupport [" + message + "] [" + name + ", " + mimeType + "] [" + + Util.DEVICE_DEBUG_INFO + "]"); + } + + private static int adjustMaxInputChannelCount(String name, String mimeType, int maxChannelCount) { + if (maxChannelCount > 1 || (Util.SDK_INT >= 26 && maxChannelCount > 0)) { + // The maximum channel count looks like it's been set correctly. + return maxChannelCount; + } + if (MimeTypes.AUDIO_MPEG.equals(mimeType) + || MimeTypes.AUDIO_AMR_NB.equals(mimeType) + || MimeTypes.AUDIO_AMR_WB.equals(mimeType) + || MimeTypes.AUDIO_AAC.equals(mimeType) + || MimeTypes.AUDIO_VORBIS.equals(mimeType) + || MimeTypes.AUDIO_OPUS.equals(mimeType) + || MimeTypes.AUDIO_RAW.equals(mimeType) + || MimeTypes.AUDIO_FLAC.equals(mimeType) + || MimeTypes.AUDIO_ALAW.equals(mimeType) + || MimeTypes.AUDIO_MLAW.equals(mimeType) + || MimeTypes.AUDIO_MSGSM.equals(mimeType)) { + // Platform code should have set a default. + return maxChannelCount; + } + // The maximum channel count looks incorrect. Adjust it to an assumed default. + int assumedMaxChannelCount; + if (MimeTypes.AUDIO_AC3.equals(mimeType)) { + assumedMaxChannelCount = 6; + } else if (MimeTypes.AUDIO_E_AC3.equals(mimeType)) { + assumedMaxChannelCount = 16; + } else { + // Default to the platform limit, which is 30. + assumedMaxChannelCount = 30; + } + Log.w(TAG, "AssumedMaxChannelAdjustment: " + name + ", [" + maxChannelCount + " to " + + assumedMaxChannelCount + "]"); + return assumedMaxChannelCount; + } + + private static boolean isAdaptive(CodecCapabilities capabilities) { + return Util.SDK_INT >= 19 && isAdaptiveV19(capabilities); + } + + @TargetApi(19) + private static boolean isAdaptiveV19(CodecCapabilities capabilities) { + return capabilities.isFeatureSupported(CodecCapabilities.FEATURE_AdaptivePlayback); + } + + private static boolean isTunneling(CodecCapabilities capabilities) { + return Util.SDK_INT >= 21 && isTunnelingV21(capabilities); + } + + @TargetApi(21) + private static boolean isTunnelingV21(CodecCapabilities capabilities) { + return capabilities.isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback); + } + + private static boolean isSecure(CodecCapabilities capabilities) { + return Util.SDK_INT >= 21 && isSecureV21(capabilities); + } + + @TargetApi(21) + private static boolean isSecureV21(CodecCapabilities capabilities) { + return capabilities.isFeatureSupported(CodecCapabilities.FEATURE_SecurePlayback); + } + + @TargetApi(21) + private static boolean areSizeAndRateSupportedV21(VideoCapabilities capabilities, int width, + int height, double frameRate) { + // Don't ever fail due to alignment. See: https://github.com/google/ExoPlayer/issues/6551. + Point alignedSize = alignVideoSizeV21(capabilities, width, height); + width = alignedSize.x; + height = alignedSize.y; + + if (frameRate == Format.NO_VALUE || frameRate <= 0) { + return capabilities.isSizeSupported(width, height); + } else { + // The signaled frame rate may be slightly higher than the actual frame rate, so we take the + // floor to avoid situations where a range check in areSizeAndRateSupported fails due to + // slightly exceeding the limits for a standard format (e.g., 1080p at 30 fps). + double floorFrameRate = Math.floor(frameRate); + return capabilities.areSizeAndRateSupported(width, height, floorFrameRate); + } + } + + @TargetApi(21) + private static Point alignVideoSizeV21(VideoCapabilities capabilities, int width, int height) { + int widthAlignment = capabilities.getWidthAlignment(); + int heightAlignment = capabilities.getHeightAlignment(); + return new Point( + Util.ceilDivide(width, widthAlignment) * widthAlignment, + Util.ceilDivide(height, heightAlignment) * heightAlignment); + } + + @TargetApi(23) + private static int getMaxSupportedInstancesV23(CodecCapabilities capabilities) { + return capabilities.getMaxSupportedInstances(); + } + + /** + * Capabilities are known to be inaccurately reported for vertical resolutions on some devices. + * [Internal ref: b/31387661]. When this workaround is enabled, we also check whether the + * capabilities indicate support if the width and height are swapped. If they do, we assume that + * the vertical resolution is also supported. + * + * @param name The name of the codec. + * @return Whether to enable the workaround. + */ + private static final boolean enableRotatedVerticalResolutionWorkaround(String name) { + if ("OMX.MTK.VIDEO.DECODER.HEVC".equals(name) && "mcv5a".equals(Util.DEVICE)) { + // See https://github.com/google/ExoPlayer/issues/6612. + return false; + } + return true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java new file mode 100644 index 0000000000..8d2f4574fd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecRenderer.java @@ -0,0 +1,2014 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaCodec.CodecException; +import android.media.MediaCodec.CryptoException; +import android.media.MediaCrypto; +import android.media.MediaCryptoException; +import android.media.MediaFormat; +import android.os.Bundle; +import android.os.SystemClock; +import androidx.annotation.CheckResult; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +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.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimedValueQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; + +/** + * An abstract renderer that uses {@link MediaCodec} to decode samples for rendering. + */ +public abstract class MediaCodecRenderer extends BaseRenderer { + + /** Thrown when a failure occurs instantiating a decoder. */ + public static class DecoderInitializationException extends Exception { + + private static final int CUSTOM_ERROR_CODE_BASE = -50000; + private static final int NO_SUITABLE_DECODER_ERROR = CUSTOM_ERROR_CODE_BASE + 1; + private static final int DECODER_QUERY_ERROR = CUSTOM_ERROR_CODE_BASE + 2; + + /** + * The mime type for which a decoder was being initialized. + */ + public final String mimeType; + + /** + * Whether it was required that the decoder support a secure output path. + */ + public final boolean secureDecoderRequired; + + /** + * The {@link MediaCodecInfo} of the decoder that failed to initialize. Null if no suitable + * decoder was found. + */ + @Nullable public final MediaCodecInfo codecInfo; + + /** An optional developer-readable diagnostic information string. May be null. */ + @Nullable public final String diagnosticInfo; + + /** + * If the decoder failed to initialize and another decoder being used as a fallback also failed + * to initialize, the {@link DecoderInitializationException} for the fallback decoder. Null if + * there was no fallback decoder or no suitable decoders were found. + */ + @Nullable public final DecoderInitializationException fallbackDecoderInitializationException; + + public DecoderInitializationException(Format format, Throwable cause, + boolean secureDecoderRequired, int errorCode) { + this( + "Decoder init failed: [" + errorCode + "], " + format, + cause, + format.sampleMimeType, + secureDecoderRequired, + /* mediaCodecInfo= */ null, + buildCustomDiagnosticInfo(errorCode), + /* fallbackDecoderInitializationException= */ null); + } + + public DecoderInitializationException( + Format format, + Throwable cause, + boolean secureDecoderRequired, + MediaCodecInfo mediaCodecInfo) { + this( + "Decoder init failed: " + mediaCodecInfo.name + ", " + format, + cause, + format.sampleMimeType, + secureDecoderRequired, + mediaCodecInfo, + Util.SDK_INT >= 21 ? getDiagnosticInfoV21(cause) : null, + /* fallbackDecoderInitializationException= */ null); + } + + private DecoderInitializationException( + String message, + Throwable cause, + String mimeType, + boolean secureDecoderRequired, + @Nullable MediaCodecInfo mediaCodecInfo, + @Nullable String diagnosticInfo, + @Nullable DecoderInitializationException fallbackDecoderInitializationException) { + super(message, cause); + this.mimeType = mimeType; + this.secureDecoderRequired = secureDecoderRequired; + this.codecInfo = mediaCodecInfo; + this.diagnosticInfo = diagnosticInfo; + this.fallbackDecoderInitializationException = fallbackDecoderInitializationException; + } + + @CheckResult + private DecoderInitializationException copyWithFallbackException( + DecoderInitializationException fallbackException) { + return new DecoderInitializationException( + getMessage(), + getCause(), + mimeType, + secureDecoderRequired, + codecInfo, + diagnosticInfo, + fallbackException); + } + + @TargetApi(21) + private static String getDiagnosticInfoV21(Throwable cause) { + if (cause instanceof CodecException) { + return ((CodecException) cause).getDiagnosticInfo(); + } + return null; + } + + private static String buildCustomDiagnosticInfo(int errorCode) { + String sign = errorCode < 0 ? "neg_" : ""; + return "com.google.android.exoplayer2.mediacodec.MediaCodecRenderer_" + + sign + + Math.abs(errorCode); + } + } + + /** Thrown when a failure occurs in the decoder. */ + public static class DecoderException extends Exception { + + /** The {@link MediaCodecInfo} of the decoder that failed. Null if unknown. */ + @Nullable public final MediaCodecInfo codecInfo; + + /** An optional developer-readable diagnostic information string. May be null. */ + @Nullable public final String diagnosticInfo; + + public DecoderException(Throwable cause, @Nullable MediaCodecInfo codecInfo) { + super("Decoder failed: " + (codecInfo == null ? null : codecInfo.name), cause); + this.codecInfo = codecInfo; + diagnosticInfo = Util.SDK_INT >= 21 ? getDiagnosticInfoV21(cause) : null; + } + + @TargetApi(21) + private static String getDiagnosticInfoV21(Throwable cause) { + if (cause instanceof CodecException) { + return ((CodecException) cause).getDiagnosticInfo(); + } + return null; + } + } + + /** Indicates no codec operating rate should be set. */ + protected static final float CODEC_OPERATING_RATE_UNSET = -1; + + private static final String TAG = "MediaCodecRenderer"; + + /** + * If the {@link MediaCodec} is hotswapped (i.e. replaced during playback), this is the period of + * time during which {@link #isReady()} will report true regardless of whether the new codec has + * output frames that are ready to be rendered. + * <p> + * This allows codec hotswapping to be performed seamlessly, without interrupting the playback of + * other renderers, provided the new codec is able to decode some frames within this time period. + */ + private static final long MAX_CODEC_HOTSWAP_TIME_MS = 1000; + + /** + * The possible return values for {@link #canKeepCodec(MediaCodec, MediaCodecInfo, Format, + * Format)}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + KEEP_CODEC_RESULT_NO, + KEEP_CODEC_RESULT_YES_WITH_FLUSH, + KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION, + KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION + }) + protected @interface KeepCodecResult {} + /** The codec cannot be kept. */ + protected static final int KEEP_CODEC_RESULT_NO = 0; + /** The codec can be kept, but must be flushed. */ + protected static final int KEEP_CODEC_RESULT_YES_WITH_FLUSH = 1; + /** + * The codec can be kept. It does not need to be flushed, but must be reconfigured by prefixing + * the next input buffer with the new format's configuration data. + */ + protected static final int KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION = 2; + /** The codec can be kept. It does not need to be flushed and no reconfiguration is required. */ + protected static final int KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION = 3; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + RECONFIGURATION_STATE_NONE, + RECONFIGURATION_STATE_WRITE_PENDING, + RECONFIGURATION_STATE_QUEUE_PENDING + }) + private @interface ReconfigurationState {} + /** + * There is no pending adaptive reconfiguration work. + */ + private static final int RECONFIGURATION_STATE_NONE = 0; + /** + * Codec configuration data needs to be written into the next buffer. + */ + private static final int RECONFIGURATION_STATE_WRITE_PENDING = 1; + /** + * Codec configuration data has been written into the next buffer, but that buffer still needs to + * be returned to the codec. + */ + private static final int RECONFIGURATION_STATE_QUEUE_PENDING = 2; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({DRAIN_STATE_NONE, DRAIN_STATE_SIGNAL_END_OF_STREAM, DRAIN_STATE_WAIT_END_OF_STREAM}) + private @interface DrainState {} + /** The codec is not being drained. */ + private static final int DRAIN_STATE_NONE = 0; + /** The codec needs to be drained, but we haven't signaled an end of stream to it yet. */ + private static final int DRAIN_STATE_SIGNAL_END_OF_STREAM = 1; + /** The codec needs to be drained, and we're waiting for it to output an end of stream. */ + private static final int DRAIN_STATE_WAIT_END_OF_STREAM = 2; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + DRAIN_ACTION_NONE, + DRAIN_ACTION_FLUSH, + DRAIN_ACTION_UPDATE_DRM_SESSION, + DRAIN_ACTION_REINITIALIZE + }) + private @interface DrainAction {} + /** No special action should be taken. */ + private static final int DRAIN_ACTION_NONE = 0; + /** The codec should be flushed. */ + private static final int DRAIN_ACTION_FLUSH = 1; + /** The codec should be flushed and updated to use the pending DRM session. */ + private static final int DRAIN_ACTION_UPDATE_DRM_SESSION = 2; + /** The codec should be reinitialized. */ + private static final int DRAIN_ACTION_REINITIALIZE = 3; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ADAPTATION_WORKAROUND_MODE_NEVER, + ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION, + ADAPTATION_WORKAROUND_MODE_ALWAYS + }) + private @interface AdaptationWorkaroundMode {} + /** + * The adaptation workaround is never used. + */ + private static final int ADAPTATION_WORKAROUND_MODE_NEVER = 0; + /** + * The adaptation workaround is used when adapting between formats of the same resolution only. + */ + private static final int ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION = 1; + /** + * The adaptation workaround is always used when adapting between formats. + */ + private static final int ADAPTATION_WORKAROUND_MODE_ALWAYS = 2; + + /** + * H.264/AVC buffer to queue when using the adaptation workaround (see {@link + * #codecAdaptationWorkaroundMode(String)}. Consists of three NAL units with start codes: Baseline + * sequence/picture parameter sets and a 32 * 32 pixel IDR slice. This stream can be queued to + * force a resolution change when adapting to a new format. + */ + private static final byte[] ADAPTATION_WORKAROUND_BUFFER = + new byte[] { + 0, 0, 1, 103, 66, -64, 11, -38, 37, -112, 0, 0, 1, 104, -50, 15, 19, 32, 0, 0, 1, 101, -120, + -124, 13, -50, 113, 24, -96, 0, 47, -65, 28, 49, -61, 39, 93, 120 + }; + + private static final int ADAPTATION_WORKAROUND_SLICE_WIDTH_HEIGHT = 32; + + private final MediaCodecSelector mediaCodecSelector; + @Nullable private final DrmSessionManager<FrameworkMediaCrypto> drmSessionManager; + private final boolean playClearSamplesWithoutKeys; + private final boolean enableDecoderFallback; + private final float assumedMinimumCodecOperatingRate; + private final DecoderInputBuffer buffer; + private final DecoderInputBuffer flagsOnlyBuffer; + private final TimedValueQueue<Format> formatQueue; + private final ArrayList<Long> decodeOnlyPresentationTimestamps; + private final MediaCodec.BufferInfo outputBufferInfo; + + private boolean drmResourcesAcquired; + @Nullable private Format inputFormat; + private Format outputFormat; + @Nullable private DrmSession<FrameworkMediaCrypto> codecDrmSession; + @Nullable private DrmSession<FrameworkMediaCrypto> sourceDrmSession; + @Nullable private MediaCrypto mediaCrypto; + private boolean mediaCryptoRequiresSecureDecoder; + private long renderTimeLimitMs; + private float rendererOperatingRate; + @Nullable private MediaCodec codec; + @Nullable private Format codecFormat; + private float codecOperatingRate; + @Nullable private ArrayDeque<MediaCodecInfo> availableCodecInfos; + @Nullable private DecoderInitializationException preferredDecoderInitializationException; + @Nullable private MediaCodecInfo codecInfo; + @AdaptationWorkaroundMode private int codecAdaptationWorkaroundMode; + private boolean codecNeedsReconfigureWorkaround; + private boolean codecNeedsDiscardToSpsWorkaround; + private boolean codecNeedsFlushWorkaround; + private boolean codecNeedsSosFlushWorkaround; + private boolean codecNeedsEosFlushWorkaround; + private boolean codecNeedsEosOutputExceptionWorkaround; + private boolean codecNeedsMonoChannelCountWorkaround; + private boolean codecNeedsAdaptationWorkaroundBuffer; + private boolean shouldSkipAdaptationWorkaroundOutputBuffer; + private boolean codecNeedsEosPropagation; + private ByteBuffer[] inputBuffers; + private ByteBuffer[] outputBuffers; + private long codecHotswapDeadlineMs; + private int inputIndex; + private int outputIndex; + private ByteBuffer outputBuffer; + private boolean isDecodeOnlyOutputBuffer; + private boolean isLastOutputBuffer; + private boolean codecReconfigured; + @ReconfigurationState private int codecReconfigurationState; + @DrainState private int codecDrainState; + @DrainAction private int codecDrainAction; + private boolean codecReceivedBuffers; + private boolean codecReceivedEos; + private boolean codecHasOutputMediaFormat; + private long largestQueuedPresentationTimeUs; + private long lastBufferInStreamPresentationTimeUs; + private boolean inputStreamEnded; + private boolean outputStreamEnded; + private boolean waitingForKeys; + private boolean waitingForFirstSyncSample; + private boolean waitingForFirstSampleInFormat; + private boolean skipMediaCodecStopOnRelease; + private boolean pendingOutputEndOfStream; + + protected DecoderCounters decoderCounters; + + /** + * @param trackType The track type that the renderer handles. One of the {@code C.TRACK_TYPE_*} + * constants defined in {@link C}. + * @param mediaCodecSelector A decoder selector. + * @param drmSessionManager For use with encrypted media. May be null if support for encrypted + * media is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is less efficient or slower + * than the primary decoder. + * @param assumedMinimumCodecOperatingRate A codec operating rate that all codecs instantiated by + * this renderer are assumed to meet implicitly (i.e. without the operating rate being set + * explicitly using {@link MediaFormat#KEY_OPERATING_RATE}). + */ + public MediaCodecRenderer( + int trackType, + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + boolean enableDecoderFallback, + float assumedMinimumCodecOperatingRate) { + super(trackType); + this.mediaCodecSelector = Assertions.checkNotNull(mediaCodecSelector); + this.drmSessionManager = drmSessionManager; + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + this.enableDecoderFallback = enableDecoderFallback; + this.assumedMinimumCodecOperatingRate = assumedMinimumCodecOperatingRate; + buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DISABLED); + flagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance(); + formatQueue = new TimedValueQueue<>(); + decodeOnlyPresentationTimestamps = new ArrayList<>(); + outputBufferInfo = new MediaCodec.BufferInfo(); + codecReconfigurationState = RECONFIGURATION_STATE_NONE; + codecDrainState = DRAIN_STATE_NONE; + codecDrainAction = DRAIN_ACTION_NONE; + codecOperatingRate = CODEC_OPERATING_RATE_UNSET; + rendererOperatingRate = 1f; + renderTimeLimitMs = C.TIME_UNSET; + } + + /** + * Set a limit on the time a single {@link #render(long, long)} call can spend draining and + * filling the decoder. + * + * <p>This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the renderer is used. + * + * @param renderTimeLimitMs The render time limit in milliseconds, or {@link C#TIME_UNSET} for no + * limit. + */ + public void experimental_setRenderTimeLimitMs(long renderTimeLimitMs) { + this.renderTimeLimitMs = renderTimeLimitMs; + } + + /** + * Skip calling {@link MediaCodec#stop()} when the underlying MediaCodec is going to be released. + * + * <p>By default, when the MediaCodecRenderer is releasing the underlying {@link MediaCodec}, it + * first calls {@link MediaCodec#stop()} and then calls {@link MediaCodec#release()}. If this + * feature is enabled, the MediaCodecRenderer will skip the call to {@link MediaCodec#stop()}. + * + * <p>This method is experimental, and will be renamed or removed in a future release. It should + * only be called before the renderer is used. + * + * @param enabled enable or disable the feature. + */ + public void experimental_setSkipMediaCodecStopOnRelease(boolean enabled) { + skipMediaCodecStopOnRelease = enabled; + } + + @Override + @AdaptiveSupport + public final int supportsMixedMimeTypeAdaptation() { + return ADAPTIVE_NOT_SEAMLESS; + } + + @Override + @Capabilities + public final int supportsFormat(Format format) throws ExoPlaybackException { + try { + return supportsFormat(mediaCodecSelector, drmSessionManager, format); + } catch (DecoderQueryException e) { + throw createRendererException(e, format); + } + } + + /** + * Returns the {@link Capabilities} for the given {@link Format}. + * + * @param mediaCodecSelector The decoder selector. + * @param drmSessionManager The renderer's {@link DrmSessionManager}. + * @param format The {@link Format}. + * @return The {@link Capabilities} for this {@link Format}. + * @throws DecoderQueryException If there was an error querying decoders. + */ + @Capabilities + protected abstract int supportsFormat( + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + Format format) + throws DecoderQueryException; + + /** + * Returns a list of decoders that can decode media in the specified format, in priority order. + * + * @param mediaCodecSelector The decoder selector. + * @param format The {@link Format} for which a decoder is required. + * @param requiresSecureDecoder Whether a secure decoder is required. + * @return A list of {@link MediaCodecInfo}s corresponding to decoders. May be empty. + * @throws DecoderQueryException Thrown if there was an error querying decoders. + */ + protected abstract List<MediaCodecInfo> getDecoderInfos( + MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder) + throws DecoderQueryException; + + /** + * Configures a newly created {@link MediaCodec}. + * + * @param codecInfo Information about the {@link MediaCodec} being configured. + * @param codec The {@link MediaCodec} to configure. + * @param format The {@link Format} for which the codec is being configured. + * @param crypto For drm protected playbacks, a {@link MediaCrypto} to use for decryption. + * @param codecOperatingRate The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if + * no codec operating rate should be set. + */ + protected abstract void configureCodec( + MediaCodecInfo codecInfo, + MediaCodec codec, + Format format, + @Nullable MediaCrypto crypto, + float codecOperatingRate); + + protected final void maybeInitCodec() throws ExoPlaybackException { + if (codec != null || inputFormat == null) { + // We have a codec already, or we don't have a format with which to instantiate one. + return; + } + + setCodecDrmSession(sourceDrmSession); + + String mimeType = inputFormat.sampleMimeType; + if (codecDrmSession != null) { + if (mediaCrypto == null) { + FrameworkMediaCrypto sessionMediaCrypto = codecDrmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + DrmSessionException drmError = codecDrmSession.getError(); + if (drmError != null) { + // Continue for now. We may be able to avoid failure if the session recovers, or if a + // new input format causes the session to be replaced before it's used. + } else { + // The drm session isn't open yet. + return; + } + } else { + try { + mediaCrypto = new MediaCrypto(sessionMediaCrypto.uuid, sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + throw createRendererException(e, inputFormat); + } + mediaCryptoRequiresSecureDecoder = + !sessionMediaCrypto.forceAllowInsecureDecoderComponents + && mediaCrypto.requiresSecureDecoderComponent(mimeType); + } + } + if (FrameworkMediaCrypto.WORKAROUND_DEVICE_NEEDS_KEYS_TO_CONFIGURE_CODEC) { + @DrmSession.State int drmSessionState = codecDrmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw createRendererException(codecDrmSession.getError(), inputFormat); + } else if (drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS) { + // Wait for keys. + return; + } + } + } + + try { + maybeInitCodecWithFallback(mediaCrypto, mediaCryptoRequiresSecureDecoder); + } catch (DecoderInitializationException e) { + throw createRendererException(e, inputFormat); + } + } + + protected boolean shouldInitCodec(MediaCodecInfo codecInfo) { + return true; + } + + /** + * Returns whether the codec needs the renderer to propagate the end-of-stream signal directly, + * rather than by using an end-of-stream buffer queued to the codec. + */ + protected boolean getCodecNeedsEosPropagation() { + return false; + } + + /** + * Polls the pending output format queue for a given buffer timestamp. If a format is present, it + * is removed and returned. Otherwise returns {@code null}. Subclasses should only call this + * method if they are taking over responsibility for output format propagation (e.g., when using + * video tunneling). + */ + protected final @Nullable Format updateOutputFormatForTime(long presentationTimeUs) { + Format format = formatQueue.pollFloor(presentationTimeUs); + if (format != null) { + outputFormat = format; + } + return format; + } + + protected final MediaCodec getCodec() { + return codec; + } + + protected final @Nullable MediaCodecInfo getCodecInfo() { + return codecInfo; + } + + @Override + protected void onEnabled(boolean joining) throws ExoPlaybackException { + if (drmSessionManager != null && !drmResourcesAcquired) { + drmResourcesAcquired = true; + drmSessionManager.prepare(); + } + decoderCounters = new DecoderCounters(); + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + inputStreamEnded = false; + outputStreamEnded = false; + pendingOutputEndOfStream = false; + flushOrReinitializeCodec(); + formatQueue.clear(); + } + + @Override + public final void setOperatingRate(float operatingRate) throws ExoPlaybackException { + rendererOperatingRate = operatingRate; + if (codec != null + && codecDrainAction != DRAIN_ACTION_REINITIALIZE + && getState() != STATE_DISABLED) { + updateCodecOperatingRate(); + } + } + + @Override + protected void onDisabled() { + inputFormat = null; + if (sourceDrmSession != null || codecDrmSession != null) { + // TODO: Do something better with this case. + onReset(); + } else { + flushOrReleaseCodec(); + } + } + + @Override + protected void onReset() { + try { + releaseCodec(); + } finally { + setSourceDrmSession(null); + } + if (drmSessionManager != null && drmResourcesAcquired) { + drmResourcesAcquired = false; + drmSessionManager.release(); + } + } + + protected void releaseCodec() { + availableCodecInfos = null; + codecInfo = null; + codecFormat = null; + codecHasOutputMediaFormat = false; + resetInputBuffer(); + resetOutputBuffer(); + resetCodecBuffers(); + waitingForKeys = false; + codecHotswapDeadlineMs = C.TIME_UNSET; + decodeOnlyPresentationTimestamps.clear(); + largestQueuedPresentationTimeUs = C.TIME_UNSET; + lastBufferInStreamPresentationTimeUs = C.TIME_UNSET; + try { + if (codec != null) { + decoderCounters.decoderReleaseCount++; + try { + if (!skipMediaCodecStopOnRelease) { + codec.stop(); + } + } finally { + codec.release(); + } + } + } finally { + codec = null; + try { + if (mediaCrypto != null) { + mediaCrypto.release(); + } + } finally { + mediaCrypto = null; + mediaCryptoRequiresSecureDecoder = false; + setCodecDrmSession(null); + } + } + } + + @Override + protected void onStarted() { + // Do nothing. Overridden to remove throws clause. + } + + @Override + protected void onStopped() { + // Do nothing. Overridden to remove throws clause. + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + if (pendingOutputEndOfStream) { + pendingOutputEndOfStream = false; + processEndOfStream(); + } + try { + if (outputStreamEnded) { + renderToEndOfStream(); + return; + } + if (inputFormat == null && !readToFlagsOnlyBuffer(/* requireFormat= */ true)) { + // We still don't have a format and can't make progress without one. + return; + } + // We have a format. + maybeInitCodec(); + if (codec != null) { + long drainStartTimeMs = SystemClock.elapsedRealtime(); + TraceUtil.beginSection("drainAndFeed"); + while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) {} + while (feedInputBuffer() && shouldContinueFeeding(drainStartTimeMs)) {} + TraceUtil.endSection(); + } else { + decoderCounters.skippedInputBufferCount += skipSource(positionUs); + // We need to read any format changes despite not having a codec so that drmSession can be + // updated, and so that we have the most recent format should the codec be initialized. We + // may also reach the end of the stream. Note that readSource will not read a sample into a + // flags-only buffer. + readToFlagsOnlyBuffer(/* requireFormat= */ false); + } + decoderCounters.ensureUpdated(); + } catch (IllegalStateException e) { + if (isMediaCodecException(e)) { + throw createRendererException(e, inputFormat); + } + throw e; + } + } + + /** + * Flushes the codec. If flushing is not possible, the codec will be released and re-instantiated. + * This method is a no-op if the codec is {@code null}. + * + * <p>The implementation of this method calls {@link #flushOrReleaseCodec()}, and {@link + * #maybeInitCodec()} if the codec needs to be re-instantiated. + * + * @return Whether the codec was released and reinitialized, rather than being flushed. + * @throws ExoPlaybackException If an error occurs re-instantiating the codec. + */ + protected final boolean flushOrReinitializeCodec() throws ExoPlaybackException { + boolean released = flushOrReleaseCodec(); + if (released) { + maybeInitCodec(); + } + return released; + } + + /** + * Flushes the codec. If flushing is not possible, the codec will be released. This method is a + * no-op if the codec is {@code null}. + * + * @return Whether the codec was released. + */ + protected boolean flushOrReleaseCodec() { + if (codec == null) { + return false; + } + if (codecDrainAction == DRAIN_ACTION_REINITIALIZE + || codecNeedsFlushWorkaround + || (codecNeedsSosFlushWorkaround && !codecHasOutputMediaFormat) + || (codecNeedsEosFlushWorkaround && codecReceivedEos)) { + releaseCodec(); + return true; + } + + codec.flush(); + resetInputBuffer(); + resetOutputBuffer(); + codecHotswapDeadlineMs = C.TIME_UNSET; + codecReceivedEos = false; + codecReceivedBuffers = false; + waitingForFirstSyncSample = true; + codecNeedsAdaptationWorkaroundBuffer = false; + shouldSkipAdaptationWorkaroundOutputBuffer = false; + isDecodeOnlyOutputBuffer = false; + isLastOutputBuffer = false; + + waitingForKeys = false; + decodeOnlyPresentationTimestamps.clear(); + largestQueuedPresentationTimeUs = C.TIME_UNSET; + lastBufferInStreamPresentationTimeUs = C.TIME_UNSET; + codecDrainState = DRAIN_STATE_NONE; + codecDrainAction = DRAIN_ACTION_NONE; + // Reconfiguration data sent shortly before the flush may not have been processed by the + // decoder. If the codec has been reconfigured we always send reconfiguration data again to + // guarantee that it's processed. + codecReconfigurationState = + codecReconfigured ? RECONFIGURATION_STATE_WRITE_PENDING : RECONFIGURATION_STATE_NONE; + return false; + } + + protected DecoderException createDecoderException( + Throwable cause, @Nullable MediaCodecInfo codecInfo) { + return new DecoderException(cause, codecInfo); + } + + /** Reads into {@link #flagsOnlyBuffer} and returns whether a {@link Format} was read. */ + private boolean readToFlagsOnlyBuffer(boolean requireFormat) throws ExoPlaybackException { + FormatHolder formatHolder = getFormatHolder(); + flagsOnlyBuffer.clear(); + int result = readSource(formatHolder, flagsOnlyBuffer, requireFormat); + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(formatHolder); + return true; + } else if (result == C.RESULT_BUFFER_READ && flagsOnlyBuffer.isEndOfStream()) { + inputStreamEnded = true; + processEndOfStream(); + } + return false; + } + + private void maybeInitCodecWithFallback( + MediaCrypto crypto, boolean mediaCryptoRequiresSecureDecoder) + throws DecoderInitializationException { + if (availableCodecInfos == null) { + try { + List<MediaCodecInfo> allAvailableCodecInfos = + getAvailableCodecInfos(mediaCryptoRequiresSecureDecoder); + availableCodecInfos = new ArrayDeque<>(); + if (enableDecoderFallback) { + availableCodecInfos.addAll(allAvailableCodecInfos); + } else if (!allAvailableCodecInfos.isEmpty()) { + availableCodecInfos.add(allAvailableCodecInfos.get(0)); + } + preferredDecoderInitializationException = null; + } catch (DecoderQueryException e) { + throw new DecoderInitializationException( + inputFormat, + e, + mediaCryptoRequiresSecureDecoder, + DecoderInitializationException.DECODER_QUERY_ERROR); + } + } + + if (availableCodecInfos.isEmpty()) { + throw new DecoderInitializationException( + inputFormat, + /* cause= */ null, + mediaCryptoRequiresSecureDecoder, + DecoderInitializationException.NO_SUITABLE_DECODER_ERROR); + } + + while (codec == null) { + MediaCodecInfo codecInfo = availableCodecInfos.peekFirst(); + if (!shouldInitCodec(codecInfo)) { + return; + } + try { + initCodec(codecInfo, crypto); + } catch (Exception e) { + Log.w(TAG, "Failed to initialize decoder: " + codecInfo, e); + // This codec failed to initialize, so fall back to the next codec in the list (if any). We + // won't try to use this codec again unless there's a format change or the renderer is + // disabled and re-enabled. + availableCodecInfos.removeFirst(); + DecoderInitializationException exception = + new DecoderInitializationException( + inputFormat, e, mediaCryptoRequiresSecureDecoder, codecInfo); + if (preferredDecoderInitializationException == null) { + preferredDecoderInitializationException = exception; + } else { + preferredDecoderInitializationException = + preferredDecoderInitializationException.copyWithFallbackException(exception); + } + if (availableCodecInfos.isEmpty()) { + throw preferredDecoderInitializationException; + } + } + } + + availableCodecInfos = null; + } + + private List<MediaCodecInfo> getAvailableCodecInfos(boolean mediaCryptoRequiresSecureDecoder) + throws DecoderQueryException { + List<MediaCodecInfo> codecInfos = + getDecoderInfos(mediaCodecSelector, inputFormat, mediaCryptoRequiresSecureDecoder); + if (codecInfos.isEmpty() && mediaCryptoRequiresSecureDecoder) { + // The drm session indicates that a secure decoder is required, but the device does not + // have one. Assuming that supportsFormat indicated support for the media being played, we + // know that it does not require a secure output path. Most CDM implementations allow + // playback to proceed with a non-secure decoder in this case, so we try our luck. + codecInfos = + getDecoderInfos(mediaCodecSelector, inputFormat, /* requiresSecureDecoder= */ false); + if (!codecInfos.isEmpty()) { + Log.w( + TAG, + "Drm session requires secure decoder for " + + inputFormat.sampleMimeType + + ", but no secure decoder available. Trying to proceed with " + + codecInfos + + "."); + } + } + return codecInfos; + } + + private void initCodec(MediaCodecInfo codecInfo, MediaCrypto crypto) throws Exception { + long codecInitializingTimestamp; + long codecInitializedTimestamp; + MediaCodec codec = null; + String codecName = codecInfo.name; + + float codecOperatingRate = + Util.SDK_INT < 23 + ? CODEC_OPERATING_RATE_UNSET + : getCodecOperatingRateV23(rendererOperatingRate, inputFormat, getStreamFormats()); + if (codecOperatingRate <= assumedMinimumCodecOperatingRate) { + codecOperatingRate = CODEC_OPERATING_RATE_UNSET; + } + try { + codecInitializingTimestamp = SystemClock.elapsedRealtime(); + TraceUtil.beginSection("createCodec:" + codecName); + codec = MediaCodec.createByCodecName(codecName); + TraceUtil.endSection(); + TraceUtil.beginSection("configureCodec"); + configureCodec(codecInfo, codec, inputFormat, crypto, codecOperatingRate); + TraceUtil.endSection(); + TraceUtil.beginSection("startCodec"); + codec.start(); + TraceUtil.endSection(); + codecInitializedTimestamp = SystemClock.elapsedRealtime(); + getCodecBuffers(codec); + } catch (Exception e) { + if (codec != null) { + resetCodecBuffers(); + codec.release(); + } + throw e; + } + + this.codec = codec; + this.codecInfo = codecInfo; + this.codecOperatingRate = codecOperatingRate; + codecFormat = inputFormat; + codecAdaptationWorkaroundMode = codecAdaptationWorkaroundMode(codecName); + codecNeedsReconfigureWorkaround = codecNeedsReconfigureWorkaround(codecName); + codecNeedsDiscardToSpsWorkaround = codecNeedsDiscardToSpsWorkaround(codecName, codecFormat); + codecNeedsFlushWorkaround = codecNeedsFlushWorkaround(codecName); + codecNeedsSosFlushWorkaround = codecNeedsSosFlushWorkaround(codecName); + codecNeedsEosFlushWorkaround = codecNeedsEosFlushWorkaround(codecName); + codecNeedsEosOutputExceptionWorkaround = codecNeedsEosOutputExceptionWorkaround(codecName); + codecNeedsMonoChannelCountWorkaround = + codecNeedsMonoChannelCountWorkaround(codecName, codecFormat); + codecNeedsEosPropagation = + codecNeedsEosPropagationWorkaround(codecInfo) || getCodecNeedsEosPropagation(); + + resetInputBuffer(); + resetOutputBuffer(); + codecHotswapDeadlineMs = + getState() == STATE_STARTED + ? (SystemClock.elapsedRealtime() + MAX_CODEC_HOTSWAP_TIME_MS) + : C.TIME_UNSET; + codecReconfigured = false; + codecReconfigurationState = RECONFIGURATION_STATE_NONE; + codecReceivedEos = false; + codecReceivedBuffers = false; + largestQueuedPresentationTimeUs = C.TIME_UNSET; + lastBufferInStreamPresentationTimeUs = C.TIME_UNSET; + codecDrainState = DRAIN_STATE_NONE; + codecDrainAction = DRAIN_ACTION_NONE; + codecNeedsAdaptationWorkaroundBuffer = false; + shouldSkipAdaptationWorkaroundOutputBuffer = false; + isDecodeOnlyOutputBuffer = false; + isLastOutputBuffer = false; + waitingForFirstSyncSample = true; + + decoderCounters.decoderInitCount++; + long elapsed = codecInitializedTimestamp - codecInitializingTimestamp; + onCodecInitialized(codecName, codecInitializedTimestamp, elapsed); + } + + private boolean shouldContinueFeeding(long drainStartTimeMs) { + return renderTimeLimitMs == C.TIME_UNSET + || SystemClock.elapsedRealtime() - drainStartTimeMs < renderTimeLimitMs; + } + + private void getCodecBuffers(MediaCodec codec) { + if (Util.SDK_INT < 21) { + inputBuffers = codec.getInputBuffers(); + outputBuffers = codec.getOutputBuffers(); + } + } + + private void resetCodecBuffers() { + if (Util.SDK_INT < 21) { + inputBuffers = null; + outputBuffers = null; + } + } + + private ByteBuffer getInputBuffer(int inputIndex) { + if (Util.SDK_INT >= 21) { + return codec.getInputBuffer(inputIndex); + } else { + return inputBuffers[inputIndex]; + } + } + + private ByteBuffer getOutputBuffer(int outputIndex) { + if (Util.SDK_INT >= 21) { + return codec.getOutputBuffer(outputIndex); + } else { + return outputBuffers[outputIndex]; + } + } + + private boolean hasOutputBuffer() { + return outputIndex >= 0; + } + + private void resetInputBuffer() { + inputIndex = C.INDEX_UNSET; + buffer.data = null; + } + + private void resetOutputBuffer() { + outputIndex = C.INDEX_UNSET; + outputBuffer = null; + } + + private void setSourceDrmSession(@Nullable DrmSession<FrameworkMediaCrypto> session) { + DrmSession.replaceSession(sourceDrmSession, session); + sourceDrmSession = session; + } + + private void setCodecDrmSession(@Nullable DrmSession<FrameworkMediaCrypto> session) { + DrmSession.replaceSession(codecDrmSession, session); + codecDrmSession = session; + } + + /** + * @return Whether it may be possible to feed more input data. + * @throws ExoPlaybackException If an error occurs feeding the input buffer. + */ + private boolean feedInputBuffer() throws ExoPlaybackException { + if (codec == null || codecDrainState == DRAIN_STATE_WAIT_END_OF_STREAM || inputStreamEnded) { + return false; + } + + if (inputIndex < 0) { + inputIndex = codec.dequeueInputBuffer(0); + if (inputIndex < 0) { + return false; + } + buffer.data = getInputBuffer(inputIndex); + buffer.clear(); + } + + if (codecDrainState == DRAIN_STATE_SIGNAL_END_OF_STREAM) { + // We need to re-initialize the codec. Send an end of stream signal to the existing codec so + // that it outputs any remaining buffers before we release it. + if (codecNeedsEosPropagation) { + // Do nothing. + } else { + codecReceivedEos = true; + codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + resetInputBuffer(); + } + codecDrainState = DRAIN_STATE_WAIT_END_OF_STREAM; + return false; + } + + if (codecNeedsAdaptationWorkaroundBuffer) { + codecNeedsAdaptationWorkaroundBuffer = false; + buffer.data.put(ADAPTATION_WORKAROUND_BUFFER); + codec.queueInputBuffer(inputIndex, 0, ADAPTATION_WORKAROUND_BUFFER.length, 0, 0); + resetInputBuffer(); + codecReceivedBuffers = true; + return true; + } + + int result; + FormatHolder formatHolder = getFormatHolder(); + int adaptiveReconfigurationBytes = 0; + if (waitingForKeys) { + // We've already read an encrypted sample into buffer, and are waiting for keys. + result = C.RESULT_BUFFER_READ; + } else { + // 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. + if (codecReconfigurationState == RECONFIGURATION_STATE_WRITE_PENDING) { + for (int i = 0; i < codecFormat.initializationData.size(); i++) { + byte[] data = codecFormat.initializationData.get(i); + buffer.data.put(data); + } + codecReconfigurationState = RECONFIGURATION_STATE_QUEUE_PENDING; + } + adaptiveReconfigurationBytes = buffer.data.position(); + result = readSource(formatHolder, buffer, false); + } + + if (hasReadStreamToEnd()) { + // Notify output queue of the last buffer's timestamp. + lastBufferInStreamPresentationTimeUs = largestQueuedPresentationTimeUs; + } + + if (result == C.RESULT_NOTHING_READ) { + return false; + } + if (result == C.RESULT_FORMAT_READ) { + if (codecReconfigurationState == RECONFIGURATION_STATE_QUEUE_PENDING) { + // We received two formats in a row. Clear the current buffer of any reconfiguration data + // associated with the first format. + buffer.clear(); + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + } + onInputFormatChanged(formatHolder); + return true; + } + + // We've read a buffer. + if (buffer.isEndOfStream()) { + if (codecReconfigurationState == RECONFIGURATION_STATE_QUEUE_PENDING) { + // 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). + buffer.clear(); + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + } + inputStreamEnded = true; + if (!codecReceivedBuffers) { + processEndOfStream(); + return false; + } + try { + if (codecNeedsEosPropagation) { + // Do nothing. + } else { + codecReceivedEos = true; + codec.queueInputBuffer(inputIndex, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + resetInputBuffer(); + } + } catch (CryptoException e) { + throw createRendererException(e, inputFormat); + } + return false; + } + if (waitingForFirstSyncSample && !buffer.isKeyFrame()) { + buffer.clear(); + if (codecReconfigurationState == RECONFIGURATION_STATE_QUEUE_PENDING) { + // The buffer we just cleared contained reconfiguration data. We need to re-write this + // data into a subsequent buffer (if there is one). + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + } + return true; + } + waitingForFirstSyncSample = false; + boolean bufferEncrypted = buffer.isEncrypted(); + waitingForKeys = shouldWaitForKeys(bufferEncrypted); + if (waitingForKeys) { + return false; + } + if (codecNeedsDiscardToSpsWorkaround && !bufferEncrypted) { + NalUnitUtil.discardToSps(buffer.data); + if (buffer.data.position() == 0) { + return true; + } + codecNeedsDiscardToSpsWorkaround = false; + } + try { + long presentationTimeUs = buffer.timeUs; + if (buffer.isDecodeOnly()) { + decodeOnlyPresentationTimestamps.add(presentationTimeUs); + } + if (waitingForFirstSampleInFormat) { + formatQueue.add(presentationTimeUs, inputFormat); + waitingForFirstSampleInFormat = false; + } + largestQueuedPresentationTimeUs = + Math.max(largestQueuedPresentationTimeUs, presentationTimeUs); + + buffer.flip(); + if (buffer.hasSupplementalData()) { + handleInputBufferSupplementalData(buffer); + } + onQueueInputBuffer(buffer); + + if (bufferEncrypted) { + MediaCodec.CryptoInfo cryptoInfo = getFrameworkCryptoInfo(buffer, + adaptiveReconfigurationBytes); + codec.queueSecureInputBuffer(inputIndex, 0, cryptoInfo, presentationTimeUs, 0); + } else { + codec.queueInputBuffer(inputIndex, 0, buffer.data.limit(), presentationTimeUs, 0); + } + resetInputBuffer(); + codecReceivedBuffers = true; + codecReconfigurationState = RECONFIGURATION_STATE_NONE; + decoderCounters.inputBufferCount++; + } catch (CryptoException e) { + throw createRendererException(e, inputFormat); + } + return true; + } + + private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { + if (codecDrmSession == null + || (!bufferEncrypted + && (playClearSamplesWithoutKeys || codecDrmSession.playClearSamplesWithoutKeys()))) { + return false; + } + @DrmSession.State int drmSessionState = codecDrmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw createRendererException(codecDrmSession.getError(), inputFormat); + } + return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; + } + + /** + * Called when a {@link MediaCodec} has been created and configured. + * <p> + * The default implementation is a no-op. + * + * @param name The name of the codec that was initialized. + * @param initializedTimestampMs {@link SystemClock#elapsedRealtime()} when initialization + * finished. + * @param initializationDurationMs The time taken to initialize the codec in milliseconds. + */ + protected void onCodecInitialized(String name, long initializedTimestampMs, + long initializationDurationMs) { + // Do nothing. + } + + /** + * Called when a new {@link Format} is read from the upstream {@link MediaPeriod}. + * + * @param formatHolder A {@link FormatHolder} that holds the new {@link Format}. + * @throws ExoPlaybackException If an error occurs re-initializing the {@link MediaCodec}. + */ + @SuppressWarnings("unchecked") + protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { + waitingForFirstSampleInFormat = true; + Format newFormat = Assertions.checkNotNull(formatHolder.format); + if (formatHolder.includesDrmSession) { + setSourceDrmSession((DrmSession<FrameworkMediaCrypto>) formatHolder.drmSession); + } else { + sourceDrmSession = + getUpdatedSourceDrmSession(inputFormat, newFormat, drmSessionManager, sourceDrmSession); + } + inputFormat = newFormat; + + if (codec == null) { + maybeInitCodec(); + return; + } + + // We have an existing codec that we may need to reconfigure or re-initialize. If the existing + // codec instance is being kept then its operating rate may need to be updated. + + if ((sourceDrmSession == null && codecDrmSession != null) + || (sourceDrmSession != null && codecDrmSession == null) + || (sourceDrmSession != codecDrmSession + && !codecInfo.secure + && maybeRequiresSecureDecoder(sourceDrmSession, newFormat)) + || (Util.SDK_INT < 23 && sourceDrmSession != codecDrmSession)) { + // We might need to switch between the clear and protected output paths, or we're using DRM + // prior to API level 23 where the codec needs to be re-initialized to switch to the new DRM + // session. + drainAndReinitializeCodec(); + return; + } + + switch (canKeepCodec(codec, codecInfo, codecFormat, newFormat)) { + case KEEP_CODEC_RESULT_NO: + drainAndReinitializeCodec(); + break; + case KEEP_CODEC_RESULT_YES_WITH_FLUSH: + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } else { + drainAndFlushCodec(); + } + break; + case KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION: + if (codecNeedsReconfigureWorkaround) { + drainAndReinitializeCodec(); + } else { + codecReconfigured = true; + codecReconfigurationState = RECONFIGURATION_STATE_WRITE_PENDING; + codecNeedsAdaptationWorkaroundBuffer = + codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_ALWAYS + || (codecAdaptationWorkaroundMode == ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION + && newFormat.width == codecFormat.width + && newFormat.height == codecFormat.height); + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } + } + break; + case KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION: + codecFormat = newFormat; + updateCodecOperatingRate(); + if (sourceDrmSession != codecDrmSession) { + drainAndUpdateCodecDrmSession(); + } + break; + default: + throw new IllegalStateException(); // Never happens. + } + } + + /** + * Called when the output {@link MediaFormat} of the {@link MediaCodec} changes. + * + * <p>The default implementation is a no-op. + * + * @param codec The {@link MediaCodec} instance. + * @param outputMediaFormat The new output {@link MediaFormat}. + * @throws ExoPlaybackException Thrown if an error occurs handling the new output media format. + */ + protected void onOutputFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) + throws ExoPlaybackException { + // Do nothing. + } + + /** + * Handles supplemental data associated with an input buffer. + * + * <p>The default implementation is a no-op. + * + * @param buffer The input buffer that is about to be queued. + * @throws ExoPlaybackException Thrown if an error occurs handling supplemental data. + */ + protected void handleInputBufferSupplementalData(DecoderInputBuffer buffer) + throws ExoPlaybackException { + // Do nothing. + } + + /** + * Called immediately before an input buffer is queued into the codec. + * + * <p>The default implementation is a no-op. + * + * @param buffer The buffer to be queued. + */ + protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + // Do nothing. + } + + /** + * Called when an output buffer is successfully processed. + * <p> + * The default implementation is a no-op. + * + * @param presentationTimeUs The timestamp associated with the output buffer. + */ + protected void onProcessedOutputBuffer(long presentationTimeUs) { + // Do nothing. + } + + /** + * Determines whether the existing {@link MediaCodec} can be kept for a new {@link Format}, and if + * it can whether it requires reconfiguration. + * + * <p>The default implementation returns {@link #KEEP_CODEC_RESULT_NO}. + * + * @param codec The existing {@link MediaCodec} instance. + * @param codecInfo A {@link MediaCodecInfo} describing the decoder. + * @param oldFormat The {@link Format} for which the existing instance is configured. + * @param newFormat The new {@link Format}. + * @return Whether the instance can be kept, and if it can whether it requires reconfiguration. + */ + protected @KeepCodecResult int canKeepCodec( + MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { + return KEEP_CODEC_RESULT_NO; + } + + @Override + public boolean isEnded() { + return outputStreamEnded; + } + + @Override + public boolean isReady() { + return inputFormat != null + && !waitingForKeys + && (isSourceReady() + || hasOutputBuffer() + || (codecHotswapDeadlineMs != C.TIME_UNSET + && SystemClock.elapsedRealtime() < codecHotswapDeadlineMs)); + } + + /** + * Returns the maximum time to block whilst waiting for a decoded output buffer. + * + * @return The maximum time to block, in microseconds. + */ + protected long getDequeueOutputBufferTimeoutUs() { + return 0; + } + + /** + * Returns the {@link MediaFormat#KEY_OPERATING_RATE} value for a given renderer operating rate, + * current {@link Format} and set of possible stream formats. + * + * <p>The default implementation returns {@link #CODEC_OPERATING_RATE_UNSET}. + * + * @param operatingRate The renderer operating rate. + * @param format The {@link Format} for which the codec is being configured. + * @param streamFormats The possible stream formats. + * @return The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if no codec operating + * rate should be set. + */ + protected float getCodecOperatingRateV23( + float operatingRate, Format format, Format[] streamFormats) { + return CODEC_OPERATING_RATE_UNSET; + } + + /** + * Updates the codec operating rate. + * + * @throws ExoPlaybackException If an error occurs releasing or initializing a codec. + */ + private void updateCodecOperatingRate() throws ExoPlaybackException { + if (Util.SDK_INT < 23) { + return; + } + + float newCodecOperatingRate = + getCodecOperatingRateV23(rendererOperatingRate, codecFormat, getStreamFormats()); + if (codecOperatingRate == newCodecOperatingRate) { + // No change. + } else if (newCodecOperatingRate == CODEC_OPERATING_RATE_UNSET) { + // The only way to clear the operating rate is to instantiate a new codec instance. See + // [Internal ref: b/71987865]. + drainAndReinitializeCodec(); + } else if (codecOperatingRate != CODEC_OPERATING_RATE_UNSET + || newCodecOperatingRate > assumedMinimumCodecOperatingRate) { + // We need to set the operating rate, either because we've set it previously or because it's + // above the assumed minimum rate. + Bundle codecParameters = new Bundle(); + codecParameters.putFloat(MediaFormat.KEY_OPERATING_RATE, newCodecOperatingRate); + codec.setParameters(codecParameters); + codecOperatingRate = newCodecOperatingRate; + } + } + + /** Starts draining the codec for flush. */ + private void drainAndFlushCodec() { + if (codecReceivedBuffers) { + codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; + codecDrainAction = DRAIN_ACTION_FLUSH; + } + } + + /** + * Starts draining the codec to update its DRM session. The update may occur immediately if no + * buffers have been queued to the codec. + * + * @throws ExoPlaybackException If an error occurs updating the codec's DRM session. + */ + private void drainAndUpdateCodecDrmSession() throws ExoPlaybackException { + if (Util.SDK_INT < 23) { + // The codec needs to be re-initialized to switch to the source DRM session. + drainAndReinitializeCodec(); + return; + } + if (codecReceivedBuffers) { + codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; + codecDrainAction = DRAIN_ACTION_UPDATE_DRM_SESSION; + } else { + // Nothing has been queued to the decoder, so we can do the update immediately. + updateDrmSessionOrReinitializeCodecV23(); + } + } + + /** + * Starts draining the codec for re-initialization. Re-initialization may occur immediately if no + * buffers have been queued to the codec. + * + * @throws ExoPlaybackException If an error occurs re-initializing a codec. + */ + private void drainAndReinitializeCodec() throws ExoPlaybackException { + if (codecReceivedBuffers) { + codecDrainState = DRAIN_STATE_SIGNAL_END_OF_STREAM; + codecDrainAction = DRAIN_ACTION_REINITIALIZE; + } else { + // Nothing has been queued to the decoder, so we can re-initialize immediately. + reinitializeCodec(); + } + } + + /** + * @return Whether it may be possible to drain more output data. + * @throws ExoPlaybackException If an error occurs draining the output buffer. + */ + private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException { + if (!hasOutputBuffer()) { + int outputIndex; + if (codecNeedsEosOutputExceptionWorkaround && codecReceivedEos) { + try { + outputIndex = + codec.dequeueOutputBuffer(outputBufferInfo, getDequeueOutputBufferTimeoutUs()); + } catch (IllegalStateException e) { + processEndOfStream(); + if (outputStreamEnded) { + // Release the codec, as it's in an error state. + releaseCodec(); + } + return false; + } + } else { + outputIndex = + codec.dequeueOutputBuffer(outputBufferInfo, getDequeueOutputBufferTimeoutUs()); + } + + if (outputIndex < 0) { + if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED /* (-2) */) { + processOutputFormat(); + return true; + } else if (outputIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED /* (-3) */) { + processOutputBuffersChanged(); + return true; + } + /* MediaCodec.INFO_TRY_AGAIN_LATER (-1) or unknown negative return value */ + if (codecNeedsEosPropagation + && (inputStreamEnded || codecDrainState == DRAIN_STATE_WAIT_END_OF_STREAM)) { + processEndOfStream(); + } + return false; + } + + // We've dequeued a buffer. + if (shouldSkipAdaptationWorkaroundOutputBuffer) { + shouldSkipAdaptationWorkaroundOutputBuffer = false; + codec.releaseOutputBuffer(outputIndex, false); + return true; + } else if (outputBufferInfo.size == 0 + && (outputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + // The dequeued buffer indicates the end of the stream. Process it immediately. + processEndOfStream(); + return false; + } + + this.outputIndex = outputIndex; + outputBuffer = getOutputBuffer(outputIndex); + // The dequeued buffer is a media buffer. Do some initial setup. + // It will be processed by calling processOutputBuffer (possibly multiple times). + if (outputBuffer != null) { + outputBuffer.position(outputBufferInfo.offset); + outputBuffer.limit(outputBufferInfo.offset + outputBufferInfo.size); + } + isDecodeOnlyOutputBuffer = isDecodeOnlyBuffer(outputBufferInfo.presentationTimeUs); + isLastOutputBuffer = + lastBufferInStreamPresentationTimeUs == outputBufferInfo.presentationTimeUs; + updateOutputFormatForTime(outputBufferInfo.presentationTimeUs); + } + + boolean processedOutputBuffer; + if (codecNeedsEosOutputExceptionWorkaround && codecReceivedEos) { + try { + processedOutputBuffer = + processOutputBuffer( + positionUs, + elapsedRealtimeUs, + codec, + outputBuffer, + outputIndex, + outputBufferInfo.flags, + outputBufferInfo.presentationTimeUs, + isDecodeOnlyOutputBuffer, + isLastOutputBuffer, + outputFormat); + } catch (IllegalStateException e) { + processEndOfStream(); + if (outputStreamEnded) { + // Release the codec, as it's in an error state. + releaseCodec(); + } + return false; + } + } else { + processedOutputBuffer = + processOutputBuffer( + positionUs, + elapsedRealtimeUs, + codec, + outputBuffer, + outputIndex, + outputBufferInfo.flags, + outputBufferInfo.presentationTimeUs, + isDecodeOnlyOutputBuffer, + isLastOutputBuffer, + outputFormat); + } + + if (processedOutputBuffer) { + onProcessedOutputBuffer(outputBufferInfo.presentationTimeUs); + boolean isEndOfStream = (outputBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + resetOutputBuffer(); + if (!isEndOfStream) { + return true; + } + processEndOfStream(); + } + + return false; + } + + /** Processes a new output {@link MediaFormat}. */ + private void processOutputFormat() throws ExoPlaybackException { + codecHasOutputMediaFormat = true; + MediaFormat mediaFormat = codec.getOutputFormat(); + if (codecAdaptationWorkaroundMode != ADAPTATION_WORKAROUND_MODE_NEVER + && mediaFormat.getInteger(MediaFormat.KEY_WIDTH) == ADAPTATION_WORKAROUND_SLICE_WIDTH_HEIGHT + && mediaFormat.getInteger(MediaFormat.KEY_HEIGHT) + == ADAPTATION_WORKAROUND_SLICE_WIDTH_HEIGHT) { + // We assume this format changed event was caused by the adaptation workaround. + shouldSkipAdaptationWorkaroundOutputBuffer = true; + return; + } + if (codecNeedsMonoChannelCountWorkaround) { + mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); + } + onOutputFormatChanged(codec, mediaFormat); + } + + /** + * Processes a change in the output buffers. + */ + private void processOutputBuffersChanged() { + if (Util.SDK_INT < 21) { + outputBuffers = codec.getOutputBuffers(); + } + } + + /** + * Processes an output media buffer. + * + * <p>When a new {@link ByteBuffer} is passed to this method its position and limit delineate the + * data to be processed. The return value indicates whether the buffer was processed in full. If + * true is returned then the next call to this method will receive a new buffer to be processed. + * If false is returned then the same buffer will be passed to the next call. An implementation of + * this method is free to modify the buffer and can assume that the buffer will not be externally + * modified between successive calls. Hence an implementation can, for example, modify the + * buffer's position to keep track of how much of the data it has processed. + * + * <p>Note that the first call to this method following a call to {@link #onPositionReset(long, + * boolean)} will always receive a new {@link ByteBuffer} to be processed. + * + * @param positionUs The current media time in microseconds, measured at the start of the current + * iteration of the rendering loop. + * @param elapsedRealtimeUs {@link SystemClock#elapsedRealtime()} in microseconds, measured at the + * start of the current iteration of the rendering loop. + * @param codec The {@link MediaCodec} instance. + * @param buffer The output buffer to process. + * @param bufferIndex The index of the output buffer. + * @param bufferFlags The flags attached to the output buffer. + * @param bufferPresentationTimeUs The presentation time of the output buffer in microseconds. + * @param isDecodeOnlyBuffer Whether the buffer was marked with {@link C#BUFFER_FLAG_DECODE_ONLY} + * by the source. + * @param isLastBuffer Whether the buffer is the last sample of the current stream. + * @param format The {@link Format} associated with the buffer. + * @return Whether the output buffer was fully processed (e.g. rendered or skipped). + * @throws ExoPlaybackException If an error occurs processing the output buffer. + */ + protected abstract boolean processOutputBuffer( + long positionUs, + long elapsedRealtimeUs, + MediaCodec codec, + ByteBuffer buffer, + int bufferIndex, + int bufferFlags, + long bufferPresentationTimeUs, + boolean isDecodeOnlyBuffer, + boolean isLastBuffer, + Format format) + throws ExoPlaybackException; + + /** + * Incrementally renders any remaining output. + * <p> + * The default implementation is a no-op. + * + * @throws ExoPlaybackException Thrown if an error occurs rendering remaining output. + */ + protected void renderToEndOfStream() throws ExoPlaybackException { + // Do nothing. + } + + /** + * Processes an end of stream signal. + * + * @throws ExoPlaybackException If an error occurs processing the signal. + */ + private void processEndOfStream() throws ExoPlaybackException { + switch (codecDrainAction) { + case DRAIN_ACTION_REINITIALIZE: + reinitializeCodec(); + break; + case DRAIN_ACTION_UPDATE_DRM_SESSION: + updateDrmSessionOrReinitializeCodecV23(); + break; + case DRAIN_ACTION_FLUSH: + flushOrReinitializeCodec(); + break; + case DRAIN_ACTION_NONE: + default: + outputStreamEnded = true; + renderToEndOfStream(); + break; + } + } + + /** + * Notifies the renderer that output end of stream is pending and should be handled on the next + * render. + */ + protected final void setPendingOutputEndOfStream() { + pendingOutputEndOfStream = true; + } + + private void reinitializeCodec() throws ExoPlaybackException { + releaseCodec(); + maybeInitCodec(); + } + + private boolean isDecodeOnlyBuffer(long presentationTimeUs) { + // We avoid using decodeOnlyPresentationTimestamps.remove(presentationTimeUs) because it would + // box presentationTimeUs, creating a Long object that would need to be garbage collected. + int size = decodeOnlyPresentationTimestamps.size(); + for (int i = 0; i < size; i++) { + if (decodeOnlyPresentationTimestamps.get(i) == presentationTimeUs) { + decodeOnlyPresentationTimestamps.remove(i); + return true; + } + } + return false; + } + + @TargetApi(23) + private void updateDrmSessionOrReinitializeCodecV23() throws ExoPlaybackException { + @Nullable FrameworkMediaCrypto sessionMediaCrypto = sourceDrmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + // We'd only expect this to happen if the CDM from which the pending session is obtained needs + // provisioning. This is unlikely to happen (it probably requires a switch from one DRM scheme + // to another, where the new CDM hasn't been used before and needs provisioning). It would be + // possible to handle this case more efficiently (i.e. with a new renderer state that waits + // for provisioning to finish and then calls mediaCrypto.setMediaDrmSession), but the extra + // complexity is not warranted given how unlikely the case is to occur. + reinitializeCodec(); + return; + } + if (C.PLAYREADY_UUID.equals(sessionMediaCrypto.uuid)) { + // The PlayReady CDM does not implement setMediaDrmSession. + // TODO: Add API check once [Internal ref: b/128835874] is fixed. + reinitializeCodec(); + return; + } + + if (flushOrReinitializeCodec()) { + // The codec was reinitialized. The new codec will be using the new DRM session, so there's + // nothing more to do. + return; + } + + try { + mediaCrypto.setMediaDrmSession(sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + throw createRendererException(e, inputFormat); + } + setCodecDrmSession(sourceDrmSession); + codecDrainState = DRAIN_STATE_NONE; + codecDrainAction = DRAIN_ACTION_NONE; + } + + /** + * Returns whether a {@link DrmSession} may require a secure decoder for a given {@link Format}. + * + * @param drmSession The {@link DrmSession}. + * @param format The {@link Format}. + * @return Whether a secure decoder may be required. + */ + private static boolean maybeRequiresSecureDecoder( + DrmSession<FrameworkMediaCrypto> drmSession, Format format) { + @Nullable FrameworkMediaCrypto sessionMediaCrypto = drmSession.getMediaCrypto(); + if (sessionMediaCrypto == null) { + // We'd only expect this to happen if the CDM from which the pending session is obtained needs + // provisioning. This is unlikely to happen (it probably requires a switch from one DRM scheme + // to another, where the new CDM hasn't been used before and needs provisioning). Assume that + // a secure decoder may be required. + return true; + } + if (sessionMediaCrypto.forceAllowInsecureDecoderComponents) { + return false; + } + MediaCrypto mediaCrypto; + try { + mediaCrypto = new MediaCrypto(sessionMediaCrypto.uuid, sessionMediaCrypto.sessionId); + } catch (MediaCryptoException e) { + // This shouldn't happen, but if it does then assume that a secure decoder may be required. + return true; + } + try { + return mediaCrypto.requiresSecureDecoderComponent(format.sampleMimeType); + } finally { + mediaCrypto.release(); + } + } + + private static MediaCodec.CryptoInfo getFrameworkCryptoInfo( + DecoderInputBuffer buffer, int adaptiveReconfigurationBytes) { + MediaCodec.CryptoInfo cryptoInfo = buffer.cryptoInfo.getFrameworkCryptoInfo(); + if (adaptiveReconfigurationBytes == 0) { + return cryptoInfo; + } + // There must be at least one sub-sample, although numBytesOfClearData is permitted to be + // null if it contains no clear data. Instantiate it if needed, and add the reconfiguration + // bytes to the clear byte count of the first sub-sample. + if (cryptoInfo.numBytesOfClearData == null) { + cryptoInfo.numBytesOfClearData = new int[1]; + } + cryptoInfo.numBytesOfClearData[0] += adaptiveReconfigurationBytes; + return cryptoInfo; + } + + private static boolean isMediaCodecException(IllegalStateException error) { + if (Util.SDK_INT >= 21 && isMediaCodecExceptionV21(error)) { + return true; + } + StackTraceElement[] stackTrace = error.getStackTrace(); + return stackTrace.length > 0 && stackTrace[0].getClassName().equals("android.media.MediaCodec"); + } + + @TargetApi(21) + private static boolean isMediaCodecExceptionV21(IllegalStateException error) { + return error instanceof MediaCodec.CodecException; + } + + /** + * Returns whether the decoder is known to fail when flushed. + * <p> + * If true is returned, the renderer will work around the issue by releasing the decoder and + * instantiating a new one rather than flushing the current instance. + * <p> + * See [Internal: b/8347958, b/8543366]. + * + * @param name The name of the decoder. + * @return True if the decoder is known to fail when flushed. + */ + private static boolean codecNeedsFlushWorkaround(String name) { + return Util.SDK_INT < 18 + || (Util.SDK_INT == 18 + && ("OMX.SEC.avc.dec".equals(name) || "OMX.SEC.avc.dec.secure".equals(name))) + || (Util.SDK_INT == 19 && Util.MODEL.startsWith("SM-G800") + && ("OMX.Exynos.avc.dec".equals(name) || "OMX.Exynos.avc.dec.secure".equals(name))); + } + + /** + * Returns a mode that specifies when the adaptation workaround should be enabled. + * + * <p>When enabled, the workaround queues and discards a blank frame with a resolution whose width + * and height both equal {@link #ADAPTATION_WORKAROUND_SLICE_WIDTH_HEIGHT}, to reset the decoder's + * internal state when a format change occurs. + * + * <p>See [Internal: b/27807182]. See <a + * href="https://github.com/google/ExoPlayer/issues/3257">GitHub issue #3257</a>. + * + * @param name The name of the decoder. + * @return The mode specifying when the adaptation workaround should be enabled. + */ + private @AdaptationWorkaroundMode int codecAdaptationWorkaroundMode(String name) { + if (Util.SDK_INT <= 25 && "OMX.Exynos.avc.dec.secure".equals(name) + && (Util.MODEL.startsWith("SM-T585") || Util.MODEL.startsWith("SM-A510") + || Util.MODEL.startsWith("SM-A520") || Util.MODEL.startsWith("SM-J700"))) { + return ADAPTATION_WORKAROUND_MODE_ALWAYS; + } else if (Util.SDK_INT < 24 + && ("OMX.Nvidia.h264.decode".equals(name) || "OMX.Nvidia.h264.decode.secure".equals(name)) + && ("flounder".equals(Util.DEVICE) || "flounder_lte".equals(Util.DEVICE) + || "grouper".equals(Util.DEVICE) || "tilapia".equals(Util.DEVICE))) { + return ADAPTATION_WORKAROUND_MODE_SAME_RESOLUTION; + } else { + return ADAPTATION_WORKAROUND_MODE_NEVER; + } + } + + /** + * Returns whether the decoder is known to fail when an attempt is made to reconfigure it with a + * new format's configuration data. + * + * <p>When enabled, the workaround will always release and recreate the decoder, rather than + * attempting to reconfigure the existing instance. + * + * @param name The name of the decoder. + * @return True if the decoder is known to fail when an attempt is made to reconfigure it with a + * new format's configuration data. + */ + private static boolean codecNeedsReconfigureWorkaround(String name) { + return Util.MODEL.startsWith("SM-T230") && "OMX.MARVELL.VIDEO.HW.CODA7542DECODER".equals(name); + } + + /** + * Returns whether the decoder is an H.264/AVC decoder known to fail if NAL units are queued + * before the codec specific data. + * + * <p>If true is returned, the renderer will work around the issue by discarding data up to the + * SPS. + * + * @param name The name of the decoder. + * @param format The {@link Format} used to configure the decoder. + * @return True if the decoder is known to fail if NAL units are queued before CSD. + */ + private static boolean codecNeedsDiscardToSpsWorkaround(String name, Format format) { + return Util.SDK_INT < 21 && format.initializationData.isEmpty() + && "OMX.MTK.VIDEO.DECODER.AVC".equals(name); + } + + /** + * Returns whether the decoder is known to handle the propagation of the {@link + * MediaCodec#BUFFER_FLAG_END_OF_STREAM} flag incorrectly on the host device. + * + * <p>If true is returned, the renderer will work around the issue by approximating end of stream + * behavior without relying on the flag being propagated through to an output buffer by the + * underlying decoder. + * + * @param codecInfo Information about the {@link MediaCodec}. + * @return True if the decoder is known to handle {@link MediaCodec#BUFFER_FLAG_END_OF_STREAM} + * propagation incorrectly on the host device. False otherwise. + */ + private static boolean codecNeedsEosPropagationWorkaround(MediaCodecInfo codecInfo) { + String name = codecInfo.name; + return (Util.SDK_INT <= 25 && "OMX.rk.video_decoder.avc".equals(name)) + || (Util.SDK_INT <= 17 && "OMX.allwinner.video.decoder.avc".equals(name)) + || ("Amazon".equals(Util.MANUFACTURER) && "AFTS".equals(Util.MODEL) && codecInfo.secure); + } + + /** + * Returns whether the decoder is known to behave incorrectly if flushed after receiving an input + * buffer with {@link MediaCodec#BUFFER_FLAG_END_OF_STREAM} set. + * <p> + * If true is returned, the renderer will work around the issue by instantiating a new decoder + * when this case occurs. + * <p> + * See [Internal: b/8578467, b/23361053]. + * + * @param name The name of the decoder. + * @return True if the decoder is known to behave incorrectly if flushed after receiving an input + * buffer with {@link MediaCodec#BUFFER_FLAG_END_OF_STREAM} set. False otherwise. + */ + private static boolean codecNeedsEosFlushWorkaround(String name) { + return (Util.SDK_INT <= 23 && "OMX.google.vorbis.decoder".equals(name)) + || (Util.SDK_INT <= 19 + && ("hb2000".equals(Util.DEVICE) || "stvm8".equals(Util.DEVICE)) + && ("OMX.amlogic.avc.decoder.awesome".equals(name) + || "OMX.amlogic.avc.decoder.awesome.secure".equals(name))); + } + + /** + * Returns whether the decoder may throw an {@link IllegalStateException} from + * {@link MediaCodec#dequeueOutputBuffer(MediaCodec.BufferInfo, long)} or + * {@link MediaCodec#releaseOutputBuffer(int, boolean)} after receiving an input + * buffer with {@link MediaCodec#BUFFER_FLAG_END_OF_STREAM} set. + * <p> + * See [Internal: b/17933838]. + * + * @param name The name of the decoder. + * @return True if the decoder may throw an exception after receiving an end-of-stream buffer. + */ + private static boolean codecNeedsEosOutputExceptionWorkaround(String name) { + return Util.SDK_INT == 21 && "OMX.google.aac.decoder".equals(name); + } + + /** + * Returns whether the decoder is known to set the number of audio channels in the output {@link + * Format} to 2 for the given input {@link Format}, whilst only actually outputting a single + * channel. + * + * <p>If true is returned then we explicitly override the number of channels in the output {@link + * Format}, setting it to 1. + * + * @param name The decoder name. + * @param format The input {@link Format}. + * @return True if the decoder is known to set the number of audio channels in the output {@link + * Format} to 2 for the given input {@link Format}, whilst only actually outputting a single + * channel. False otherwise. + */ + private static boolean codecNeedsMonoChannelCountWorkaround(String name, Format format) { + return Util.SDK_INT <= 18 && format.channelCount == 1 + && "OMX.MTK.AUDIO.DECODER.MP3".equals(name); + } + + /** + * Returns whether the decoder is known to behave incorrectly if flushed prior to having output a + * {@link MediaFormat}. + * + * <p>If true is returned, the renderer will work around the issue by instantiating a new decoder + * when this case occurs. + * + * <p>See [Internal: b/141097367]. + * + * @param name The name of the decoder. + * @return True if the decoder is known to behave incorrectly if flushed prior to having output a + * {@link MediaFormat}. False otherwise. + */ + private static boolean codecNeedsSosFlushWorkaround(String name) { + return Util.SDK_INT == 29 && "c2.android.aac.decoder".equals(name); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java new file mode 100644 index 0000000000..3f90c3a105 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecSelector.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec; + +import android.media.MediaCodec; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil.DecoderQueryException; +import java.util.List; + +/** + * Selector of {@link MediaCodec} instances. + */ +public interface MediaCodecSelector { + + /** + * Default implementation of {@link MediaCodecSelector}, which returns the preferred decoder for + * the given format. + */ + MediaCodecSelector DEFAULT = + new MediaCodecSelector() { + @Override + public List<MediaCodecInfo> getDecoderInfos( + String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) + throws DecoderQueryException { + return MediaCodecUtil.getDecoderInfos( + mimeType, requiresSecureDecoder, requiresTunnelingDecoder); + } + + @Override + @Nullable + public MediaCodecInfo getPassthroughDecoderInfo() throws DecoderQueryException { + return MediaCodecUtil.getPassthroughDecoderInfo(); + } + }; + + /** + * Returns a list of decoders that can decode media in the specified MIME type, in priority order. + * + * @param mimeType The MIME type for which a decoder is required. + * @param requiresSecureDecoder Whether a secure decoder is required. + * @param requiresTunnelingDecoder Whether a tunneling decoder is required. + * @return An unmodifiable list of {@link MediaCodecInfo}s corresponding to decoders. May be + * empty. + * @throws DecoderQueryException Thrown if there was an error querying decoders. + */ + List<MediaCodecInfo> getDecoderInfos( + String mimeType, boolean requiresSecureDecoder, boolean requiresTunnelingDecoder) + throws DecoderQueryException; + + /** + * Selects a decoder to instantiate for audio passthrough. + * + * @return A {@link MediaCodecInfo} describing the decoder, or null if no suitable decoder exists. + * @throws DecoderQueryException Thrown if there was an error querying decoders. + */ + @Nullable + MediaCodecInfo getPassthroughDecoderInfo() throws DecoderQueryException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java new file mode 100644 index 0000000000..11fe931305 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaCodecUtil.java @@ -0,0 +1,1232 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecInfo.CodecProfileLevel; +import android.media.MediaCodecList; +import android.text.TextUtils; +import android.util.Pair; +import android.util.SparseIntArray; +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.ColorInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; + +/** + * A utility class for querying the available codecs. + */ +@SuppressLint("InlinedApi") +public final class MediaCodecUtil { + + /** + * Thrown when an error occurs querying the device for its underlying media capabilities. + * <p> + * Such failures are not expected in normal operation and are normally temporary (e.g. if the + * mediaserver process has crashed and is yet to restart). + */ + public static class DecoderQueryException extends Exception { + + private DecoderQueryException(Throwable cause) { + super("Failed to query underlying media codecs", cause); + } + + } + + private static final String TAG = "MediaCodecUtil"; + private static final Pattern PROFILE_PATTERN = Pattern.compile("^\\D?(\\d+)$"); + + private static final HashMap<CodecKey, List<MediaCodecInfo>> decoderInfosCache = new HashMap<>(); + + // Codecs to constant mappings. + // AVC. + private static final SparseIntArray AVC_PROFILE_NUMBER_TO_CONST; + private static final SparseIntArray AVC_LEVEL_NUMBER_TO_CONST; + private static final String CODEC_ID_AVC1 = "avc1"; + private static final String CODEC_ID_AVC2 = "avc2"; + // VP9 + private static final SparseIntArray VP9_PROFILE_NUMBER_TO_CONST; + private static final SparseIntArray VP9_LEVEL_NUMBER_TO_CONST; + private static final String CODEC_ID_VP09 = "vp09"; + // HEVC. + private static final Map<String, Integer> HEVC_CODEC_STRING_TO_PROFILE_LEVEL; + private static final String CODEC_ID_HEV1 = "hev1"; + private static final String CODEC_ID_HVC1 = "hvc1"; + // Dolby Vision. + private static final Map<String, Integer> DOLBY_VISION_STRING_TO_PROFILE; + private static final Map<String, Integer> DOLBY_VISION_STRING_TO_LEVEL; + // AV1. + private static final SparseIntArray AV1_LEVEL_NUMBER_TO_CONST; + private static final String CODEC_ID_AV01 = "av01"; + // MP4A AAC. + private static final SparseIntArray MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE; + private static final String CODEC_ID_MP4A = "mp4a"; + + // Lazily initialized. + private static int maxH264DecodableFrameSize = -1; + + private MediaCodecUtil() {} + + /** + * Optional call to warm the codec cache for a given mime type. + * + * <p>Calling this method may speed up subsequent calls to {@link #getDecoderInfo(String, boolean, + * boolean)} and {@link #getDecoderInfos(String, boolean, boolean)}. + * + * @param mimeType The mime type. + * @param secure Whether the decoder is required to support secure decryption. Always pass false + * unless secure decryption really is required. + * @param tunneling Whether the decoder is required to support tunneling. Always pass false unless + * tunneling really is required. + */ + public static void warmDecoderInfoCache(String mimeType, boolean secure, boolean tunneling) { + try { + getDecoderInfos(mimeType, secure, tunneling); + } catch (DecoderQueryException e) { + // Codec warming is best effort, so we can swallow the exception. + Log.e(TAG, "Codec warming failed", e); + } + } + + /** + * Returns information about a decoder suitable for audio passthrough. + * + * @return A {@link MediaCodecInfo} describing the decoder, or null if no suitable decoder exists. + * @throws DecoderQueryException If there was an error querying the available decoders. + */ + @Nullable + public static MediaCodecInfo getPassthroughDecoderInfo() throws DecoderQueryException { + @Nullable + MediaCodecInfo decoderInfo = + getDecoderInfo(MimeTypes.AUDIO_RAW, /* secure= */ false, /* tunneling= */ false); + return decoderInfo == null ? null : MediaCodecInfo.newPassthroughInstance(decoderInfo.name); + } + + /** + * Returns information about the preferred decoder for a given mime type. + * + * @param mimeType The MIME type. + * @param secure Whether the decoder is required to support secure decryption. Always pass false + * unless secure decryption really is required. + * @param tunneling Whether the decoder is required to support tunneling. Always pass false unless + * tunneling really is required. + * @return A {@link MediaCodecInfo} describing the decoder, or null if no suitable decoder exists. + * @throws DecoderQueryException If there was an error querying the available decoders. + */ + @Nullable + public static MediaCodecInfo getDecoderInfo(String mimeType, boolean secure, boolean tunneling) + throws DecoderQueryException { + List<MediaCodecInfo> decoderInfos = getDecoderInfos(mimeType, secure, tunneling); + return decoderInfos.isEmpty() ? null : decoderInfos.get(0); + } + + /** + * Returns all {@link MediaCodecInfo}s for the given mime type, in the order given by {@link + * MediaCodecList}. + * + * @param mimeType The MIME type. + * @param secure Whether the decoder is required to support secure decryption. Always pass false + * unless secure decryption really is required. + * @param tunneling Whether the decoder is required to support tunneling. Always pass false unless + * tunneling really is required. + * @return An unmodifiable list of all {@link MediaCodecInfo}s for the given mime type, in the + * order given by {@link MediaCodecList}. + * @throws DecoderQueryException If there was an error querying the available decoders. + */ + public static synchronized List<MediaCodecInfo> getDecoderInfos( + String mimeType, boolean secure, boolean tunneling) throws DecoderQueryException { + CodecKey key = new CodecKey(mimeType, secure, tunneling); + @Nullable List<MediaCodecInfo> cachedDecoderInfos = decoderInfosCache.get(key); + if (cachedDecoderInfos != null) { + return cachedDecoderInfos; + } + MediaCodecListCompat mediaCodecList = + Util.SDK_INT >= 21 + ? new MediaCodecListCompatV21(secure, tunneling) + : new MediaCodecListCompatV16(); + ArrayList<MediaCodecInfo> decoderInfos = getDecoderInfosInternal(key, mediaCodecList); + if (secure && decoderInfos.isEmpty() && 21 <= Util.SDK_INT && Util.SDK_INT <= 23) { + // Some devices don't list secure decoders on API level 21 [Internal: b/18678462]. Try the + // legacy path. We also try this path on API levels 22 and 23 as a defensive measure. + mediaCodecList = new MediaCodecListCompatV16(); + decoderInfos = getDecoderInfosInternal(key, mediaCodecList); + if (!decoderInfos.isEmpty()) { + Log.w(TAG, "MediaCodecList API didn't list secure decoder for: " + mimeType + + ". Assuming: " + decoderInfos.get(0).name); + } + } + applyWorkarounds(mimeType, decoderInfos); + List<MediaCodecInfo> unmodifiableDecoderInfos = Collections.unmodifiableList(decoderInfos); + decoderInfosCache.put(key, unmodifiableDecoderInfos); + return unmodifiableDecoderInfos; + } + + /** + * Returns a copy of the provided decoder list sorted such that decoders with format support are + * listed first. The returned list is modifiable for convenience. + */ + @CheckResult + public static List<MediaCodecInfo> getDecoderInfosSortedByFormatSupport( + List<MediaCodecInfo> decoderInfos, Format format) { + decoderInfos = new ArrayList<>(decoderInfos); + sortByScore( + decoderInfos, + decoderInfo -> { + try { + return decoderInfo.isFormatSupported(format) ? 1 : 0; + } catch (DecoderQueryException e) { + return -1; + } + }); + return decoderInfos; + } + + /** + * Returns the maximum frame size supported by the default H264 decoder. + * + * @return The maximum frame size for an H264 stream that can be decoded on the device. + */ + public static int maxH264DecodableFrameSize() throws DecoderQueryException { + if (maxH264DecodableFrameSize == -1) { + int result = 0; + @Nullable + MediaCodecInfo decoderInfo = + getDecoderInfo(MimeTypes.VIDEO_H264, /* secure= */ false, /* tunneling= */ false); + if (decoderInfo != null) { + for (CodecProfileLevel profileLevel : decoderInfo.getProfileLevels()) { + result = Math.max(avcLevelToMaxFrameSize(profileLevel.level), result); + } + // We assume support for at least 480p (SDK_INT >= 21) or 360p (SDK_INT < 21), which are + // the levels mandated by the Android CDD. + result = Math.max(result, Util.SDK_INT >= 21 ? (720 * 480) : (480 * 360)); + } + maxH264DecodableFrameSize = result; + } + return maxH264DecodableFrameSize; + } + + /** + * Returns profile and level (as defined by {@link CodecProfileLevel}) corresponding to the codec + * description string (as defined by RFC 6381) of the given format. + * + * @param format Media format with a codec description string, as defined by RFC 6381. + * @return A pair (profile constant, level constant) if the codec of the {@code format} is + * well-formed and recognized, or null otherwise. + */ + @Nullable + public static Pair<Integer, Integer> getCodecProfileAndLevel(Format format) { + if (format.codecs == null) { + return null; + } + String[] parts = format.codecs.split("\\."); + // Dolby Vision can use DV, AVC or HEVC codec IDs, so check the MIME type first. + if (MimeTypes.VIDEO_DOLBY_VISION.equals(format.sampleMimeType)) { + return getDolbyVisionProfileAndLevel(format.codecs, parts); + } + switch (parts[0]) { + case CODEC_ID_AVC1: + case CODEC_ID_AVC2: + return getAvcProfileAndLevel(format.codecs, parts); + case CODEC_ID_VP09: + return getVp9ProfileAndLevel(format.codecs, parts); + case CODEC_ID_HEV1: + case CODEC_ID_HVC1: + return getHevcProfileAndLevel(format.codecs, parts); + case CODEC_ID_AV01: + return getAv1ProfileAndLevel(format.codecs, parts, format.colorInfo); + case CODEC_ID_MP4A: + return getAacCodecProfileAndLevel(format.codecs, parts); + default: + return null; + } + } + + // Internal methods. + + /** + * Returns {@link MediaCodecInfo}s for the given codec {@link CodecKey} in the order given by + * {@code mediaCodecList}. + * + * @param key The codec key. + * @param mediaCodecList The codec list. + * @return The codec information for usable codecs matching the specified key. + * @throws DecoderQueryException If there was an error querying the available decoders. + */ + private static ArrayList<MediaCodecInfo> getDecoderInfosInternal( + CodecKey key, MediaCodecListCompat mediaCodecList) throws DecoderQueryException { + try { + ArrayList<MediaCodecInfo> decoderInfos = new ArrayList<>(); + String mimeType = key.mimeType; + int numberOfCodecs = mediaCodecList.getCodecCount(); + boolean secureDecodersExplicit = mediaCodecList.secureDecodersExplicit(); + // Note: MediaCodecList is sorted by the framework such that the best decoders come first. + for (int i = 0; i < numberOfCodecs; i++) { + android.media.MediaCodecInfo codecInfo = mediaCodecList.getCodecInfoAt(i); + if (isAlias(codecInfo)) { + // Skip aliases of other codecs, since they will also be listed under their canonical + // names. + continue; + } + String name = codecInfo.getName(); + if (!isCodecUsableDecoder(codecInfo, name, secureDecodersExplicit, mimeType)) { + continue; + } + @Nullable String codecMimeType = getCodecMimeType(codecInfo, name, mimeType); + if (codecMimeType == null) { + continue; + } + try { + CodecCapabilities capabilities = codecInfo.getCapabilitiesForType(codecMimeType); + boolean tunnelingSupported = + mediaCodecList.isFeatureSupported( + CodecCapabilities.FEATURE_TunneledPlayback, codecMimeType, capabilities); + boolean tunnelingRequired = + mediaCodecList.isFeatureRequired( + CodecCapabilities.FEATURE_TunneledPlayback, codecMimeType, capabilities); + if ((!key.tunneling && tunnelingRequired) || (key.tunneling && !tunnelingSupported)) { + continue; + } + boolean secureSupported = + mediaCodecList.isFeatureSupported( + CodecCapabilities.FEATURE_SecurePlayback, codecMimeType, capabilities); + boolean secureRequired = + mediaCodecList.isFeatureRequired( + CodecCapabilities.FEATURE_SecurePlayback, codecMimeType, capabilities); + if ((!key.secure && secureRequired) || (key.secure && !secureSupported)) { + continue; + } + boolean hardwareAccelerated = isHardwareAccelerated(codecInfo); + boolean softwareOnly = isSoftwareOnly(codecInfo); + boolean vendor = isVendor(codecInfo); + boolean forceDisableAdaptive = codecNeedsDisableAdaptationWorkaround(name); + if ((secureDecodersExplicit && key.secure == secureSupported) + || (!secureDecodersExplicit && !key.secure)) { + decoderInfos.add( + MediaCodecInfo.newInstance( + name, + mimeType, + codecMimeType, + capabilities, + hardwareAccelerated, + softwareOnly, + vendor, + forceDisableAdaptive, + /* forceSecure= */ false)); + } else if (!secureDecodersExplicit && secureSupported) { + decoderInfos.add( + MediaCodecInfo.newInstance( + name + ".secure", + mimeType, + codecMimeType, + capabilities, + hardwareAccelerated, + softwareOnly, + vendor, + forceDisableAdaptive, + /* forceSecure= */ true)); + // It only makes sense to have one synthesized secure decoder, return immediately. + return decoderInfos; + } + } catch (Exception e) { + if (Util.SDK_INT <= 23 && !decoderInfos.isEmpty()) { + // Suppress error querying secondary codec capabilities up to API level 23. + Log.e(TAG, "Skipping codec " + name + " (failed to query capabilities)"); + } else { + // Rethrow error querying primary codec capabilities, or secondary codec + // capabilities if API level is greater than 23. + Log.e(TAG, "Failed to query codec " + name + " (" + codecMimeType + ")"); + throw e; + } + } + } + return decoderInfos; + } catch (Exception e) { + // If the underlying mediaserver is in a bad state, we may catch an IllegalStateException + // or an IllegalArgumentException here. + throw new DecoderQueryException(e); + } + } + + /** + * Returns the codec's supported MIME type for media of type {@code mimeType}, or {@code null} if + * the codec can't be used. + * + * @param info The codec information. + * @param name The name of the codec + * @param mimeType The MIME type. + * @return The codec's supported MIME type for media of type {@code mimeType}, or {@code null} if + * the codec can't be used. If non-null, the returned type will be equal to {@code mimeType} + * except in cases where the codec is known to use a non-standard MIME type alias. + */ + @Nullable + private static String getCodecMimeType( + android.media.MediaCodecInfo info, + String name, + String mimeType) { + String[] supportedTypes = info.getSupportedTypes(); + for (String supportedType : supportedTypes) { + if (supportedType.equalsIgnoreCase(mimeType)) { + return supportedType; + } + } + + if (mimeType.equals(MimeTypes.VIDEO_DOLBY_VISION)) { + // Handle decoders that declare support for DV via MIME types that aren't + // video/dolby-vision. + if ("OMX.MS.HEVCDV.Decoder".equals(name)) { + return "video/hevcdv"; + } else if ("OMX.RTK.video.decoder".equals(name) + || "OMX.realtek.video.decoder.tunneled".equals(name)) { + return "video/dv_hevc"; + } + } else if (mimeType.equals(MimeTypes.AUDIO_ALAC) && "OMX.lge.alac.decoder".equals(name)) { + return "audio/x-lg-alac"; + } else if (mimeType.equals(MimeTypes.AUDIO_FLAC) && "OMX.lge.flac.decoder".equals(name)) { + return "audio/x-lg-flac"; + } + + return null; + } + + /** + * Returns whether the specified codec is usable for decoding on the current device. + * + * @param info The codec information. + * @param name The name of the codec + * @param secureDecodersExplicit Whether secure decoders were explicitly listed, if present. + * @param mimeType The MIME type. + * @return Whether the specified codec is usable for decoding on the current device. + */ + private static boolean isCodecUsableDecoder( + android.media.MediaCodecInfo info, + String name, + boolean secureDecodersExplicit, + String mimeType) { + if (info.isEncoder() || (!secureDecodersExplicit && name.endsWith(".secure"))) { + return false; + } + + // Work around broken audio decoders. + if (Util.SDK_INT < 21 + && ("CIPAACDecoder".equals(name) + || "CIPMP3Decoder".equals(name) + || "CIPVorbisDecoder".equals(name) + || "CIPAMRNBDecoder".equals(name) + || "AACDecoder".equals(name) + || "MP3Decoder".equals(name))) { + return false; + } + + // Work around https://github.com/google/ExoPlayer/issues/1528 and + // https://github.com/google/ExoPlayer/issues/3171. + if (Util.SDK_INT < 18 + && "OMX.MTK.AUDIO.DECODER.AAC".equals(name) + && ("a70".equals(Util.DEVICE) + || ("Xiaomi".equals(Util.MANUFACTURER) && Util.DEVICE.startsWith("HM")))) { + return false; + } + + // Work around an issue where querying/creating a particular MP3 decoder on some devices on + // platform API version 16 fails. + if (Util.SDK_INT == 16 + && "OMX.qcom.audio.decoder.mp3".equals(name) + && ("dlxu".equals(Util.DEVICE) // HTC Butterfly + || "protou".equals(Util.DEVICE) // HTC Desire X + || "ville".equals(Util.DEVICE) // HTC One S + || "villeplus".equals(Util.DEVICE) + || "villec2".equals(Util.DEVICE) + || Util.DEVICE.startsWith("gee") // LGE Optimus G + || "C6602".equals(Util.DEVICE) // Sony Xperia Z + || "C6603".equals(Util.DEVICE) + || "C6606".equals(Util.DEVICE) + || "C6616".equals(Util.DEVICE) + || "L36h".equals(Util.DEVICE) + || "SO-02E".equals(Util.DEVICE))) { + return false; + } + + // Work around an issue where large timestamps are not propagated correctly. + if (Util.SDK_INT == 16 + && "OMX.qcom.audio.decoder.aac".equals(name) + && ("C1504".equals(Util.DEVICE) // Sony Xperia E + || "C1505".equals(Util.DEVICE) + || "C1604".equals(Util.DEVICE) // Sony Xperia E dual + || "C1605".equals(Util.DEVICE))) { + return false; + } + + // Work around https://github.com/google/ExoPlayer/issues/3249. + if (Util.SDK_INT < 24 + && ("OMX.SEC.aac.dec".equals(name) || "OMX.Exynos.AAC.Decoder".equals(name)) + && "samsung".equals(Util.MANUFACTURER) + && (Util.DEVICE.startsWith("zeroflte") // Galaxy S6 + || Util.DEVICE.startsWith("zerolte") // Galaxy S6 Edge + || Util.DEVICE.startsWith("zenlte") // Galaxy S6 Edge+ + || "SC-05G".equals(Util.DEVICE) // Galaxy S6 + || "marinelteatt".equals(Util.DEVICE) // Galaxy S6 Active + || "404SC".equals(Util.DEVICE) // Galaxy S6 Edge + || "SC-04G".equals(Util.DEVICE) + || "SCV31".equals(Util.DEVICE))) { + return false; + } + + // Work around https://github.com/google/ExoPlayer/issues/548. + // VP8 decoder on Samsung Galaxy S3/S4/S4 Mini/Tab 3/Note 2 does not render video. + if (Util.SDK_INT <= 19 + && "OMX.SEC.vp8.dec".equals(name) + && "samsung".equals(Util.MANUFACTURER) + && (Util.DEVICE.startsWith("d2") + || Util.DEVICE.startsWith("serrano") + || Util.DEVICE.startsWith("jflte") + || Util.DEVICE.startsWith("santos") + || Util.DEVICE.startsWith("t0"))) { + return false; + } + + // VP8 decoder on Samsung Galaxy S4 cannot be queried. + if (Util.SDK_INT <= 19 && Util.DEVICE.startsWith("jflte") + && "OMX.qcom.video.decoder.vp8".equals(name)) { + return false; + } + + // MTK E-AC3 decoder doesn't support decoding JOC streams in 2-D. See [Internal: b/69400041]. + if (MimeTypes.AUDIO_E_AC3_JOC.equals(mimeType) && "OMX.MTK.AUDIO.DECODER.DSPAC3".equals(name)) { + return false; + } + + return true; + } + + /** + * Modifies a list of {@link MediaCodecInfo}s to apply workarounds where we know better than the + * platform. + * + * @param mimeType The MIME type of input media. + * @param decoderInfos The list to modify. + */ + private static void applyWorkarounds(String mimeType, List<MediaCodecInfo> decoderInfos) { + if (MimeTypes.AUDIO_RAW.equals(mimeType)) { + if (Util.SDK_INT < 26 + && Util.DEVICE.equals("R9") + && decoderInfos.size() == 1 + && decoderInfos.get(0).name.equals("OMX.MTK.AUDIO.DECODER.RAW")) { + // This device does not list a generic raw audio decoder, yet it can be instantiated by + // name. See <a href="https://github.com/google/ExoPlayer/issues/5782">Issue #5782</a>. + decoderInfos.add( + MediaCodecInfo.newInstance( + /* name= */ "OMX.google.raw.decoder", + /* mimeType= */ MimeTypes.AUDIO_RAW, + /* codecMimeType= */ MimeTypes.AUDIO_RAW, + /* capabilities= */ null, + /* hardwareAccelerated= */ false, + /* softwareOnly= */ true, + /* vendor= */ false, + /* forceDisableAdaptive= */ false, + /* forceSecure= */ false)); + } + // Work around inconsistent raw audio decoding behavior across different devices. + sortByScore( + decoderInfos, + decoderInfo -> { + String name = decoderInfo.name; + if (name.startsWith("OMX.google") || name.startsWith("c2.android")) { + // Prefer generic decoders over ones provided by the device. + return 1; + } + if (Util.SDK_INT < 26 && name.equals("OMX.MTK.AUDIO.DECODER.RAW")) { + // This decoder may modify the audio, so any other compatible decoders take + // precedence. See [Internal: b/62337687]. + return -1; + } + return 0; + }); + } + + if (Util.SDK_INT < 21 && decoderInfos.size() > 1) { + String firstCodecName = decoderInfos.get(0).name; + if ("OMX.SEC.mp3.dec".equals(firstCodecName) + || "OMX.SEC.MP3.Decoder".equals(firstCodecName) + || "OMX.brcm.audio.mp3.decoder".equals(firstCodecName)) { + // Prefer OMX.google codecs over OMX.SEC.mp3.dec, OMX.SEC.MP3.Decoder and + // OMX.brcm.audio.mp3.decoder on older devices. See: + // https://github.com/google/ExoPlayer/issues/398 and + // https://github.com/google/ExoPlayer/issues/4519. + sortByScore(decoderInfos, decoderInfo -> decoderInfo.name.startsWith("OMX.google") ? 1 : 0); + } + } + + if (Util.SDK_INT < 30 && decoderInfos.size() > 1) { + String firstCodecName = decoderInfos.get(0).name; + // Prefer anything other than OMX.qti.audio.decoder.flac on older devices. See [Internal + // ref: b/147278539] and [Internal ref: b/147354613]. + if ("OMX.qti.audio.decoder.flac".equals(firstCodecName)) { + decoderInfos.add(decoderInfos.remove(0)); + } + } + } + + private static boolean isAlias(android.media.MediaCodecInfo info) { + return Util.SDK_INT >= 29 && isAliasV29(info); + } + + @RequiresApi(29) + private static boolean isAliasV29(android.media.MediaCodecInfo info) { + return info.isAlias(); + } + + /** + * The result of {@link android.media.MediaCodecInfo#isHardwareAccelerated()} for API levels 29+, + * or a best-effort approximation for lower levels. + */ + private static boolean isHardwareAccelerated(android.media.MediaCodecInfo codecInfo) { + if (Util.SDK_INT >= 29) { + return isHardwareAcceleratedV29(codecInfo); + } + // codecInfo.isHardwareAccelerated() != codecInfo.isSoftwareOnly() is not necessarily true. + // However, we assume this to be true as an approximation. + return !isSoftwareOnly(codecInfo); + } + + @TargetApi(29) + private static boolean isHardwareAcceleratedV29(android.media.MediaCodecInfo codecInfo) { + return codecInfo.isHardwareAccelerated(); + } + + /** + * The result of {@link android.media.MediaCodecInfo#isSoftwareOnly()} for API levels 29+, or a + * best-effort approximation for lower levels. + */ + private static boolean isSoftwareOnly(android.media.MediaCodecInfo codecInfo) { + if (Util.SDK_INT >= 29) { + return isSoftwareOnlyV29(codecInfo); + } + String codecName = Util.toLowerInvariant(codecInfo.getName()); + if (codecName.startsWith("arc.")) { // App Runtime for Chrome (ARC) codecs + return false; + } + return codecName.startsWith("omx.google.") + || codecName.startsWith("omx.ffmpeg.") + || (codecName.startsWith("omx.sec.") && codecName.contains(".sw.")) + || codecName.equals("omx.qcom.video.decoder.hevcswvdec") + || codecName.startsWith("c2.android.") + || codecName.startsWith("c2.google.") + || (!codecName.startsWith("omx.") && !codecName.startsWith("c2.")); + } + + @TargetApi(29) + private static boolean isSoftwareOnlyV29(android.media.MediaCodecInfo codecInfo) { + return codecInfo.isSoftwareOnly(); + } + + /** + * The result of {@link android.media.MediaCodecInfo#isVendor()} for API levels 29+, or a + * best-effort approximation for lower levels. + */ + private static boolean isVendor(android.media.MediaCodecInfo codecInfo) { + if (Util.SDK_INT >= 29) { + return isVendorV29(codecInfo); + } + String codecName = Util.toLowerInvariant(codecInfo.getName()); + return !codecName.startsWith("omx.google.") + && !codecName.startsWith("c2.android.") + && !codecName.startsWith("c2.google."); + } + + @TargetApi(29) + private static boolean isVendorV29(android.media.MediaCodecInfo codecInfo) { + return codecInfo.isVendor(); + } + + /** + * Returns whether the decoder is known to fail when adapting, despite advertising itself as an + * adaptive decoder. + * + * @param name The decoder name. + * @return True if the decoder is known to fail when adapting. + */ + private static boolean codecNeedsDisableAdaptationWorkaround(String name) { + return Util.SDK_INT <= 22 + && ("ODROID-XU3".equals(Util.MODEL) || "Nexus 10".equals(Util.MODEL)) + && ("OMX.Exynos.AVC.Decoder".equals(name) || "OMX.Exynos.AVC.Decoder.secure".equals(name)); + } + + @Nullable + private static Pair<Integer, Integer> getDolbyVisionProfileAndLevel( + String codec, String[] parts) { + if (parts.length < 3) { + // The codec has fewer parts than required by the Dolby Vision codec string format. + Log.w(TAG, "Ignoring malformed Dolby Vision codec string: " + codec); + return null; + } + // The profile_space gets ignored. + Matcher matcher = PROFILE_PATTERN.matcher(parts[1]); + if (!matcher.matches()) { + Log.w(TAG, "Ignoring malformed Dolby Vision codec string: " + codec); + return null; + } + @Nullable String profileString = matcher.group(1); + @Nullable Integer profile = DOLBY_VISION_STRING_TO_PROFILE.get(profileString); + if (profile == null) { + Log.w(TAG, "Unknown Dolby Vision profile string: " + profileString); + return null; + } + String levelString = parts[2]; + @Nullable Integer level = DOLBY_VISION_STRING_TO_LEVEL.get(levelString); + if (level == null) { + Log.w(TAG, "Unknown Dolby Vision level string: " + levelString); + return null; + } + return new Pair<>(profile, level); + } + + @Nullable + private static Pair<Integer, Integer> getHevcProfileAndLevel(String codec, String[] parts) { + if (parts.length < 4) { + // The codec has fewer parts than required by the HEVC codec string format. + Log.w(TAG, "Ignoring malformed HEVC codec string: " + codec); + return null; + } + // The profile_space gets ignored. + Matcher matcher = PROFILE_PATTERN.matcher(parts[1]); + if (!matcher.matches()) { + Log.w(TAG, "Ignoring malformed HEVC codec string: " + codec); + return null; + } + @Nullable String profileString = matcher.group(1); + int profile; + if ("1".equals(profileString)) { + profile = CodecProfileLevel.HEVCProfileMain; + } else if ("2".equals(profileString)) { + profile = CodecProfileLevel.HEVCProfileMain10; + } else { + Log.w(TAG, "Unknown HEVC profile string: " + profileString); + return null; + } + @Nullable String levelString = parts[3]; + @Nullable Integer level = HEVC_CODEC_STRING_TO_PROFILE_LEVEL.get(levelString); + if (level == null) { + Log.w(TAG, "Unknown HEVC level string: " + levelString); + return null; + } + return new Pair<>(profile, level); + } + + @Nullable + private static Pair<Integer, Integer> getAvcProfileAndLevel(String codec, String[] parts) { + if (parts.length < 2) { + // The codec has fewer parts than required by the AVC codec string format. + Log.w(TAG, "Ignoring malformed AVC codec string: " + codec); + return null; + } + int profileInteger; + int levelInteger; + try { + if (parts[1].length() == 6) { + // Format: avc1.xxccyy, where xx is profile and yy level, both hexadecimal. + profileInteger = Integer.parseInt(parts[1].substring(0, 2), 16); + levelInteger = Integer.parseInt(parts[1].substring(4), 16); + } else if (parts.length >= 3) { + // Format: avc1.xx.[y]yy where xx is profile and [y]yy level, both decimal. + profileInteger = Integer.parseInt(parts[1]); + levelInteger = Integer.parseInt(parts[2]); + } else { + // We don't recognize the format. + Log.w(TAG, "Ignoring malformed AVC codec string: " + codec); + return null; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed AVC codec string: " + codec); + return null; + } + + int profile = AVC_PROFILE_NUMBER_TO_CONST.get(profileInteger, -1); + if (profile == -1) { + Log.w(TAG, "Unknown AVC profile: " + profileInteger); + return null; + } + int level = AVC_LEVEL_NUMBER_TO_CONST.get(levelInteger, -1); + if (level == -1) { + Log.w(TAG, "Unknown AVC level: " + levelInteger); + return null; + } + return new Pair<>(profile, level); + } + + @Nullable + private static Pair<Integer, Integer> getVp9ProfileAndLevel(String codec, String[] parts) { + if (parts.length < 3) { + Log.w(TAG, "Ignoring malformed VP9 codec string: " + codec); + return null; + } + int profileInteger; + int levelInteger; + try { + profileInteger = Integer.parseInt(parts[1]); + levelInteger = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed VP9 codec string: " + codec); + return null; + } + + int profile = VP9_PROFILE_NUMBER_TO_CONST.get(profileInteger, -1); + if (profile == -1) { + Log.w(TAG, "Unknown VP9 profile: " + profileInteger); + return null; + } + int level = VP9_LEVEL_NUMBER_TO_CONST.get(levelInteger, -1); + if (level == -1) { + Log.w(TAG, "Unknown VP9 level: " + levelInteger); + return null; + } + return new Pair<>(profile, level); + } + + @Nullable + private static Pair<Integer, Integer> getAv1ProfileAndLevel( + String codec, String[] parts, @Nullable ColorInfo colorInfo) { + if (parts.length < 4) { + Log.w(TAG, "Ignoring malformed AV1 codec string: " + codec); + return null; + } + int profileInteger; + int levelInteger; + int bitDepthInteger; + try { + profileInteger = Integer.parseInt(parts[1]); + levelInteger = Integer.parseInt(parts[2].substring(0, 2)); + bitDepthInteger = Integer.parseInt(parts[3]); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed AV1 codec string: " + codec); + return null; + } + + if (profileInteger != 0) { + Log.w(TAG, "Unknown AV1 profile: " + profileInteger); + return null; + } + if (bitDepthInteger != 8 && bitDepthInteger != 10) { + Log.w(TAG, "Unknown AV1 bit depth: " + bitDepthInteger); + return null; + } + int profile; + if (bitDepthInteger == 8) { + profile = CodecProfileLevel.AV1ProfileMain8; + } else if (colorInfo != null + && (colorInfo.hdrStaticInfo != null + || colorInfo.colorTransfer == C.COLOR_TRANSFER_HLG + || colorInfo.colorTransfer == C.COLOR_TRANSFER_ST2084)) { + profile = CodecProfileLevel.AV1ProfileMain10HDR10; + } else { + profile = CodecProfileLevel.AV1ProfileMain10; + } + + int level = AV1_LEVEL_NUMBER_TO_CONST.get(levelInteger, -1); + if (level == -1) { + Log.w(TAG, "Unknown AV1 level: " + levelInteger); + return null; + } + return new Pair<>(profile, level); + } + + /** + * Conversion values taken from ISO 14496-10 Table A-1. + * + * @param avcLevel one of CodecProfileLevel.AVCLevel* constants. + * @return maximum frame size that can be decoded by a decoder with the specified avc level + * (or {@code -1} if the level is not recognized) + */ + private static int avcLevelToMaxFrameSize(int avcLevel) { + switch (avcLevel) { + case CodecProfileLevel.AVCLevel1: + case CodecProfileLevel.AVCLevel1b: + return 99 * 16 * 16; + case CodecProfileLevel.AVCLevel12: + case CodecProfileLevel.AVCLevel13: + case CodecProfileLevel.AVCLevel2: + return 396 * 16 * 16; + case CodecProfileLevel.AVCLevel21: + return 792 * 16 * 16; + case CodecProfileLevel.AVCLevel22: + case CodecProfileLevel.AVCLevel3: + return 1620 * 16 * 16; + case CodecProfileLevel.AVCLevel31: + return 3600 * 16 * 16; + case CodecProfileLevel.AVCLevel32: + return 5120 * 16 * 16; + case CodecProfileLevel.AVCLevel4: + case CodecProfileLevel.AVCLevel41: + return 8192 * 16 * 16; + case CodecProfileLevel.AVCLevel42: + return 8704 * 16 * 16; + case CodecProfileLevel.AVCLevel5: + return 22080 * 16 * 16; + case CodecProfileLevel.AVCLevel51: + case CodecProfileLevel.AVCLevel52: + return 36864 * 16 * 16; + default: + return -1; + } + } + + @Nullable + private static Pair<Integer, Integer> getAacCodecProfileAndLevel(String codec, String[] parts) { + if (parts.length != 3) { + Log.w(TAG, "Ignoring malformed MP4A codec string: " + codec); + return null; + } + try { + // Get the object type indication, which is a hexadecimal value (see RFC 6381/ISO 14496-1). + int objectTypeIndication = Integer.parseInt(parts[1], 16); + @Nullable String mimeType = MimeTypes.getMimeTypeFromMp4ObjectType(objectTypeIndication); + if (MimeTypes.AUDIO_AAC.equals(mimeType)) { + // For MPEG-4 audio this is followed by an audio object type indication as a decimal number. + int audioObjectTypeIndication = Integer.parseInt(parts[2]); + int profile = MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.get(audioObjectTypeIndication, -1); + if (profile != -1) { + // Level is set to zero in AAC decoder CodecProfileLevels. + return new Pair<>(profile, 0); + } + } + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed MP4A codec string: " + codec); + } + return null; + } + + /** Stably sorts the provided {@code list} in-place, in order of decreasing score. */ + private static <T> void sortByScore(List<T> list, ScoreProvider<T> scoreProvider) { + Collections.sort(list, (a, b) -> scoreProvider.getScore(b) - scoreProvider.getScore(a)); + } + + /** Interface for providers of item scores. */ + private interface ScoreProvider<T> { + /** Returns the score of the provided item. */ + int getScore(T t); + } + + private interface MediaCodecListCompat { + + /** + * The number of codecs in the list. + */ + int getCodecCount(); + + /** + * The info at the specified index in the list. + * + * @param index The index. + */ + android.media.MediaCodecInfo getCodecInfoAt(int index); + + /** + * Returns whether secure decoders are explicitly listed, if present. + */ + boolean secureDecodersExplicit(); + + /** Whether the specified {@link CodecCapabilities} {@code feature} is supported. */ + boolean isFeatureSupported(String feature, String mimeType, CodecCapabilities capabilities); + + /** Whether the specified {@link CodecCapabilities} {@code feature} is required. */ + boolean isFeatureRequired(String feature, String mimeType, CodecCapabilities capabilities); + } + + @TargetApi(21) + private static final class MediaCodecListCompatV21 implements MediaCodecListCompat { + + private final int codecKind; + + @Nullable private android.media.MediaCodecInfo[] mediaCodecInfos; + + // the constructor does not initialize fields: mediaCodecInfos + @SuppressWarnings("nullness:initialization.fields.uninitialized") + public MediaCodecListCompatV21(boolean includeSecure, boolean includeTunneling) { + codecKind = + includeSecure || includeTunneling + ? MediaCodecList.ALL_CODECS + : MediaCodecList.REGULAR_CODECS; + } + + @Override + public int getCodecCount() { + ensureMediaCodecInfosInitialized(); + return mediaCodecInfos.length; + } + + // incompatible types in return. + @SuppressWarnings("nullness:return.type.incompatible") + @Override + public android.media.MediaCodecInfo getCodecInfoAt(int index) { + ensureMediaCodecInfosInitialized(); + return mediaCodecInfos[index]; + } + + @Override + public boolean secureDecodersExplicit() { + return true; + } + + @Override + public boolean isFeatureSupported( + String feature, String mimeType, CodecCapabilities capabilities) { + return capabilities.isFeatureSupported(feature); + } + + @Override + public boolean isFeatureRequired( + String feature, String mimeType, CodecCapabilities capabilities) { + return capabilities.isFeatureRequired(feature); + } + + @EnsuresNonNull({"mediaCodecInfos"}) + private void ensureMediaCodecInfosInitialized() { + if (mediaCodecInfos == null) { + mediaCodecInfos = new MediaCodecList(codecKind).getCodecInfos(); + } + } + + } + + private static final class MediaCodecListCompatV16 implements MediaCodecListCompat { + + @Override + public int getCodecCount() { + return MediaCodecList.getCodecCount(); + } + + @Override + public android.media.MediaCodecInfo getCodecInfoAt(int index) { + return MediaCodecList.getCodecInfoAt(index); + } + + @Override + public boolean secureDecodersExplicit() { + return false; + } + + @Override + public boolean isFeatureSupported( + String feature, String mimeType, CodecCapabilities capabilities) { + // Secure decoders weren't explicitly listed prior to API level 21. We assume that a secure + // H264 decoder exists. + return CodecCapabilities.FEATURE_SecurePlayback.equals(feature) + && MimeTypes.VIDEO_H264.equals(mimeType); + } + + @Override + public boolean isFeatureRequired( + String feature, String mimeType, CodecCapabilities capabilities) { + return false; + } + + } + + private static final class CodecKey { + + public final String mimeType; + public final boolean secure; + public final boolean tunneling; + + public CodecKey(String mimeType, boolean secure, boolean tunneling) { + this.mimeType = mimeType; + this.secure = secure; + this.tunneling = tunneling; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + mimeType.hashCode(); + result = prime * result + (secure ? 1231 : 1237); + result = prime * result + (tunneling ? 1231 : 1237); + return result; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || obj.getClass() != CodecKey.class) { + return false; + } + CodecKey other = (CodecKey) obj; + return TextUtils.equals(mimeType, other.mimeType) + && secure == other.secure + && tunneling == other.tunneling; + } + + } + + static { + AVC_PROFILE_NUMBER_TO_CONST = new SparseIntArray(); + AVC_PROFILE_NUMBER_TO_CONST.put(66, CodecProfileLevel.AVCProfileBaseline); + AVC_PROFILE_NUMBER_TO_CONST.put(77, CodecProfileLevel.AVCProfileMain); + AVC_PROFILE_NUMBER_TO_CONST.put(88, CodecProfileLevel.AVCProfileExtended); + AVC_PROFILE_NUMBER_TO_CONST.put(100, CodecProfileLevel.AVCProfileHigh); + AVC_PROFILE_NUMBER_TO_CONST.put(110, CodecProfileLevel.AVCProfileHigh10); + AVC_PROFILE_NUMBER_TO_CONST.put(122, CodecProfileLevel.AVCProfileHigh422); + AVC_PROFILE_NUMBER_TO_CONST.put(244, CodecProfileLevel.AVCProfileHigh444); + + AVC_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); + AVC_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.AVCLevel1); + // TODO: Find int for CodecProfileLevel.AVCLevel1b. + AVC_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.AVCLevel11); + AVC_LEVEL_NUMBER_TO_CONST.put(12, CodecProfileLevel.AVCLevel12); + AVC_LEVEL_NUMBER_TO_CONST.put(13, CodecProfileLevel.AVCLevel13); + AVC_LEVEL_NUMBER_TO_CONST.put(20, CodecProfileLevel.AVCLevel2); + AVC_LEVEL_NUMBER_TO_CONST.put(21, CodecProfileLevel.AVCLevel21); + AVC_LEVEL_NUMBER_TO_CONST.put(22, CodecProfileLevel.AVCLevel22); + AVC_LEVEL_NUMBER_TO_CONST.put(30, CodecProfileLevel.AVCLevel3); + AVC_LEVEL_NUMBER_TO_CONST.put(31, CodecProfileLevel.AVCLevel31); + AVC_LEVEL_NUMBER_TO_CONST.put(32, CodecProfileLevel.AVCLevel32); + AVC_LEVEL_NUMBER_TO_CONST.put(40, CodecProfileLevel.AVCLevel4); + AVC_LEVEL_NUMBER_TO_CONST.put(41, CodecProfileLevel.AVCLevel41); + AVC_LEVEL_NUMBER_TO_CONST.put(42, CodecProfileLevel.AVCLevel42); + AVC_LEVEL_NUMBER_TO_CONST.put(50, CodecProfileLevel.AVCLevel5); + AVC_LEVEL_NUMBER_TO_CONST.put(51, CodecProfileLevel.AVCLevel51); + AVC_LEVEL_NUMBER_TO_CONST.put(52, CodecProfileLevel.AVCLevel52); + + VP9_PROFILE_NUMBER_TO_CONST = new SparseIntArray(); + VP9_PROFILE_NUMBER_TO_CONST.put(0, CodecProfileLevel.VP9Profile0); + VP9_PROFILE_NUMBER_TO_CONST.put(1, CodecProfileLevel.VP9Profile1); + VP9_PROFILE_NUMBER_TO_CONST.put(2, CodecProfileLevel.VP9Profile2); + VP9_PROFILE_NUMBER_TO_CONST.put(3, CodecProfileLevel.VP9Profile3); + VP9_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); + VP9_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.VP9Level1); + VP9_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.VP9Level11); + VP9_LEVEL_NUMBER_TO_CONST.put(20, CodecProfileLevel.VP9Level2); + VP9_LEVEL_NUMBER_TO_CONST.put(21, CodecProfileLevel.VP9Level21); + VP9_LEVEL_NUMBER_TO_CONST.put(30, CodecProfileLevel.VP9Level3); + VP9_LEVEL_NUMBER_TO_CONST.put(31, CodecProfileLevel.VP9Level31); + VP9_LEVEL_NUMBER_TO_CONST.put(40, CodecProfileLevel.VP9Level4); + VP9_LEVEL_NUMBER_TO_CONST.put(41, CodecProfileLevel.VP9Level41); + VP9_LEVEL_NUMBER_TO_CONST.put(50, CodecProfileLevel.VP9Level5); + VP9_LEVEL_NUMBER_TO_CONST.put(51, CodecProfileLevel.VP9Level51); + VP9_LEVEL_NUMBER_TO_CONST.put(60, CodecProfileLevel.VP9Level6); + VP9_LEVEL_NUMBER_TO_CONST.put(61, CodecProfileLevel.VP9Level61); + VP9_LEVEL_NUMBER_TO_CONST.put(62, CodecProfileLevel.VP9Level62); + + HEVC_CODEC_STRING_TO_PROFILE_LEVEL = new HashMap<>(); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L30", CodecProfileLevel.HEVCMainTierLevel1); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L60", CodecProfileLevel.HEVCMainTierLevel2); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L63", CodecProfileLevel.HEVCMainTierLevel21); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L90", CodecProfileLevel.HEVCMainTierLevel3); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L93", CodecProfileLevel.HEVCMainTierLevel31); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L120", CodecProfileLevel.HEVCMainTierLevel4); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L123", CodecProfileLevel.HEVCMainTierLevel41); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L150", CodecProfileLevel.HEVCMainTierLevel5); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L153", CodecProfileLevel.HEVCMainTierLevel51); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L156", CodecProfileLevel.HEVCMainTierLevel52); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L180", CodecProfileLevel.HEVCMainTierLevel6); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L183", CodecProfileLevel.HEVCMainTierLevel61); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("L186", CodecProfileLevel.HEVCMainTierLevel62); + + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H30", CodecProfileLevel.HEVCHighTierLevel1); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H60", CodecProfileLevel.HEVCHighTierLevel2); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H63", CodecProfileLevel.HEVCHighTierLevel21); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H90", CodecProfileLevel.HEVCHighTierLevel3); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H93", CodecProfileLevel.HEVCHighTierLevel31); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H120", CodecProfileLevel.HEVCHighTierLevel4); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H123", CodecProfileLevel.HEVCHighTierLevel41); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H150", CodecProfileLevel.HEVCHighTierLevel5); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H153", CodecProfileLevel.HEVCHighTierLevel51); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H156", CodecProfileLevel.HEVCHighTierLevel52); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H180", CodecProfileLevel.HEVCHighTierLevel6); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H183", CodecProfileLevel.HEVCHighTierLevel61); + HEVC_CODEC_STRING_TO_PROFILE_LEVEL.put("H186", CodecProfileLevel.HEVCHighTierLevel62); + + DOLBY_VISION_STRING_TO_PROFILE = new HashMap<>(); + DOLBY_VISION_STRING_TO_PROFILE.put("00", CodecProfileLevel.DolbyVisionProfileDvavPer); + DOLBY_VISION_STRING_TO_PROFILE.put("01", CodecProfileLevel.DolbyVisionProfileDvavPen); + DOLBY_VISION_STRING_TO_PROFILE.put("02", CodecProfileLevel.DolbyVisionProfileDvheDer); + DOLBY_VISION_STRING_TO_PROFILE.put("03", CodecProfileLevel.DolbyVisionProfileDvheDen); + DOLBY_VISION_STRING_TO_PROFILE.put("04", CodecProfileLevel.DolbyVisionProfileDvheDtr); + DOLBY_VISION_STRING_TO_PROFILE.put("05", CodecProfileLevel.DolbyVisionProfileDvheStn); + DOLBY_VISION_STRING_TO_PROFILE.put("06", CodecProfileLevel.DolbyVisionProfileDvheDth); + DOLBY_VISION_STRING_TO_PROFILE.put("07", CodecProfileLevel.DolbyVisionProfileDvheDtb); + DOLBY_VISION_STRING_TO_PROFILE.put("08", CodecProfileLevel.DolbyVisionProfileDvheSt); + DOLBY_VISION_STRING_TO_PROFILE.put("09", CodecProfileLevel.DolbyVisionProfileDvavSe); + + DOLBY_VISION_STRING_TO_LEVEL = new HashMap<>(); + DOLBY_VISION_STRING_TO_LEVEL.put("01", CodecProfileLevel.DolbyVisionLevelHd24); + DOLBY_VISION_STRING_TO_LEVEL.put("02", CodecProfileLevel.DolbyVisionLevelHd30); + DOLBY_VISION_STRING_TO_LEVEL.put("03", CodecProfileLevel.DolbyVisionLevelFhd24); + DOLBY_VISION_STRING_TO_LEVEL.put("04", CodecProfileLevel.DolbyVisionLevelFhd30); + DOLBY_VISION_STRING_TO_LEVEL.put("05", CodecProfileLevel.DolbyVisionLevelFhd60); + DOLBY_VISION_STRING_TO_LEVEL.put("06", CodecProfileLevel.DolbyVisionLevelUhd24); + DOLBY_VISION_STRING_TO_LEVEL.put("07", CodecProfileLevel.DolbyVisionLevelUhd30); + DOLBY_VISION_STRING_TO_LEVEL.put("08", CodecProfileLevel.DolbyVisionLevelUhd48); + DOLBY_VISION_STRING_TO_LEVEL.put("09", CodecProfileLevel.DolbyVisionLevelUhd60); + + // See https://aomediacodec.github.io/av1-spec/av1-spec.pdf Annex A: Profiles and levels for + // more information on mapping AV1 codec strings to levels. + AV1_LEVEL_NUMBER_TO_CONST = new SparseIntArray(); + AV1_LEVEL_NUMBER_TO_CONST.put(0, CodecProfileLevel.AV1Level2); + AV1_LEVEL_NUMBER_TO_CONST.put(1, CodecProfileLevel.AV1Level21); + AV1_LEVEL_NUMBER_TO_CONST.put(2, CodecProfileLevel.AV1Level22); + AV1_LEVEL_NUMBER_TO_CONST.put(3, CodecProfileLevel.AV1Level23); + AV1_LEVEL_NUMBER_TO_CONST.put(4, CodecProfileLevel.AV1Level3); + AV1_LEVEL_NUMBER_TO_CONST.put(5, CodecProfileLevel.AV1Level31); + AV1_LEVEL_NUMBER_TO_CONST.put(6, CodecProfileLevel.AV1Level32); + AV1_LEVEL_NUMBER_TO_CONST.put(7, CodecProfileLevel.AV1Level33); + AV1_LEVEL_NUMBER_TO_CONST.put(8, CodecProfileLevel.AV1Level4); + AV1_LEVEL_NUMBER_TO_CONST.put(9, CodecProfileLevel.AV1Level41); + AV1_LEVEL_NUMBER_TO_CONST.put(10, CodecProfileLevel.AV1Level42); + AV1_LEVEL_NUMBER_TO_CONST.put(11, CodecProfileLevel.AV1Level43); + AV1_LEVEL_NUMBER_TO_CONST.put(12, CodecProfileLevel.AV1Level5); + AV1_LEVEL_NUMBER_TO_CONST.put(13, CodecProfileLevel.AV1Level51); + AV1_LEVEL_NUMBER_TO_CONST.put(14, CodecProfileLevel.AV1Level52); + AV1_LEVEL_NUMBER_TO_CONST.put(15, CodecProfileLevel.AV1Level53); + AV1_LEVEL_NUMBER_TO_CONST.put(16, CodecProfileLevel.AV1Level6); + AV1_LEVEL_NUMBER_TO_CONST.put(17, CodecProfileLevel.AV1Level61); + AV1_LEVEL_NUMBER_TO_CONST.put(18, CodecProfileLevel.AV1Level62); + AV1_LEVEL_NUMBER_TO_CONST.put(19, CodecProfileLevel.AV1Level63); + AV1_LEVEL_NUMBER_TO_CONST.put(20, CodecProfileLevel.AV1Level7); + AV1_LEVEL_NUMBER_TO_CONST.put(21, CodecProfileLevel.AV1Level71); + AV1_LEVEL_NUMBER_TO_CONST.put(22, CodecProfileLevel.AV1Level72); + AV1_LEVEL_NUMBER_TO_CONST.put(23, CodecProfileLevel.AV1Level73); + + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE = new SparseIntArray(); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(1, CodecProfileLevel.AACObjectMain); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(2, CodecProfileLevel.AACObjectLC); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(3, CodecProfileLevel.AACObjectSSR); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(4, CodecProfileLevel.AACObjectLTP); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(5, CodecProfileLevel.AACObjectHE); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(6, CodecProfileLevel.AACObjectScalable); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(17, CodecProfileLevel.AACObjectERLC); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(20, CodecProfileLevel.AACObjectERScalable); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(23, CodecProfileLevel.AACObjectLD); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(29, CodecProfileLevel.AACObjectHE_PS); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(39, CodecProfileLevel.AACObjectELD); + MP4A_AUDIO_OBJECT_TYPE_TO_PROFILE.put(42, CodecProfileLevel.AACObjectXHE); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java new file mode 100644 index 0000000000..cafaaa7c83 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/MediaFormatUtil.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec; + +import android.media.MediaFormat; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.ColorInfo; +import java.nio.ByteBuffer; +import java.util.List; + +/** Helper class for configuring {@link MediaFormat} instances. */ +public final class MediaFormatUtil { + + private MediaFormatUtil() {} + + /** + * Sets a {@link MediaFormat} {@link String} value. + * + * @param format The {@link MediaFormat} being configured. + * @param key The key to set. + * @param value The value to set. + */ + public static void setString(MediaFormat format, String key, String value) { + format.setString(key, value); + } + + /** + * Sets a {@link MediaFormat}'s codec specific data buffers. + * + * @param format The {@link MediaFormat} being configured. + * @param csdBuffers The csd buffers to set. + */ + public static void setCsdBuffers(MediaFormat format, List<byte[]> csdBuffers) { + for (int i = 0; i < csdBuffers.size(); i++) { + format.setByteBuffer("csd-" + i, ByteBuffer.wrap(csdBuffers.get(i))); + } + } + + /** + * Sets a {@link MediaFormat} integer value. Does nothing if {@code value} is {@link + * Format#NO_VALUE}. + * + * @param format The {@link MediaFormat} being configured. + * @param key The key to set. + * @param value The value to set. + */ + public static void maybeSetInteger(MediaFormat format, String key, int value) { + if (value != Format.NO_VALUE) { + format.setInteger(key, value); + } + } + + /** + * Sets a {@link MediaFormat} float value. Does nothing if {@code value} is {@link + * Format#NO_VALUE}. + * + * @param format The {@link MediaFormat} being configured. + * @param key The key to set. + * @param value The value to set. + */ + public static void maybeSetFloat(MediaFormat format, String key, float value) { + if (value != Format.NO_VALUE) { + format.setFloat(key, value); + } + } + + /** + * Sets a {@link MediaFormat} {@link ByteBuffer} value. Does nothing if {@code value} is null. + * + * @param format The {@link MediaFormat} being configured. + * @param key The key to set. + * @param value The {@link byte[]} that will be wrapped to obtain the value. + */ + public static void maybeSetByteBuffer(MediaFormat format, String key, @Nullable byte[] value) { + if (value != null) { + format.setByteBuffer(key, ByteBuffer.wrap(value)); + } + } + + /** + * Sets a {@link MediaFormat}'s color information. Does nothing if {@code colorInfo} is null. + * + * @param format The {@link MediaFormat} being configured. + * @param colorInfo The color info to set. + */ + @SuppressWarnings("InlinedApi") + public static void maybeSetColorInfo(MediaFormat format, @Nullable ColorInfo colorInfo) { + if (colorInfo != null) { + maybeSetInteger(format, MediaFormat.KEY_COLOR_TRANSFER, colorInfo.colorTransfer); + maybeSetInteger(format, MediaFormat.KEY_COLOR_STANDARD, colorInfo.colorSpace); + maybeSetInteger(format, MediaFormat.KEY_COLOR_RANGE, colorInfo.colorRange); + maybeSetByteBuffer(format, MediaFormat.KEY_HDR_STATIC_INFO, colorInfo.hdrStaticInfo); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/package-info.java new file mode 100644 index 0000000000..c8dd17d0df --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/mediacodec/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/Metadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/Metadata.java new file mode 100644 index 0000000000..16f01c4627 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/Metadata.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.List; + +/** + * A collection of metadata entries. + */ +public final class Metadata implements Parcelable { + + /** A metadata entry. */ + public interface Entry extends Parcelable { + + /** + * Returns the {@link Format} that can be used to decode the wrapped metadata in {@link + * #getWrappedMetadataBytes()}, or null if this Entry doesn't contain wrapped metadata. + */ + @Nullable + default Format getWrappedMetadataFormat() { + return null; + } + + /** + * Returns the bytes of the wrapped metadata in this Entry, or null if it doesn't contain + * wrapped metadata. + */ + @Nullable + default byte[] getWrappedMetadataBytes() { + return null; + } + } + + private final Entry[] entries; + + /** + * @param entries The metadata entries. + */ + public Metadata(Entry... entries) { + this.entries = entries; + } + + /** + * @param entries The metadata entries. + */ + public Metadata(List<? extends Entry> entries) { + this.entries = new Entry[entries.size()]; + entries.toArray(this.entries); + } + + /* package */ Metadata(Parcel in) { + entries = new Metadata.Entry[in.readInt()]; + for (int i = 0; i < entries.length; i++) { + entries[i] = in.readParcelable(Entry.class.getClassLoader()); + } + } + + /** + * Returns the number of metadata entries. + */ + public int length() { + return entries.length; + } + + /** + * Returns the entry at the specified index. + * + * @param index The index of the entry. + * @return The entry at the specified index. + */ + public Metadata.Entry get(int index) { + return entries[index]; + } + + /** + * Returns a copy of this metadata with the entries of the specified metadata appended. Returns + * this instance if {@code other} is null. + * + * @param other The metadata that holds the entries to append. If null, this methods returns this + * instance. + * @return The metadata instance with the appended entries. + */ + public Metadata copyWithAppendedEntriesFrom(@Nullable Metadata other) { + if (other == null) { + return this; + } + return copyWithAppendedEntries(other.entries); + } + + /** + * Returns a copy of this metadata with the specified entries appended. + * + * @param entriesToAppend The entries to append. + * @return The metadata instance with the appended entries. + */ + public Metadata copyWithAppendedEntries(Entry... entriesToAppend) { + if (entriesToAppend.length == 0) { + return this; + } + return new Metadata(Util.nullSafeArrayConcatenation(entries, entriesToAppend)); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Metadata other = (Metadata) obj; + return Arrays.equals(entries, other.entries); + } + + @Override + public int hashCode() { + return Arrays.hashCode(entries); + } + + @Override + public String toString() { + return "entries=" + Arrays.toString(entries); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(entries.length); + for (Entry entry : entries) { + dest.writeParcelable(entry, 0); + } + } + + public static final Parcelable.Creator<Metadata> CREATOR = + new Parcelable.Creator<Metadata>() { + @Override + public Metadata createFromParcel(Parcel in) { + return new Metadata(in); + } + + @Override + public Metadata[] newArray(int size) { + return new Metadata[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataDecoder.java new file mode 100644 index 0000000000..1bc1c7dc06 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataDecoder.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +import androidx.annotation.Nullable; + +/** + * Decodes metadata from binary data. + */ +public interface MetadataDecoder { + + /** + * Decodes a {@link Metadata} element from the provided input buffer. + * + * @param inputBuffer The input buffer to decode. + * @return The decoded metadata object, or null if the metadata could not be decoded. + */ + @Nullable + Metadata decode(MetadataInputBuffer inputBuffer); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java new file mode 100644 index 0000000000..30f6aad4a9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataDecoderFactory.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy.IcyDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35.SpliceInfoDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +/** + * A factory for {@link MetadataDecoder} instances. + */ +public interface MetadataDecoderFactory { + + /** + * Returns whether the factory is able to instantiate a {@link MetadataDecoder} for the given + * {@link Format}. + * + * @param format The {@link Format}. + * @return Whether the factory can instantiate a suitable {@link MetadataDecoder}. + */ + boolean supportsFormat(Format format); + + /** + * Creates a {@link MetadataDecoder} for the given {@link Format}. + * + * @param format The {@link Format}. + * @return A new {@link MetadataDecoder}. + * @throws IllegalArgumentException If the {@link Format} is not supported. + */ + MetadataDecoder createDecoder(Format format); + + /** + * Default {@link MetadataDecoder} implementation. + * + * <p>The formats supported by this factory are: + * + * <ul> + * <li>ID3 ({@link Id3Decoder}) + * <li>EMSG ({@link EventMessageDecoder}) + * <li>SCTE-35 ({@link SpliceInfoDecoder}) + * <li>ICY ({@link IcyDecoder}) + * </ul> + */ + MetadataDecoderFactory DEFAULT = + new MetadataDecoderFactory() { + + @Override + public boolean supportsFormat(Format format) { + @Nullable String mimeType = format.sampleMimeType; + return MimeTypes.APPLICATION_ID3.equals(mimeType) + || MimeTypes.APPLICATION_EMSG.equals(mimeType) + || MimeTypes.APPLICATION_SCTE35.equals(mimeType) + || MimeTypes.APPLICATION_ICY.equals(mimeType); + } + + @Override + public MetadataDecoder createDecoder(Format format) { + @Nullable String mimeType = format.sampleMimeType; + if (mimeType != null) { + switch (mimeType) { + case MimeTypes.APPLICATION_ID3: + return new Id3Decoder(); + case MimeTypes.APPLICATION_EMSG: + return new EventMessageDecoder(); + case MimeTypes.APPLICATION_SCTE35: + return new SpliceInfoDecoder(); + case MimeTypes.APPLICATION_ICY: + return new IcyDecoder(); + default: + break; + } + } + throw new IllegalArgumentException( + "Attempted to create decoder for unsupported MIME type: " + mimeType); + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataInputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataInputBuffer.java new file mode 100644 index 0000000000..9a265744ec --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataInputBuffer.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; + +/** + * A {@link DecoderInputBuffer} for a {@link MetadataDecoder}. + */ +public final class MetadataInputBuffer extends DecoderInputBuffer { + + /** + * An offset that must be added to the metadata's timestamps after it's been decoded, or + * {@link Format#OFFSET_SAMPLE_RELATIVE} if {@link #timeUs} should be added. + */ + public long subsampleOffsetUs; + + public MetadataInputBuffer() { + super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataOutput.java new file mode 100644 index 0000000000..025f9f01bc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataOutput.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +/** + * Receives metadata output. + */ +public interface MetadataOutput { + + /** + * Called when there is metadata associated with current playback time. + * + * @param metadata The metadata. + */ + void onMetadata(Metadata metadata); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataRenderer.java new file mode 100644 index 0000000000..329f9ffa7d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/MetadataRenderer.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Looper; +import android.os.Message; +import androidx.annotation.Nullable; +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.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.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A renderer for metadata. + */ +public final class MetadataRenderer extends BaseRenderer implements Callback { + + private static final int MSG_INVOKE_RENDERER = 0; + // TODO: Holding multiple pending metadata objects is temporary mitigation against + // https://github.com/google/ExoPlayer/issues/1874. It should be removed once this issue has been + // addressed. + private static final int MAX_PENDING_METADATA_COUNT = 5; + + private final MetadataDecoderFactory decoderFactory; + private final MetadataOutput output; + @Nullable private final Handler outputHandler; + private final MetadataInputBuffer buffer; + private final @NullableType Metadata[] pendingMetadata; + private final long[] pendingMetadataTimestamps; + + private int pendingMetadataIndex; + private int pendingMetadataCount; + @Nullable private MetadataDecoder decoder; + private boolean inputStreamEnded; + private long subsampleOffsetUs; + + /** + * @param output The output. + * @param outputLooper The looper associated with the thread on which the output should be called. + * If the output makes use of standard Android UI components, then this should normally be the + * looper associated with the application's main thread, which can be obtained using {@link + * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called + * directly on the player's internal rendering thread. + */ + public MetadataRenderer(MetadataOutput output, @Nullable Looper outputLooper) { + this(output, outputLooper, MetadataDecoderFactory.DEFAULT); + } + + /** + * @param output The output. + * @param outputLooper The looper associated with the thread on which the output should be called. + * If the output makes use of standard Android UI components, then this should normally be the + * looper associated with the application's main thread, which can be obtained using {@link + * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called + * directly on the player's internal rendering thread. + * @param decoderFactory A factory from which to obtain {@link MetadataDecoder} instances. + */ + public MetadataRenderer( + MetadataOutput output, @Nullable Looper outputLooper, MetadataDecoderFactory decoderFactory) { + super(C.TRACK_TYPE_METADATA); + this.output = Assertions.checkNotNull(output); + this.outputHandler = + outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); + this.decoderFactory = Assertions.checkNotNull(decoderFactory); + buffer = new MetadataInputBuffer(); + pendingMetadata = new Metadata[MAX_PENDING_METADATA_COUNT]; + pendingMetadataTimestamps = new long[MAX_PENDING_METADATA_COUNT]; + } + + @Override + @Capabilities + public int supportsFormat(Format format) { + if (decoderFactory.supportsFormat(format)) { + return RendererCapabilities.create( + supportsFormatDrm(null, format.drmInitData) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM); + } else { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) { + decoder = decoderFactory.createDecoder(formats[0]); + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) { + flushPendingMetadata(); + inputStreamEnded = false; + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) { + if (!inputStreamEnded && pendingMetadataCount < MAX_PENDING_METADATA_COUNT) { + buffer.clear(); + FormatHolder formatHolder = getFormatHolder(); + int result = readSource(formatHolder, buffer, false); + if (result == C.RESULT_BUFFER_READ) { + if (buffer.isEndOfStream()) { + inputStreamEnded = true; + } else if (buffer.isDecodeOnly()) { + // Do nothing. Note this assumes that all metadata buffers can be decoded independently. + // If we ever need to support a metadata format where this is not the case, we'll need to + // pass the buffer to the decoder and discard the output. + } else { + buffer.subsampleOffsetUs = subsampleOffsetUs; + buffer.flip(); + @Nullable Metadata metadata = castNonNull(decoder).decode(buffer); + if (metadata != null) { + List<Metadata.Entry> entries = new ArrayList<>(metadata.length()); + decodeWrappedMetadata(metadata, entries); + if (!entries.isEmpty()) { + Metadata expandedMetadata = new Metadata(entries); + int index = + (pendingMetadataIndex + pendingMetadataCount) % MAX_PENDING_METADATA_COUNT; + pendingMetadata[index] = expandedMetadata; + pendingMetadataTimestamps[index] = buffer.timeUs; + pendingMetadataCount++; + } + } + } + } else if (result == C.RESULT_FORMAT_READ) { + subsampleOffsetUs = Assertions.checkNotNull(formatHolder.format).subsampleOffsetUs; + } + } + + if (pendingMetadataCount > 0 && pendingMetadataTimestamps[pendingMetadataIndex] <= positionUs) { + Metadata metadata = castNonNull(pendingMetadata[pendingMetadataIndex]); + invokeRenderer(metadata); + pendingMetadata[pendingMetadataIndex] = null; + pendingMetadataIndex = (pendingMetadataIndex + 1) % MAX_PENDING_METADATA_COUNT; + pendingMetadataCount--; + } + } + + /** + * Iterates through {@code metadata.entries} and checks each one to see if contains wrapped + * metadata. If it does, then we recursively decode the wrapped metadata. If it doesn't (recursion + * base-case), we add the {@link Metadata.Entry} to {@code decodedEntries} (output parameter). + */ + private void decodeWrappedMetadata(Metadata metadata, List<Metadata.Entry> decodedEntries) { + for (int i = 0; i < metadata.length(); i++) { + @Nullable Format wrappedMetadataFormat = metadata.get(i).getWrappedMetadataFormat(); + if (wrappedMetadataFormat != null && decoderFactory.supportsFormat(wrappedMetadataFormat)) { + MetadataDecoder wrappedMetadataDecoder = + decoderFactory.createDecoder(wrappedMetadataFormat); + // wrappedMetadataFormat != null so wrappedMetadataBytes must be non-null too. + byte[] wrappedMetadataBytes = + Assertions.checkNotNull(metadata.get(i).getWrappedMetadataBytes()); + buffer.clear(); + buffer.ensureSpaceForWrite(wrappedMetadataBytes.length); + castNonNull(buffer.data).put(wrappedMetadataBytes); + buffer.flip(); + @Nullable Metadata innerMetadata = wrappedMetadataDecoder.decode(buffer); + if (innerMetadata != null) { + // The decoding succeeded, so we'll try another level of unwrapping. + decodeWrappedMetadata(innerMetadata, decodedEntries); + } + } else { + // Entry doesn't contain any wrapped metadata, so output it directly. + decodedEntries.add(metadata.get(i)); + } + } + } + + @Override + protected void onDisabled() { + flushPendingMetadata(); + decoder = null; + } + + @Override + public boolean isEnded() { + return inputStreamEnded; + } + + @Override + public boolean isReady() { + return true; + } + + private void invokeRenderer(Metadata metadata) { + if (outputHandler != null) { + outputHandler.obtainMessage(MSG_INVOKE_RENDERER, metadata).sendToTarget(); + } else { + invokeRendererInternal(metadata); + } + } + + private void flushPendingMetadata() { + Arrays.fill(pendingMetadata, null); + pendingMetadataIndex = 0; + pendingMetadataCount = 0; + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INVOKE_RENDERER: + invokeRendererInternal((Metadata) msg.obj); + return true; + default: + // Should never happen. + throw new IllegalStateException(); + } + } + + private void invokeRendererInternal(Metadata metadata) { + output.onMetadata(metadata); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessage.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessage.java new file mode 100644 index 0000000000..01aac27a27 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessage.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** An Event Message (emsg) as defined in ISO 23009-1. */ +public final class EventMessage implements Metadata.Entry { + + /** + * emsg scheme_id_uri from the <a href="https://aomediacodec.github.io/av1-id3/#semantics">CMAF + * spec</a>. + */ + @VisibleForTesting public static final String ID3_SCHEME_ID_AOM = "https://aomedia.org/emsg/ID3"; + + /** + * The Apple-hosted scheme_id equivalent to {@code ID3_SCHEME_ID_AOM} - used before AOM adoption. + */ + private static final String ID3_SCHEME_ID_APPLE = + "https://developer.apple.com/streaming/emsg-id3"; + + /** + * scheme_id_uri from section 7.3.2 of <a + * href="https://www.scte.org/SCTEDocs/Standards/ANSI_SCTE%20214-3%202015.pdf">SCTE 214-3 + * 2015</a>. + */ + @VisibleForTesting public static final String SCTE35_SCHEME_ID = "urn:scte:scte35:2014:bin"; + + private static final Format ID3_FORMAT = + Format.createSampleFormat( + /* id= */ null, MimeTypes.APPLICATION_ID3, Format.OFFSET_SAMPLE_RELATIVE); + private static final Format SCTE35_FORMAT = + Format.createSampleFormat( + /* id= */ null, MimeTypes.APPLICATION_SCTE35, Format.OFFSET_SAMPLE_RELATIVE); + + /** The message scheme. */ + public final String schemeIdUri; + + /** + * The value for the event. + */ + public final String value; + + /** + * The duration of the event in milliseconds. + */ + public final long durationMs; + + /** + * The instance identifier. + */ + public final long id; + + /** + * The body of the message. + */ + public final byte[] messageData; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * @param schemeIdUri The message scheme. + * @param value The value for the event. + * @param durationMs The duration of the event in milliseconds. + * @param id The instance identifier. + * @param messageData The body of the message. + */ + public EventMessage( + String schemeIdUri, String value, long durationMs, long id, byte[] messageData) { + this.schemeIdUri = schemeIdUri; + this.value = value; + this.durationMs = durationMs; + this.id = id; + this.messageData = messageData; + } + + /* package */ EventMessage(Parcel in) { + schemeIdUri = castNonNull(in.readString()); + value = castNonNull(in.readString()); + durationMs = in.readLong(); + id = in.readLong(); + messageData = castNonNull(in.createByteArray()); + } + + @Override + @Nullable + public Format getWrappedMetadataFormat() { + switch (schemeIdUri) { + case ID3_SCHEME_ID_AOM: + case ID3_SCHEME_ID_APPLE: + return ID3_FORMAT; + case SCTE35_SCHEME_ID: + return SCTE35_FORMAT; + default: + return null; + } + } + + @Override + @Nullable + public byte[] getWrappedMetadataBytes() { + return getWrappedMetadataFormat() != null ? messageData : null; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 17; + result = 31 * result + (schemeIdUri != null ? schemeIdUri.hashCode() : 0); + result = 31 * result + (value != null ? value.hashCode() : 0); + result = 31 * result + (int) (durationMs ^ (durationMs >>> 32)); + result = 31 * result + (int) (id ^ (id >>> 32)); + result = 31 * result + Arrays.hashCode(messageData); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + EventMessage other = (EventMessage) obj; + return durationMs == other.durationMs + && id == other.id + && Util.areEqual(schemeIdUri, other.schemeIdUri) + && Util.areEqual(value, other.value) + && Arrays.equals(messageData, other.messageData); + } + + @Override + public String toString() { + return "EMSG: scheme=" + + schemeIdUri + + ", id=" + + id + + ", durationMs=" + + durationMs + + ", value=" + + value; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(schemeIdUri); + dest.writeString(value); + dest.writeLong(durationMs); + dest.writeLong(id); + dest.writeByteArray(messageData); + } + + public static final Parcelable.Creator<EventMessage> CREATOR = + new Parcelable.Creator<EventMessage>() { + + @Override + public EventMessage createFromParcel(Parcel in) { + return new EventMessage(in); + } + + @Override + public EventMessage[] newArray(int size) { + return new EventMessage[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java new file mode 100644 index 0000000000..09b0a69395 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessageDecoder.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** Decodes data encoded by {@link EventMessageEncoder}. */ +public final class EventMessageDecoder implements MetadataDecoder { + + @SuppressWarnings("ByteBufferBackingArray") + @Override + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); + byte[] data = buffer.array(); + int size = buffer.limit(); + return new Metadata(decode(new ParsableByteArray(data, size))); + } + + public EventMessage decode(ParsableByteArray emsgData) { + String schemeIdUri = Assertions.checkNotNull(emsgData.readNullTerminatedString()); + String value = Assertions.checkNotNull(emsgData.readNullTerminatedString()); + long durationMs = emsgData.readUnsignedInt(); + long id = emsgData.readUnsignedInt(); + byte[] messageData = + Arrays.copyOfRange(emsgData.data, emsgData.getPosition(), emsgData.limit()); + return new EventMessage(schemeIdUri, value, durationMs, id, messageData); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoder.java new file mode 100644 index 0000000000..261e39ae70 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/EventMessageEncoder.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * Encodes data that can be decoded by {@link EventMessageDecoder}. This class isn't thread safe. + */ +public final class EventMessageEncoder { + + private final ByteArrayOutputStream byteArrayOutputStream; + private final DataOutputStream dataOutputStream; + + public EventMessageEncoder() { + byteArrayOutputStream = new ByteArrayOutputStream(512); + dataOutputStream = new DataOutputStream(byteArrayOutputStream); + } + + /** + * Encodes an {@link EventMessage} to a byte array that can be decoded by {@link + * EventMessageDecoder}. + * + * @param eventMessage The event message to be encoded. + * @return The serialized byte array. + */ + public byte[] encode(EventMessage eventMessage) { + byteArrayOutputStream.reset(); + try { + writeNullTerminatedString(dataOutputStream, eventMessage.schemeIdUri); + String nonNullValue = eventMessage.value != null ? eventMessage.value : ""; + writeNullTerminatedString(dataOutputStream, nonNullValue); + writeUnsignedInt(dataOutputStream, eventMessage.durationMs); + writeUnsignedInt(dataOutputStream, eventMessage.id); + dataOutputStream.write(eventMessage.messageData); + dataOutputStream.flush(); + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + private static void writeNullTerminatedString(DataOutputStream dataOutputStream, String value) + throws IOException { + dataOutputStream.writeBytes(value); + dataOutputStream.writeByte(0); + } + + private static void writeUnsignedInt(DataOutputStream outputStream, long value) + throws IOException { + outputStream.writeByte((int) (value >>> 24) & 0xFF); + outputStream.writeByte((int) (value >>> 16) & 0xFF); + outputStream.writeByte((int) (value >>> 8) & 0xFF); + outputStream.writeByte((int) value & 0xFF); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/package-info.java new file mode 100644 index 0000000000..3e54b59a8c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/emsg/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/PictureFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/PictureFrame.java new file mode 100644 index 0000000000..8a7ffbd976 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/PictureFrame.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.flac; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import java.util.Arrays; + +/** A picture parsed from a FLAC file. */ +public final class PictureFrame implements Metadata.Entry { + + /** The type of the picture. */ + public final int pictureType; + /** The mime type of the picture. */ + public final String mimeType; + /** A description of the picture. */ + public final String description; + /** The width of the picture in pixels. */ + public final int width; + /** The height of the picture in pixels. */ + public final int height; + /** The color depth of the picture in bits-per-pixel. */ + public final int depth; + /** For indexed-color pictures (e.g. GIF), the number of colors used. 0 otherwise. */ + public final int colors; + /** The encoded picture data. */ + public final byte[] pictureData; + + public PictureFrame( + int pictureType, + String mimeType, + String description, + int width, + int height, + int depth, + int colors, + byte[] pictureData) { + this.pictureType = pictureType; + this.mimeType = mimeType; + this.description = description; + this.width = width; + this.height = height; + this.depth = depth; + this.colors = colors; + this.pictureData = pictureData; + } + + /* package */ PictureFrame(Parcel in) { + this.pictureType = in.readInt(); + this.mimeType = castNonNull(in.readString()); + this.description = castNonNull(in.readString()); + this.width = in.readInt(); + this.height = in.readInt(); + this.depth = in.readInt(); + this.colors = in.readInt(); + this.pictureData = castNonNull(in.createByteArray()); + } + + @Override + public String toString() { + return "Picture: mimeType=" + mimeType + ", description=" + description; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + PictureFrame other = (PictureFrame) obj; + return (pictureType == other.pictureType) + && mimeType.equals(other.mimeType) + && description.equals(other.description) + && (width == other.width) + && (height == other.height) + && (depth == other.depth) + && (colors == other.colors) + && Arrays.equals(pictureData, other.pictureData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + pictureType; + result = 31 * result + mimeType.hashCode(); + result = 31 * result + description.hashCode(); + result = 31 * result + width; + result = 31 * result + height; + result = 31 * result + depth; + result = 31 * result + colors; + result = 31 * result + Arrays.hashCode(pictureData); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(pictureType); + dest.writeString(mimeType); + dest.writeString(description); + dest.writeInt(width); + dest.writeInt(height); + dest.writeInt(depth); + dest.writeInt(colors); + dest.writeByteArray(pictureData); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator<PictureFrame> CREATOR = + new Parcelable.Creator<PictureFrame>() { + + @Override + public PictureFrame createFromParcel(Parcel in) { + return new PictureFrame(in); + } + + @Override + public PictureFrame[] newArray(int size) { + return new PictureFrame[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/VorbisComment.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/VorbisComment.java new file mode 100644 index 0000000000..b777582b5d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/VorbisComment.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.flac; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; + +/** A vorbis comment. */ +public final class VorbisComment implements Metadata.Entry { + + /** The key. */ + public final String key; + + /** The value. */ + public final String value; + + /** + * @param key The key. + * @param value The value. + */ + public VorbisComment(String key, String value) { + this.key = key; + this.value = value; + } + + /* package */ VorbisComment(Parcel in) { + this.key = castNonNull(in.readString()); + this.value = castNonNull(in.readString()); + } + + @Override + public String toString() { + return "VC: " + key + "=" + value; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + VorbisComment other = (VorbisComment) obj; + return key.equals(other.key) && value.equals(other.value); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + key.hashCode(); + result = 31 * result + value.hashCode(); + return result; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(key); + dest.writeString(value); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator<VorbisComment> CREATOR = + new Parcelable.Creator<VorbisComment>() { + + @Override + public VorbisComment createFromParcel(Parcel in) { + return new VorbisComment(in); + } + + @Override + public VorbisComment[] newArray(int size) { + return new VorbisComment[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/package-info.java new file mode 100644 index 0000000000..02353ec303 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/flac/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.flac; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java new file mode 100644 index 0000000000..1d44219eda --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyDecoder.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Decodes ICY stream information. */ +public final class IcyDecoder implements MetadataDecoder { + + private static final Pattern METADATA_ELEMENT = Pattern.compile("(.+?)='(.*?)';", Pattern.DOTALL); + private static final String STREAM_KEY_NAME = "streamtitle"; + private static final String STREAM_KEY_URL = "streamurl"; + + private final CharsetDecoder utf8Decoder; + private final CharsetDecoder iso88591Decoder; + + public IcyDecoder() { + utf8Decoder = Charset.forName(C.UTF8_NAME).newDecoder(); + iso88591Decoder = Charset.forName(C.ISO88591_NAME).newDecoder(); + } + + @Override + @SuppressWarnings("ByteBufferBackingArray") + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); + @Nullable String icyString = decodeToString(buffer); + byte[] icyBytes = new byte[buffer.limit()]; + buffer.get(icyBytes); + + if (icyString == null) { + return new Metadata(new IcyInfo(icyBytes, /* title= */ null, /* url= */ null)); + } + + @Nullable String name = null; + @Nullable String url = null; + int index = 0; + Matcher matcher = METADATA_ELEMENT.matcher(icyString); + while (matcher.find(index)) { + @Nullable String key = Util.toLowerInvariant(matcher.group(1)); + @Nullable String value = matcher.group(2); + switch (key) { + case STREAM_KEY_NAME: + name = value; + break; + case STREAM_KEY_URL: + url = value; + break; + } + index = matcher.end(); + } + return new Metadata(new IcyInfo(icyBytes, name, url)); + } + + // The ICY spec doesn't specify a character encoding, and there's no way to communicate one + // either. So try decoding UTF-8 first, then fall back to ISO-8859-1. + // https://github.com/google/ExoPlayer/issues/6753 + @Nullable + private String decodeToString(ByteBuffer data) { + try { + return utf8Decoder.decode(data).toString(); + } catch (CharacterCodingException e) { + // Fall through to try ISO-8859-1 decoding. + } finally { + utf8Decoder.reset(); + data.rewind(); + } + try { + return iso88591Decoder.decode(data).toString(); + } catch (CharacterCodingException e) { + return null; + } finally { + iso88591Decoder.reset(); + data.rewind(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyHeaders.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyHeaders.java new file mode 100644 index 0000000000..638e7594eb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyHeaders.java @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.List; +import java.util.Map; + +/** ICY headers. */ +public final class IcyHeaders implements Metadata.Entry { + + public static final String REQUEST_HEADER_ENABLE_METADATA_NAME = "Icy-MetaData"; + public static final String REQUEST_HEADER_ENABLE_METADATA_VALUE = "1"; + + private static final String TAG = "IcyHeaders"; + + private static final String RESPONSE_HEADER_BITRATE = "icy-br"; + private static final String RESPONSE_HEADER_GENRE = "icy-genre"; + private static final String RESPONSE_HEADER_NAME = "icy-name"; + private static final String RESPONSE_HEADER_URL = "icy-url"; + private static final String RESPONSE_HEADER_PUB = "icy-pub"; + private static final String RESPONSE_HEADER_METADATA_INTERVAL = "icy-metaint"; + + /** + * Parses {@link IcyHeaders} from response headers. + * + * @param responseHeaders The response headers. + * @return The parsed {@link IcyHeaders}, or {@code null} if no ICY headers were present. + */ + @Nullable + public static IcyHeaders parse(Map<String, List<String>> responseHeaders) { + boolean icyHeadersPresent = false; + int bitrate = Format.NO_VALUE; + String genre = null; + String name = null; + String url = null; + boolean isPublic = false; + int metadataInterval = C.LENGTH_UNSET; + + List<String> headers = responseHeaders.get(RESPONSE_HEADER_BITRATE); + if (headers != null) { + String bitrateHeader = headers.get(0); + try { + bitrate = Integer.parseInt(bitrateHeader) * 1000; + if (bitrate > 0) { + icyHeadersPresent = true; + } else { + Log.w(TAG, "Invalid bitrate: " + bitrateHeader); + bitrate = Format.NO_VALUE; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid bitrate header: " + bitrateHeader); + } + } + headers = responseHeaders.get(RESPONSE_HEADER_GENRE); + if (headers != null) { + genre = headers.get(0); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_NAME); + if (headers != null) { + name = headers.get(0); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_URL); + if (headers != null) { + url = headers.get(0); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_PUB); + if (headers != null) { + isPublic = headers.get(0).equals("1"); + icyHeadersPresent = true; + } + headers = responseHeaders.get(RESPONSE_HEADER_METADATA_INTERVAL); + if (headers != null) { + String metadataIntervalHeader = headers.get(0); + try { + metadataInterval = Integer.parseInt(metadataIntervalHeader); + if (metadataInterval > 0) { + icyHeadersPresent = true; + } else { + Log.w(TAG, "Invalid metadata interval: " + metadataIntervalHeader); + metadataInterval = C.LENGTH_UNSET; + } + } catch (NumberFormatException e) { + Log.w(TAG, "Invalid metadata interval: " + metadataIntervalHeader); + } + } + return icyHeadersPresent + ? new IcyHeaders(bitrate, genre, name, url, isPublic, metadataInterval) + : null; + } + + /** + * Bitrate in bits per second ({@code (icy-br * 1000)}), or {@link Format#NO_VALUE} if the header + * was not present. + */ + public final int bitrate; + /** The genre ({@code icy-genre}). */ + @Nullable public final String genre; + /** The stream name ({@code icy-name}). */ + @Nullable public final String name; + /** The URL of the radio station ({@code icy-url}). */ + @Nullable public final String url; + /** + * Whether the radio station is listed ({@code icy-pub}), or {@code false} if the header was not + * present. + */ + public final boolean isPublic; + + /** + * The interval in bytes between metadata chunks ({@code icy-metaint}), or {@link C#LENGTH_UNSET} + * if the header was not present. + */ + public final int metadataInterval; + + /** + * @param bitrate See {@link #bitrate}. + * @param genre See {@link #genre}. + * @param name See {@link #name See}. + * @param url See {@link #url}. + * @param isPublic See {@link #isPublic}. + * @param metadataInterval See {@link #metadataInterval}. + */ + public IcyHeaders( + int bitrate, + @Nullable String genre, + @Nullable String name, + @Nullable String url, + boolean isPublic, + int metadataInterval) { + Assertions.checkArgument(metadataInterval == C.LENGTH_UNSET || metadataInterval > 0); + this.bitrate = bitrate; + this.genre = genre; + this.name = name; + this.url = url; + this.isPublic = isPublic; + this.metadataInterval = metadataInterval; + } + + /* package */ IcyHeaders(Parcel in) { + bitrate = in.readInt(); + genre = in.readString(); + name = in.readString(); + url = in.readString(); + isPublic = Util.readBoolean(in); + metadataInterval = in.readInt(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + IcyHeaders other = (IcyHeaders) obj; + return bitrate == other.bitrate + && Util.areEqual(genre, other.genre) + && Util.areEqual(name, other.name) + && Util.areEqual(url, other.url) + && isPublic == other.isPublic + && metadataInterval == other.metadataInterval; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + bitrate; + result = 31 * result + (genre != null ? genre.hashCode() : 0); + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (url != null ? url.hashCode() : 0); + result = 31 * result + (isPublic ? 1 : 0); + result = 31 * result + metadataInterval; + return result; + } + + @Override + public String toString() { + return "IcyHeaders: name=\"" + + name + + "\", genre=\"" + + genre + + "\", bitrate=" + + bitrate + + ", metadataInterval=" + + metadataInterval; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(bitrate); + dest.writeString(genre); + dest.writeString(name); + dest.writeString(url); + Util.writeBoolean(dest, isPublic); + dest.writeInt(metadataInterval); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator<IcyHeaders> CREATOR = + new Parcelable.Creator<IcyHeaders>() { + + @Override + public IcyHeaders createFromParcel(Parcel in) { + return new IcyHeaders(in); + } + + @Override + public IcyHeaders[] newArray(int size) { + return new IcyHeaders[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyInfo.java new file mode 100644 index 0000000000..4104e41c64 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/IcyInfo.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Arrays; + +/** ICY in-stream information. */ +public final class IcyInfo implements Metadata.Entry { + + /** The complete metadata bytes used to construct this IcyInfo. */ + public final byte[] rawMetadata; + /** The stream title if present and decodable, or {@code null}. */ + @Nullable public final String title; + /** The stream URL if present and decodable, or {@code null}. */ + @Nullable public final String url; + + /** + * Construct a new IcyInfo from the source metadata, and optionally a StreamTitle and StreamUrl + * that have been extracted. + * + * @param rawMetadata See {@link #rawMetadata}. + * @param title See {@link #title}. + * @param url See {@link #url}. + */ + public IcyInfo(byte[] rawMetadata, @Nullable String title, @Nullable String url) { + this.rawMetadata = rawMetadata; + this.title = title; + this.url = url; + } + + /* package */ IcyInfo(Parcel in) { + rawMetadata = Assertions.checkNotNull(in.createByteArray()); + title = in.readString(); + url = in.readString(); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + IcyInfo other = (IcyInfo) obj; + // title & url are derived from rawMetadata, so no need to include them in the comparison. + return Arrays.equals(rawMetadata, other.rawMetadata); + } + + @Override + public int hashCode() { + // title & url are derived from rawMetadata, so no need to include them in the hash. + return Arrays.hashCode(rawMetadata); + } + + @Override + public String toString() { + return String.format( + "ICY: title=\"%s\", url=\"%s\", rawMetadata.length=\"%s\"", title, url, rawMetadata.length); + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByteArray(rawMetadata); + dest.writeString(title); + dest.writeString(url); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Parcelable.Creator<IcyInfo> CREATOR = + new Parcelable.Creator<IcyInfo>() { + + @Override + public IcyInfo createFromParcel(Parcel in) { + return new IcyInfo(in); + } + + @Override + public IcyInfo[] newArray(int size) { + return new IcyInfo[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/package-info.java new file mode 100644 index 0000000000..a8a45e2ef1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/icy/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ApicFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ApicFrame.java new file mode 100644 index 0000000000..f151707e4b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ApicFrame.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * APIC (Attached Picture) ID3 frame. + */ +public final class ApicFrame extends Id3Frame { + + public static final String ID = "APIC"; + + public final String mimeType; + @Nullable public final String description; + public final int pictureType; + public final byte[] pictureData; + + public ApicFrame( + String mimeType, @Nullable String description, int pictureType, byte[] pictureData) { + super(ID); + this.mimeType = mimeType; + this.description = description; + this.pictureType = pictureType; + this.pictureData = pictureData; + } + + /* package */ ApicFrame(Parcel in) { + super(ID); + mimeType = castNonNull(in.readString()); + description = in.readString(); + pictureType = in.readInt(); + pictureData = castNonNull(in.createByteArray()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ApicFrame other = (ApicFrame) obj; + return pictureType == other.pictureType && Util.areEqual(mimeType, other.mimeType) + && Util.areEqual(description, other.description) + && Arrays.equals(pictureData, other.pictureData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + pictureType; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + Arrays.hashCode(pictureData); + return result; + } + + @Override + public String toString() { + return id + ": mimeType=" + mimeType + ", description=" + description; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mimeType); + dest.writeString(description); + dest.writeInt(pictureType); + dest.writeByteArray(pictureData); + } + + public static final Parcelable.Creator<ApicFrame> CREATOR = new Parcelable.Creator<ApicFrame>() { + + @Override + public ApicFrame createFromParcel(Parcel in) { + return new ApicFrame(in); + } + + @Override + public ApicFrame[] newArray(int size) { + return new ApicFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java new file mode 100644 index 0000000000..adc66ccdfe --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/BinaryFrame.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import java.util.Arrays; + +/** + * Binary ID3 frame. + */ +public final class BinaryFrame extends Id3Frame { + + public final byte[] data; + + public BinaryFrame(String id, byte[] data) { + super(id); + this.data = data; + } + + /* package */ BinaryFrame(Parcel in) { + super(castNonNull(in.readString())); + data = castNonNull(in.createByteArray()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BinaryFrame other = (BinaryFrame) obj; + return id.equals(other.id) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator<BinaryFrame> CREATOR = + new Parcelable.Creator<BinaryFrame>() { + + @Override + public BinaryFrame createFromParcel(Parcel in) { + return new BinaryFrame(in); + } + + @Override + public BinaryFrame[] newArray(int size) { + return new BinaryFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java new file mode 100644 index 0000000000..348781dddf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ChapterFrame.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Chapter information ID3 frame. + */ +public final class ChapterFrame extends Id3Frame { + + public static final String ID = "CHAP"; + + public final String chapterId; + public final int startTimeMs; + public final int endTimeMs; + /** + * The byte offset of the start of the chapter, or {@link C#POSITION_UNSET} if not set. + */ + public final long startOffset; + /** + * The byte offset of the end of the chapter, or {@link C#POSITION_UNSET} if not set. + */ + public final long endOffset; + private final Id3Frame[] subFrames; + + public ChapterFrame(String chapterId, int startTimeMs, int endTimeMs, long startOffset, + long endOffset, Id3Frame[] subFrames) { + super(ID); + this.chapterId = chapterId; + this.startTimeMs = startTimeMs; + this.endTimeMs = endTimeMs; + this.startOffset = startOffset; + this.endOffset = endOffset; + this.subFrames = subFrames; + } + + /* package */ ChapterFrame(Parcel in) { + super(ID); + this.chapterId = castNonNull(in.readString()); + this.startTimeMs = in.readInt(); + this.endTimeMs = in.readInt(); + this.startOffset = in.readLong(); + this.endOffset = in.readLong(); + int subFrameCount = in.readInt(); + subFrames = new Id3Frame[subFrameCount]; + for (int i = 0; i < subFrameCount; i++) { + subFrames[i] = in.readParcelable(Id3Frame.class.getClassLoader()); + } + } + + /** + * Returns the number of sub-frames. + */ + public int getSubFrameCount() { + return subFrames.length; + } + + /** + * Returns the sub-frame at {@code index}. + */ + public Id3Frame getSubFrame(int index) { + return subFrames[index]; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ChapterFrame other = (ChapterFrame) obj; + return startTimeMs == other.startTimeMs + && endTimeMs == other.endTimeMs + && startOffset == other.startOffset + && endOffset == other.endOffset + && Util.areEqual(chapterId, other.chapterId) + && Arrays.equals(subFrames, other.subFrames); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + startTimeMs; + result = 31 * result + endTimeMs; + result = 31 * result + (int) startOffset; + result = 31 * result + (int) endOffset; + result = 31 * result + (chapterId != null ? chapterId.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(chapterId); + dest.writeInt(startTimeMs); + dest.writeInt(endTimeMs); + dest.writeLong(startOffset); + dest.writeLong(endOffset); + dest.writeInt(subFrames.length); + for (Id3Frame subFrame : subFrames) { + dest.writeParcelable(subFrame, 0); + } + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator<ChapterFrame> CREATOR = new Creator<ChapterFrame>() { + + @Override + public ChapterFrame createFromParcel(Parcel in) { + return new ChapterFrame(in); + } + + @Override + public ChapterFrame[] newArray(int size) { + return new ChapterFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java new file mode 100644 index 0000000000..9451151c16 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/ChapterTocFrame.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Chapter table of contents ID3 frame. + */ +public final class ChapterTocFrame extends Id3Frame { + + public static final String ID = "CTOC"; + + public final String elementId; + public final boolean isRoot; + public final boolean isOrdered; + public final String[] children; + private final Id3Frame[] subFrames; + + public ChapterTocFrame(String elementId, boolean isRoot, boolean isOrdered, String[] children, + Id3Frame[] subFrames) { + super(ID); + this.elementId = elementId; + this.isRoot = isRoot; + this.isOrdered = isOrdered; + this.children = children; + this.subFrames = subFrames; + } + + /* package */ + ChapterTocFrame(Parcel in) { + super(ID); + this.elementId = castNonNull(in.readString()); + this.isRoot = in.readByte() != 0; + this.isOrdered = in.readByte() != 0; + this.children = castNonNull(in.createStringArray()); + int subFrameCount = in.readInt(); + subFrames = new Id3Frame[subFrameCount]; + for (int i = 0; i < subFrameCount; i++) { + subFrames[i] = in.readParcelable(Id3Frame.class.getClassLoader()); + } + } + + /** + * Returns the number of sub-frames. + */ + public int getSubFrameCount() { + return subFrames.length; + } + + /** + * Returns the sub-frame at {@code index}. + */ + public Id3Frame getSubFrame(int index) { + return subFrames[index]; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ChapterTocFrame other = (ChapterTocFrame) obj; + return isRoot == other.isRoot + && isOrdered == other.isOrdered + && Util.areEqual(elementId, other.elementId) + && Arrays.equals(children, other.children) + && Arrays.equals(subFrames, other.subFrames); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (isRoot ? 1 : 0); + result = 31 * result + (isOrdered ? 1 : 0); + result = 31 * result + (elementId != null ? elementId.hashCode() : 0); + return result; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(elementId); + dest.writeByte((byte) (isRoot ? 1 : 0)); + dest.writeByte((byte) (isOrdered ? 1 : 0)); + dest.writeStringArray(children); + dest.writeInt(subFrames.length); + for (Id3Frame subFrame : subFrames) { + dest.writeParcelable(subFrame, 0); + } + } + + public static final Creator<ChapterTocFrame> CREATOR = new Creator<ChapterTocFrame>() { + + @Override + public ChapterTocFrame createFromParcel(Parcel in) { + return new ChapterTocFrame(in); + } + + @Override + public ChapterTocFrame[] newArray(int size) { + return new ChapterTocFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/CommentFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/CommentFrame.java new file mode 100644 index 0000000000..98b8c79a96 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/CommentFrame.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Comment ID3 frame. + */ +public final class CommentFrame extends Id3Frame { + + public static final String ID = "COMM"; + + public final String language; + public final String description; + public final String text; + + public CommentFrame(String language, String description, String text) { + super(ID); + this.language = language; + this.description = description; + this.text = text; + } + + /* package */ CommentFrame(Parcel in) { + super(ID); + language = castNonNull(in.readString()); + description = castNonNull(in.readString()); + text = castNonNull(in.readString()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + CommentFrame other = (CommentFrame) obj; + return Util.areEqual(description, other.description) && Util.areEqual(language, other.language) + && Util.areEqual(text, other.text); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (language != null ? language.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (text != null ? text.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return id + ": language=" + language + ", description=" + description; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(language); + dest.writeString(text); + } + + public static final Parcelable.Creator<CommentFrame> CREATOR = + new Parcelable.Creator<CommentFrame>() { + + @Override + public CommentFrame createFromParcel(Parcel in) { + return new CommentFrame(in); + } + + @Override + public CommentFrame[] newArray(int size) { + return new CommentFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/GeobFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/GeobFrame.java new file mode 100644 index 0000000000..58a208a76a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/GeobFrame.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * GEOB (General Encapsulated Object) ID3 frame. + */ +public final class GeobFrame extends Id3Frame { + + public static final String ID = "GEOB"; + + public final String mimeType; + public final String filename; + public final String description; + public final byte[] data; + + public GeobFrame(String mimeType, String filename, String description, byte[] data) { + super(ID); + this.mimeType = mimeType; + this.filename = filename; + this.description = description; + this.data = data; + } + + /* package */ GeobFrame(Parcel in) { + super(ID); + mimeType = castNonNull(in.readString()); + filename = castNonNull(in.readString()); + description = castNonNull(in.readString()); + data = castNonNull(in.createByteArray()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + GeobFrame other = (GeobFrame) obj; + return Util.areEqual(mimeType, other.mimeType) && Util.areEqual(filename, other.filename) + && Util.areEqual(description, other.description) && Arrays.equals(data, other.data); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + result = 31 * result + (filename != null ? filename.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + @Override + public String toString() { + return id + + ": mimeType=" + + mimeType + + ", filename=" + + filename + + ", description=" + + description; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(mimeType); + dest.writeString(filename); + dest.writeString(description); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator<GeobFrame> CREATOR = new Parcelable.Creator<GeobFrame>() { + + @Override + public GeobFrame createFromParcel(Parcel in) { + return new GeobFrame(in); + } + + @Override + public GeobFrame[] newArray(int size) { + return new GeobFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java new file mode 100644 index 0000000000..36e004ed52 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/Id3Decoder.java @@ -0,0 +1,842 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * Decodes ID3 tags. + */ +public final class Id3Decoder implements MetadataDecoder { + + /** + * A predicate for determining whether individual frames should be decoded. + */ + public interface FramePredicate { + + /** + * Returns whether a frame with the specified parameters should be decoded. + * + * @param majorVersion The major version of the ID3 tag. + * @param id0 The first byte of the frame ID. + * @param id1 The second byte of the frame ID. + * @param id2 The third byte of the frame ID. + * @param id3 The fourth byte of the frame ID. + * @return Whether the frame should be decoded. + */ + boolean evaluate(int majorVersion, int id0, int id1, int id2, int id3); + + } + + /** A predicate that indicates no frames should be decoded. */ + public static final FramePredicate NO_FRAMES_PREDICATE = + (majorVersion, id0, id1, id2, id3) -> false; + + private static final String TAG = "Id3Decoder"; + + /** The first three bytes of a well formed ID3 tag header. */ + public static final int ID3_TAG = 0x00494433; + /** + * Length of an ID3 tag header. + */ + public static final int ID3_HEADER_LENGTH = 10; + + private static final int FRAME_FLAG_V3_IS_COMPRESSED = 0x0080; + private static final int FRAME_FLAG_V3_IS_ENCRYPTED = 0x0040; + private static final int FRAME_FLAG_V3_HAS_GROUP_IDENTIFIER = 0x0020; + private static final int FRAME_FLAG_V4_IS_COMPRESSED = 0x0008; + private static final int FRAME_FLAG_V4_IS_ENCRYPTED = 0x0004; + private static final int FRAME_FLAG_V4_HAS_GROUP_IDENTIFIER = 0x0040; + private static final int FRAME_FLAG_V4_IS_UNSYNCHRONIZED = 0x0002; + private static final int FRAME_FLAG_V4_HAS_DATA_LENGTH = 0x0001; + + private static final int ID3_TEXT_ENCODING_ISO_8859_1 = 0; + private static final int ID3_TEXT_ENCODING_UTF_16 = 1; + private static final int ID3_TEXT_ENCODING_UTF_16BE = 2; + private static final int ID3_TEXT_ENCODING_UTF_8 = 3; + + @Nullable private final FramePredicate framePredicate; + + public Id3Decoder() { + this(null); + } + + /** + * @param framePredicate Determines which frames are decoded. May be null to decode all frames. + */ + public Id3Decoder(@Nullable FramePredicate framePredicate) { + this.framePredicate = framePredicate; + } + + @SuppressWarnings("ByteBufferBackingArray") + @Override + @Nullable + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); + return decode(buffer.array(), buffer.limit()); + } + + /** + * Decodes ID3 tags. + * + * @param data The bytes to decode ID3 tags from. + * @param size Amount of bytes in {@code data} to read. + * @return A {@link Metadata} object containing the decoded ID3 tags, or null if the data could + * not be decoded. + */ + @Nullable + public Metadata decode(byte[] data, int size) { + List<Id3Frame> id3Frames = new ArrayList<>(); + ParsableByteArray id3Data = new ParsableByteArray(data, size); + + Id3Header id3Header = decodeHeader(id3Data); + if (id3Header == null) { + return null; + } + + int startPosition = id3Data.getPosition(); + int frameHeaderSize = id3Header.majorVersion == 2 ? 6 : 10; + int framesSize = id3Header.framesSize; + if (id3Header.isUnsynchronized) { + framesSize = removeUnsynchronization(id3Data, id3Header.framesSize); + } + id3Data.setLimit(startPosition + framesSize); + + boolean unsignedIntFrameSizeHack = false; + if (!validateFrames(id3Data, id3Header.majorVersion, frameHeaderSize, false)) { + if (id3Header.majorVersion == 4 && validateFrames(id3Data, 4, frameHeaderSize, true)) { + unsignedIntFrameSizeHack = true; + } else { + Log.w(TAG, "Failed to validate ID3 tag with majorVersion=" + id3Header.majorVersion); + return null; + } + } + + while (id3Data.bytesLeft() >= frameHeaderSize) { + Id3Frame frame = decodeFrame(id3Header.majorVersion, id3Data, unsignedIntFrameSizeHack, + frameHeaderSize, framePredicate); + if (frame != null) { + id3Frames.add(frame); + } + } + + return new Metadata(id3Frames); + } + + /** + * @param data A {@link ParsableByteArray} from which the header should be read. + * @return The parsed header, or null if the ID3 tag is unsupported. + */ + @Nullable + private static Id3Header decodeHeader(ParsableByteArray data) { + if (data.bytesLeft() < ID3_HEADER_LENGTH) { + Log.w(TAG, "Data too short to be an ID3 tag"); + return null; + } + + int id = data.readUnsignedInt24(); + if (id != ID3_TAG) { + Log.w(TAG, "Unexpected first three bytes of ID3 tag header: 0x" + String.format("%06X", id)); + return null; + } + + int majorVersion = data.readUnsignedByte(); + data.skipBytes(1); // Skip minor version. + int flags = data.readUnsignedByte(); + int framesSize = data.readSynchSafeInt(); + + if (majorVersion == 2) { + boolean isCompressed = (flags & 0x40) != 0; + if (isCompressed) { + Log.w(TAG, "Skipped ID3 tag with majorVersion=2 and undefined compression scheme"); + return null; + } + } else if (majorVersion == 3) { + boolean hasExtendedHeader = (flags & 0x40) != 0; + if (hasExtendedHeader) { + int extendedHeaderSize = data.readInt(); // Size excluding size field. + data.skipBytes(extendedHeaderSize); + framesSize -= (extendedHeaderSize + 4); + } + } else if (majorVersion == 4) { + boolean hasExtendedHeader = (flags & 0x40) != 0; + if (hasExtendedHeader) { + int extendedHeaderSize = data.readSynchSafeInt(); // Size including size field. + data.skipBytes(extendedHeaderSize - 4); + framesSize -= extendedHeaderSize; + } + boolean hasFooter = (flags & 0x10) != 0; + if (hasFooter) { + framesSize -= 10; + } + } else { + Log.w(TAG, "Skipped ID3 tag with unsupported majorVersion=" + majorVersion); + return null; + } + + // isUnsynchronized is advisory only in version 4. Frame level flags are used instead. + boolean isUnsynchronized = majorVersion < 4 && (flags & 0x80) != 0; + return new Id3Header(majorVersion, isUnsynchronized, framesSize); + } + + private static boolean validateFrames(ParsableByteArray id3Data, int majorVersion, + int frameHeaderSize, boolean unsignedIntFrameSizeHack) { + int startPosition = id3Data.getPosition(); + try { + while (id3Data.bytesLeft() >= frameHeaderSize) { + // Read the next frame header. + int id; + long frameSize; + int flags; + if (majorVersion >= 3) { + id = id3Data.readInt(); + frameSize = id3Data.readUnsignedInt(); + flags = id3Data.readUnsignedShort(); + } else { + id = id3Data.readUnsignedInt24(); + frameSize = id3Data.readUnsignedInt24(); + flags = 0; + } + // Validate the frame header and skip to the next one. + if (id == 0 && frameSize == 0 && flags == 0) { + // We've reached zero padding after the end of the final frame. + return true; + } else { + if (majorVersion == 4 && !unsignedIntFrameSizeHack) { + // Parse the data size as a synchsafe integer, as per the spec. + if ((frameSize & 0x808080L) != 0) { + return false; + } + frameSize = (frameSize & 0xFF) | (((frameSize >> 8) & 0xFF) << 7) + | (((frameSize >> 16) & 0xFF) << 14) | (((frameSize >> 24) & 0xFF) << 21); + } + boolean hasGroupIdentifier = false; + boolean hasDataLength = false; + if (majorVersion == 4) { + hasGroupIdentifier = (flags & FRAME_FLAG_V4_HAS_GROUP_IDENTIFIER) != 0; + hasDataLength = (flags & FRAME_FLAG_V4_HAS_DATA_LENGTH) != 0; + } else if (majorVersion == 3) { + hasGroupIdentifier = (flags & FRAME_FLAG_V3_HAS_GROUP_IDENTIFIER) != 0; + // A V3 frame has data length if and only if it's compressed. + hasDataLength = (flags & FRAME_FLAG_V3_IS_COMPRESSED) != 0; + } + int minimumFrameSize = 0; + if (hasGroupIdentifier) { + minimumFrameSize++; + } + if (hasDataLength) { + minimumFrameSize += 4; + } + if (frameSize < minimumFrameSize) { + return false; + } + if (id3Data.bytesLeft() < frameSize) { + return false; + } + id3Data.skipBytes((int) frameSize); // flags + } + } + return true; + } finally { + id3Data.setPosition(startPosition); + } + } + + @Nullable + private static Id3Frame decodeFrame( + int majorVersion, + ParsableByteArray id3Data, + boolean unsignedIntFrameSizeHack, + int frameHeaderSize, + @Nullable FramePredicate framePredicate) { + int frameId0 = id3Data.readUnsignedByte(); + int frameId1 = id3Data.readUnsignedByte(); + int frameId2 = id3Data.readUnsignedByte(); + int frameId3 = majorVersion >= 3 ? id3Data.readUnsignedByte() : 0; + + int frameSize; + if (majorVersion == 4) { + frameSize = id3Data.readUnsignedIntToInt(); + if (!unsignedIntFrameSizeHack) { + frameSize = (frameSize & 0xFF) | (((frameSize >> 8) & 0xFF) << 7) + | (((frameSize >> 16) & 0xFF) << 14) | (((frameSize >> 24) & 0xFF) << 21); + } + } else if (majorVersion == 3) { + frameSize = id3Data.readUnsignedIntToInt(); + } else /* id3Header.majorVersion == 2 */ { + frameSize = id3Data.readUnsignedInt24(); + } + + int flags = majorVersion >= 3 ? id3Data.readUnsignedShort() : 0; + if (frameId0 == 0 && frameId1 == 0 && frameId2 == 0 && frameId3 == 0 && frameSize == 0 + && flags == 0) { + // We must be reading zero padding at the end of the tag. + id3Data.setPosition(id3Data.limit()); + return null; + } + + int nextFramePosition = id3Data.getPosition() + frameSize; + if (nextFramePosition > id3Data.limit()) { + Log.w(TAG, "Frame size exceeds remaining tag data"); + id3Data.setPosition(id3Data.limit()); + return null; + } + + if (framePredicate != null + && !framePredicate.evaluate(majorVersion, frameId0, frameId1, frameId2, frameId3)) { + // Filtered by the predicate. + id3Data.setPosition(nextFramePosition); + return null; + } + + // Frame flags. + boolean isCompressed = false; + boolean isEncrypted = false; + boolean isUnsynchronized = false; + boolean hasDataLength = false; + boolean hasGroupIdentifier = false; + if (majorVersion == 3) { + isCompressed = (flags & FRAME_FLAG_V3_IS_COMPRESSED) != 0; + isEncrypted = (flags & FRAME_FLAG_V3_IS_ENCRYPTED) != 0; + hasGroupIdentifier = (flags & FRAME_FLAG_V3_HAS_GROUP_IDENTIFIER) != 0; + // A V3 frame has data length if and only if it's compressed. + hasDataLength = isCompressed; + } else if (majorVersion == 4) { + hasGroupIdentifier = (flags & FRAME_FLAG_V4_HAS_GROUP_IDENTIFIER) != 0; + isCompressed = (flags & FRAME_FLAG_V4_IS_COMPRESSED) != 0; + isEncrypted = (flags & FRAME_FLAG_V4_IS_ENCRYPTED) != 0; + isUnsynchronized = (flags & FRAME_FLAG_V4_IS_UNSYNCHRONIZED) != 0; + hasDataLength = (flags & FRAME_FLAG_V4_HAS_DATA_LENGTH) != 0; + } + + if (isCompressed || isEncrypted) { + Log.w(TAG, "Skipping unsupported compressed or encrypted frame"); + id3Data.setPosition(nextFramePosition); + return null; + } + + if (hasGroupIdentifier) { + frameSize--; + id3Data.skipBytes(1); + } + if (hasDataLength) { + frameSize -= 4; + id3Data.skipBytes(4); + } + if (isUnsynchronized) { + frameSize = removeUnsynchronization(id3Data, frameSize); + } + + try { + Id3Frame frame; + if (frameId0 == 'T' && frameId1 == 'X' && frameId2 == 'X' + && (majorVersion == 2 || frameId3 == 'X')) { + frame = decodeTxxxFrame(id3Data, frameSize); + } else if (frameId0 == 'T') { + String id = getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3); + frame = decodeTextInformationFrame(id3Data, frameSize, id); + } else if (frameId0 == 'W' && frameId1 == 'X' && frameId2 == 'X' + && (majorVersion == 2 || frameId3 == 'X')) { + frame = decodeWxxxFrame(id3Data, frameSize); + } else if (frameId0 == 'W') { + String id = getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3); + frame = decodeUrlLinkFrame(id3Data, frameSize, id); + } else if (frameId0 == 'P' && frameId1 == 'R' && frameId2 == 'I' && frameId3 == 'V') { + frame = decodePrivFrame(id3Data, frameSize); + } else if (frameId0 == 'G' && frameId1 == 'E' && frameId2 == 'O' + && (frameId3 == 'B' || majorVersion == 2)) { + frame = decodeGeobFrame(id3Data, frameSize); + } else if (majorVersion == 2 ? (frameId0 == 'P' && frameId1 == 'I' && frameId2 == 'C') + : (frameId0 == 'A' && frameId1 == 'P' && frameId2 == 'I' && frameId3 == 'C')) { + frame = decodeApicFrame(id3Data, frameSize, majorVersion); + } else if (frameId0 == 'C' && frameId1 == 'O' && frameId2 == 'M' + && (frameId3 == 'M' || majorVersion == 2)) { + frame = decodeCommentFrame(id3Data, frameSize); + } else if (frameId0 == 'C' && frameId1 == 'H' && frameId2 == 'A' && frameId3 == 'P') { + frame = decodeChapterFrame(id3Data, frameSize, majorVersion, unsignedIntFrameSizeHack, + frameHeaderSize, framePredicate); + } else if (frameId0 == 'C' && frameId1 == 'T' && frameId2 == 'O' && frameId3 == 'C') { + frame = decodeChapterTOCFrame(id3Data, frameSize, majorVersion, unsignedIntFrameSizeHack, + frameHeaderSize, framePredicate); + } else if (frameId0 == 'M' && frameId1 == 'L' && frameId2 == 'L' && frameId3 == 'T') { + frame = decodeMlltFrame(id3Data, frameSize); + } else { + String id = getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3); + frame = decodeBinaryFrame(id3Data, frameSize, id); + } + if (frame == null) { + Log.w(TAG, "Failed to decode frame: id=" + + getFrameId(majorVersion, frameId0, frameId1, frameId2, frameId3) + ", frameSize=" + + frameSize); + } + return frame; + } catch (UnsupportedEncodingException e) { + Log.w(TAG, "Unsupported character encoding"); + return null; + } finally { + id3Data.setPosition(nextFramePosition); + } + } + + @Nullable + private static TextInformationFrame decodeTxxxFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + if (frameSize < 1) { + // Frame is malformed. + return null; + } + + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + int descriptionEndIndex = indexOfEos(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, charset); + + int valueStartIndex = descriptionEndIndex + delimiterLength(encoding); + int valueEndIndex = indexOfEos(data, valueStartIndex, encoding); + String value = decodeStringIfValid(data, valueStartIndex, valueEndIndex, charset); + + return new TextInformationFrame("TXXX", description, value); + } + + @Nullable + private static TextInformationFrame decodeTextInformationFrame( + ParsableByteArray id3Data, int frameSize, String id) throws UnsupportedEncodingException { + if (frameSize < 1) { + // Frame is malformed. + return null; + } + + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + int valueEndIndex = indexOfEos(data, 0, encoding); + String value = new String(data, 0, valueEndIndex, charset); + + return new TextInformationFrame(id, null, value); + } + + @Nullable + private static UrlLinkFrame decodeWxxxFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + if (frameSize < 1) { + // Frame is malformed. + return null; + } + + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + int descriptionEndIndex = indexOfEos(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, charset); + + int urlStartIndex = descriptionEndIndex + delimiterLength(encoding); + int urlEndIndex = indexOfZeroByte(data, urlStartIndex); + String url = decodeStringIfValid(data, urlStartIndex, urlEndIndex, "ISO-8859-1"); + + return new UrlLinkFrame("WXXX", description, url); + } + + private static UrlLinkFrame decodeUrlLinkFrame(ParsableByteArray id3Data, int frameSize, + String id) throws UnsupportedEncodingException { + byte[] data = new byte[frameSize]; + id3Data.readBytes(data, 0, frameSize); + + int urlEndIndex = indexOfZeroByte(data, 0); + String url = new String(data, 0, urlEndIndex, "ISO-8859-1"); + + return new UrlLinkFrame(id, null, url); + } + + private static PrivFrame decodePrivFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + byte[] data = new byte[frameSize]; + id3Data.readBytes(data, 0, frameSize); + + int ownerEndIndex = indexOfZeroByte(data, 0); + String owner = new String(data, 0, ownerEndIndex, "ISO-8859-1"); + + int privateDataStartIndex = ownerEndIndex + 1; + byte[] privateData = copyOfRangeIfValid(data, privateDataStartIndex, data.length); + + return new PrivFrame(owner, privateData); + } + + private static GeobFrame decodeGeobFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + int mimeTypeEndIndex = indexOfZeroByte(data, 0); + String mimeType = new String(data, 0, mimeTypeEndIndex, "ISO-8859-1"); + + int filenameStartIndex = mimeTypeEndIndex + 1; + int filenameEndIndex = indexOfEos(data, filenameStartIndex, encoding); + String filename = decodeStringIfValid(data, filenameStartIndex, filenameEndIndex, charset); + + int descriptionStartIndex = filenameEndIndex + delimiterLength(encoding); + int descriptionEndIndex = indexOfEos(data, descriptionStartIndex, encoding); + String description = + decodeStringIfValid(data, descriptionStartIndex, descriptionEndIndex, charset); + + int objectDataStartIndex = descriptionEndIndex + delimiterLength(encoding); + byte[] objectData = copyOfRangeIfValid(data, objectDataStartIndex, data.length); + + return new GeobFrame(mimeType, filename, description, objectData); + } + + private static ApicFrame decodeApicFrame(ParsableByteArray id3Data, int frameSize, + int majorVersion) throws UnsupportedEncodingException { + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[frameSize - 1]; + id3Data.readBytes(data, 0, frameSize - 1); + + String mimeType; + int mimeTypeEndIndex; + if (majorVersion == 2) { + mimeTypeEndIndex = 2; + mimeType = "image/" + Util.toLowerInvariant(new String(data, 0, 3, "ISO-8859-1")); + if ("image/jpg".equals(mimeType)) { + mimeType = "image/jpeg"; + } + } else { + mimeTypeEndIndex = indexOfZeroByte(data, 0); + mimeType = Util.toLowerInvariant(new String(data, 0, mimeTypeEndIndex, "ISO-8859-1")); + if (mimeType.indexOf('/') == -1) { + mimeType = "image/" + mimeType; + } + } + + int pictureType = data[mimeTypeEndIndex + 1] & 0xFF; + + int descriptionStartIndex = mimeTypeEndIndex + 2; + int descriptionEndIndex = indexOfEos(data, descriptionStartIndex, encoding); + String description = new String(data, descriptionStartIndex, + descriptionEndIndex - descriptionStartIndex, charset); + + int pictureDataStartIndex = descriptionEndIndex + delimiterLength(encoding); + byte[] pictureData = copyOfRangeIfValid(data, pictureDataStartIndex, data.length); + + return new ApicFrame(mimeType, description, pictureType, pictureData); + } + + @Nullable + private static CommentFrame decodeCommentFrame(ParsableByteArray id3Data, int frameSize) + throws UnsupportedEncodingException { + if (frameSize < 4) { + // Frame is malformed. + return null; + } + + int encoding = id3Data.readUnsignedByte(); + String charset = getCharsetName(encoding); + + byte[] data = new byte[3]; + id3Data.readBytes(data, 0, 3); + String language = new String(data, 0, 3); + + data = new byte[frameSize - 4]; + id3Data.readBytes(data, 0, frameSize - 4); + + int descriptionEndIndex = indexOfEos(data, 0, encoding); + String description = new String(data, 0, descriptionEndIndex, charset); + + int textStartIndex = descriptionEndIndex + delimiterLength(encoding); + int textEndIndex = indexOfEos(data, textStartIndex, encoding); + String text = decodeStringIfValid(data, textStartIndex, textEndIndex, charset); + + return new CommentFrame(language, description, text); + } + + private static ChapterFrame decodeChapterFrame( + ParsableByteArray id3Data, + int frameSize, + int majorVersion, + boolean unsignedIntFrameSizeHack, + int frameHeaderSize, + @Nullable FramePredicate framePredicate) + throws UnsupportedEncodingException { + int framePosition = id3Data.getPosition(); + int chapterIdEndIndex = indexOfZeroByte(id3Data.data, framePosition); + String chapterId = new String(id3Data.data, framePosition, chapterIdEndIndex - framePosition, + "ISO-8859-1"); + id3Data.setPosition(chapterIdEndIndex + 1); + + int startTime = id3Data.readInt(); + int endTime = id3Data.readInt(); + long startOffset = id3Data.readUnsignedInt(); + if (startOffset == 0xFFFFFFFFL) { + startOffset = C.POSITION_UNSET; + } + long endOffset = id3Data.readUnsignedInt(); + if (endOffset == 0xFFFFFFFFL) { + endOffset = C.POSITION_UNSET; + } + + ArrayList<Id3Frame> subFrames = new ArrayList<>(); + int limit = framePosition + frameSize; + while (id3Data.getPosition() < limit) { + Id3Frame frame = decodeFrame(majorVersion, id3Data, unsignedIntFrameSizeHack, + frameHeaderSize, framePredicate); + if (frame != null) { + subFrames.add(frame); + } + } + + Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; + subFrames.toArray(subFrameArray); + return new ChapterFrame(chapterId, startTime, endTime, startOffset, endOffset, subFrameArray); + } + + private static ChapterTocFrame decodeChapterTOCFrame( + ParsableByteArray id3Data, + int frameSize, + int majorVersion, + boolean unsignedIntFrameSizeHack, + int frameHeaderSize, + @Nullable FramePredicate framePredicate) + throws UnsupportedEncodingException { + int framePosition = id3Data.getPosition(); + int elementIdEndIndex = indexOfZeroByte(id3Data.data, framePosition); + String elementId = new String(id3Data.data, framePosition, elementIdEndIndex - framePosition, + "ISO-8859-1"); + id3Data.setPosition(elementIdEndIndex + 1); + + int ctocFlags = id3Data.readUnsignedByte(); + boolean isRoot = (ctocFlags & 0x0002) != 0; + boolean isOrdered = (ctocFlags & 0x0001) != 0; + + int childCount = id3Data.readUnsignedByte(); + String[] children = new String[childCount]; + for (int i = 0; i < childCount; i++) { + int startIndex = id3Data.getPosition(); + int endIndex = indexOfZeroByte(id3Data.data, startIndex); + children[i] = new String(id3Data.data, startIndex, endIndex - startIndex, "ISO-8859-1"); + id3Data.setPosition(endIndex + 1); + } + + ArrayList<Id3Frame> subFrames = new ArrayList<>(); + int limit = framePosition + frameSize; + while (id3Data.getPosition() < limit) { + Id3Frame frame = decodeFrame(majorVersion, id3Data, unsignedIntFrameSizeHack, + frameHeaderSize, framePredicate); + if (frame != null) { + subFrames.add(frame); + } + } + + Id3Frame[] subFrameArray = new Id3Frame[subFrames.size()]; + subFrames.toArray(subFrameArray); + return new ChapterTocFrame(elementId, isRoot, isOrdered, children, subFrameArray); + } + + private static MlltFrame decodeMlltFrame(ParsableByteArray id3Data, int frameSize) { + // See ID3v2.4.0 native frames subsection 4.6. + int mpegFramesBetweenReference = id3Data.readUnsignedShort(); + int bytesBetweenReference = id3Data.readUnsignedInt24(); + int millisecondsBetweenReference = id3Data.readUnsignedInt24(); + int bitsForBytesDeviation = id3Data.readUnsignedByte(); + int bitsForMillisecondsDeviation = id3Data.readUnsignedByte(); + + ParsableBitArray references = new ParsableBitArray(); + references.reset(id3Data); + int referencesBits = 8 * (frameSize - 10); + int bitsPerReference = bitsForBytesDeviation + bitsForMillisecondsDeviation; + int referencesCount = referencesBits / bitsPerReference; + int[] bytesDeviations = new int[referencesCount]; + int[] millisecondsDeviations = new int[referencesCount]; + for (int i = 0; i < referencesCount; i++) { + int bytesDeviation = references.readBits(bitsForBytesDeviation); + int millisecondsDeviation = references.readBits(bitsForMillisecondsDeviation); + bytesDeviations[i] = bytesDeviation; + millisecondsDeviations[i] = millisecondsDeviation; + } + + return new MlltFrame( + mpegFramesBetweenReference, + bytesBetweenReference, + millisecondsBetweenReference, + bytesDeviations, + millisecondsDeviations); + } + + private static BinaryFrame decodeBinaryFrame(ParsableByteArray id3Data, int frameSize, + String id) { + byte[] frame = new byte[frameSize]; + id3Data.readBytes(frame, 0, frameSize); + + return new BinaryFrame(id, frame); + } + + /** + * Performs in-place removal of unsynchronization for {@code length} bytes starting from + * {@link ParsableByteArray#getPosition()} + * + * @param data Contains the data to be processed. + * @param length The length of the data to be processed. + * @return The length of the data after processing. + */ + private static int removeUnsynchronization(ParsableByteArray data, int length) { + byte[] bytes = data.data; + int startPosition = data.getPosition(); + for (int i = startPosition; i + 1 < startPosition + length; i++) { + if ((bytes[i] & 0xFF) == 0xFF && bytes[i + 1] == 0x00) { + int relativePosition = i - startPosition; + System.arraycopy(bytes, i + 2, bytes, i + 1, length - relativePosition - 2); + length--; + } + } + return length; + } + + /** + * Maps encoding byte from ID3v2 frame to a Charset. + * + * @param encodingByte The value of encoding byte from ID3v2 frame. + * @return Charset name. + */ + private static String getCharsetName(int encodingByte) { + switch (encodingByte) { + case ID3_TEXT_ENCODING_UTF_16: + return "UTF-16"; + case ID3_TEXT_ENCODING_UTF_16BE: + return "UTF-16BE"; + case ID3_TEXT_ENCODING_UTF_8: + return "UTF-8"; + case ID3_TEXT_ENCODING_ISO_8859_1: + default: + return "ISO-8859-1"; + } + } + + private static String getFrameId(int majorVersion, int frameId0, int frameId1, int frameId2, + int frameId3) { + return majorVersion == 2 ? String.format(Locale.US, "%c%c%c", frameId0, frameId1, frameId2) + : String.format(Locale.US, "%c%c%c%c", frameId0, frameId1, frameId2, frameId3); + } + + private static int indexOfEos(byte[] data, int fromIndex, int encoding) { + int terminationPos = indexOfZeroByte(data, fromIndex); + + // For single byte encoding charsets, we're done. + if (encoding == ID3_TEXT_ENCODING_ISO_8859_1 || encoding == ID3_TEXT_ENCODING_UTF_8) { + return terminationPos; + } + + // Otherwise ensure an even index and look for a second zero byte. + while (terminationPos < data.length - 1) { + if (terminationPos % 2 == 0 && data[terminationPos + 1] == (byte) 0) { + return terminationPos; + } + terminationPos = indexOfZeroByte(data, terminationPos + 1); + } + + return data.length; + } + + private static int indexOfZeroByte(byte[] data, int fromIndex) { + for (int i = fromIndex; i < data.length; i++) { + if (data[i] == (byte) 0) { + return i; + } + } + return data.length; + } + + private static int delimiterLength(int encodingByte) { + return (encodingByte == ID3_TEXT_ENCODING_ISO_8859_1 || encodingByte == ID3_TEXT_ENCODING_UTF_8) + ? 1 : 2; + } + + /** + * Copies the specified range of an array, or returns a zero length array if the range is invalid. + * + * @param data The array from which to copy. + * @param from The start of the range to copy (inclusive). + * @param to The end of the range to copy (exclusive). + * @return The copied data, or a zero length array if the range is invalid. + */ + private static byte[] copyOfRangeIfValid(byte[] data, int from, int to) { + if (to <= from) { + // Invalid or zero length range. + return Util.EMPTY_BYTE_ARRAY; + } + return Arrays.copyOfRange(data, from, to); + } + + /** + * Returns a string obtained by decoding the specified range of {@code data} using the specified + * {@code charsetName}. An empty string is returned if the range is invalid. + * + * @param data The array from which to decode the string. + * @param from The start of the range. + * @param to The end of the range (exclusive). + * @param charsetName The name of the Charset to use. + * @return The decoded string, or an empty string if the range is invalid. + * @throws UnsupportedEncodingException If the Charset is not supported. + */ + private static String decodeStringIfValid(byte[] data, int from, int to, String charsetName) + throws UnsupportedEncodingException { + if (to <= from || to > data.length) { + return ""; + } + return new String(data, from, to - from, charsetName); + } + + private static final class Id3Header { + + private final int majorVersion; + private final boolean isUnsynchronized; + private final int framesSize; + + public Id3Header(int majorVersion, boolean isUnsynchronized, int framesSize) { + this.majorVersion = majorVersion; + this.isUnsynchronized = isUnsynchronized; + this.framesSize = framesSize; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/Id3Frame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/Id3Frame.java new file mode 100644 index 0000000000..f96b5e752c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/Id3Frame.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; + +/** + * Base class for ID3 frames. + */ +public abstract class Id3Frame implements Metadata.Entry { + + /** + * The frame ID. + */ + public final String id; + + public Id3Frame(String id) { + this.id = id; + } + + @Override + public String toString() { + return id; + } + + @Override + public int describeContents() { + return 0; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/InternalFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/InternalFrame.java new file mode 100644 index 0000000000..ab8ccff343 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/InternalFrame.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** Internal ID3 frame that is intended for use by the player. */ +public final class InternalFrame extends Id3Frame { + + public static final String ID = "----"; + + public final String domain; + public final String description; + public final String text; + + public InternalFrame(String domain, String description, String text) { + super(ID); + this.domain = domain; + this.description = description; + this.text = text; + } + + /* package */ InternalFrame(Parcel in) { + super(ID); + domain = castNonNull(in.readString()); + description = castNonNull(in.readString()); + text = castNonNull(in.readString()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + InternalFrame other = (InternalFrame) obj; + return Util.areEqual(description, other.description) + && Util.areEqual(domain, other.domain) + && Util.areEqual(text, other.text); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (domain != null ? domain.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (text != null ? text.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return id + ": domain=" + domain + ", description=" + description; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(domain); + dest.writeString(text); + } + + public static final Creator<InternalFrame> CREATOR = + new Creator<InternalFrame>() { + + @Override + public InternalFrame createFromParcel(Parcel in) { + return new InternalFrame(in); + } + + @Override + public InternalFrame[] newArray(int size) { + return new InternalFrame[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/MlltFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/MlltFrame.java new file mode 100644 index 0000000000..441235d7c9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/MlltFrame.java @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import android.os.Parcel; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** MPEG location lookup table frame. */ +public final class MlltFrame extends Id3Frame { + + public static final String ID = "MLLT"; + + public final int mpegFramesBetweenReference; + public final int bytesBetweenReference; + public final int millisecondsBetweenReference; + public final int[] bytesDeviations; + public final int[] millisecondsDeviations; + + public MlltFrame( + int mpegFramesBetweenReference, + int bytesBetweenReference, + int millisecondsBetweenReference, + int[] bytesDeviations, + int[] millisecondsDeviations) { + super(ID); + this.mpegFramesBetweenReference = mpegFramesBetweenReference; + this.bytesBetweenReference = bytesBetweenReference; + this.millisecondsBetweenReference = millisecondsBetweenReference; + this.bytesDeviations = bytesDeviations; + this.millisecondsDeviations = millisecondsDeviations; + } + + /* package */ + MlltFrame(Parcel in) { + super(ID); + this.mpegFramesBetweenReference = in.readInt(); + this.bytesBetweenReference = in.readInt(); + this.millisecondsBetweenReference = in.readInt(); + this.bytesDeviations = Util.castNonNull(in.createIntArray()); + this.millisecondsDeviations = Util.castNonNull(in.createIntArray()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + MlltFrame other = (MlltFrame) obj; + return mpegFramesBetweenReference == other.mpegFramesBetweenReference + && bytesBetweenReference == other.bytesBetweenReference + && millisecondsBetweenReference == other.millisecondsBetweenReference + && Arrays.equals(bytesDeviations, other.bytesDeviations) + && Arrays.equals(millisecondsDeviations, other.millisecondsDeviations); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + mpegFramesBetweenReference; + result = 31 * result + bytesBetweenReference; + result = 31 * result + millisecondsBetweenReference; + result = 31 * result + Arrays.hashCode(bytesDeviations); + result = 31 * result + Arrays.hashCode(millisecondsDeviations); + return result; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(mpegFramesBetweenReference); + dest.writeInt(bytesBetweenReference); + dest.writeInt(millisecondsBetweenReference); + dest.writeIntArray(bytesDeviations); + dest.writeIntArray(millisecondsDeviations); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator<MlltFrame> CREATOR = + new Creator<MlltFrame>() { + + @Override + public MlltFrame createFromParcel(Parcel in) { + return new MlltFrame(in); + } + + @Override + public MlltFrame[] newArray(int size) { + return new MlltFrame[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/PrivFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/PrivFrame.java new file mode 100644 index 0000000000..248d9996dd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/PrivFrame.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * PRIV (Private) ID3 frame. + */ +public final class PrivFrame extends Id3Frame { + + public static final String ID = "PRIV"; + + public final String owner; + public final byte[] privateData; + + public PrivFrame(String owner, byte[] privateData) { + super(ID); + this.owner = owner; + this.privateData = privateData; + } + + /* package */ PrivFrame(Parcel in) { + super(ID); + owner = castNonNull(in.readString()); + privateData = castNonNull(in.createByteArray()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + PrivFrame other = (PrivFrame) obj; + return Util.areEqual(owner, other.owner) && Arrays.equals(privateData, other.privateData); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + (owner != null ? owner.hashCode() : 0); + result = 31 * result + Arrays.hashCode(privateData); + return result; + } + + @Override + public String toString() { + return id + ": owner=" + owner; + } + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(owner); + dest.writeByteArray(privateData); + } + + public static final Parcelable.Creator<PrivFrame> CREATOR = new Parcelable.Creator<PrivFrame>() { + + @Override + public PrivFrame createFromParcel(Parcel in) { + return new PrivFrame(in); + } + + @Override + public PrivFrame[] newArray(int size) { + return new PrivFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java new file mode 100644 index 0000000000..c0bd36ccf7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/TextInformationFrame.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Text information ID3 frame. + */ +public final class TextInformationFrame extends Id3Frame { + + @Nullable public final String description; + public final String value; + + public TextInformationFrame(String id, @Nullable String description, String value) { + super(id); + this.description = description; + this.value = value; + } + + /* package */ TextInformationFrame(Parcel in) { + super(castNonNull(in.readString())); + description = in.readString(); + value = castNonNull(in.readString()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TextInformationFrame other = (TextInformationFrame) obj; + return id.equals(other.id) && Util.areEqual(description, other.description) + && Util.areEqual(value, other.value); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (value != null ? value.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return id + ": description=" + description + ": value=" + value; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(description); + dest.writeString(value); + } + + public static final Parcelable.Creator<TextInformationFrame> CREATOR = + new Parcelable.Creator<TextInformationFrame>() { + + @Override + public TextInformationFrame createFromParcel(Parcel in) { + return new TextInformationFrame(in); + } + + @Override + public TextInformationFrame[] newArray(int size) { + return new TextInformationFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java new file mode 100644 index 0000000000..ced474960e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/UrlLinkFrame.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Url link ID3 frame. + */ +public final class UrlLinkFrame extends Id3Frame { + + @Nullable public final String description; + public final String url; + + public UrlLinkFrame(String id, @Nullable String description, String url) { + super(id); + this.description = description; + this.url = url; + } + + /* package */ UrlLinkFrame(Parcel in) { + super(castNonNull(in.readString())); + description = in.readString(); + url = castNonNull(in.readString()); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + UrlLinkFrame other = (UrlLinkFrame) obj; + return id.equals(other.id) && Util.areEqual(description, other.description) + && Util.areEqual(url, other.url); + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + id.hashCode(); + result = 31 * result + (description != null ? description.hashCode() : 0); + result = 31 * result + (url != null ? url.hashCode() : 0); + return result; + } + + @Override + public String toString() { + return id + ": url=" + url; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(description); + dest.writeString(url); + } + + public static final Parcelable.Creator<UrlLinkFrame> CREATOR = + new Parcelable.Creator<UrlLinkFrame>() { + + @Override + public UrlLinkFrame createFromParcel(Parcel in) { + return new UrlLinkFrame(in); + } + + @Override + public UrlLinkFrame[] newArray(int size) { + return new UrlLinkFrame[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/package-info.java new file mode 100644 index 0000000000..87b20161df --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/id3/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/package-info.java new file mode 100644 index 0000000000..e5775f7acc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/PrivateCommand.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/PrivateCommand.java new file mode 100644 index 0000000000..3437c8dd73 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/PrivateCommand.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Represents a private command as defined in SCTE35, Section 9.3.6. + */ +public final class PrivateCommand extends SpliceCommand { + + /** + * The {@code pts_adjustment} as defined in SCTE35, Section 9.2. + */ + public final long ptsAdjustment; + /** + * The identifier as defined in SCTE35, Section 9.3.6. + */ + public final long identifier; + /** + * The private bytes as defined in SCTE35, Section 9.3.6. + */ + public final byte[] commandBytes; + + private PrivateCommand(long identifier, byte[] commandBytes, long ptsAdjustment) { + this.ptsAdjustment = ptsAdjustment; + this.identifier = identifier; + this.commandBytes = commandBytes; + } + + private PrivateCommand(Parcel in) { + ptsAdjustment = in.readLong(); + identifier = in.readLong(); + commandBytes = Util.castNonNull(in.createByteArray()); + } + + /* package */ static PrivateCommand parseFromSection(ParsableByteArray sectionData, + int commandLength, long ptsAdjustment) { + long identifier = sectionData.readUnsignedInt(); + byte[] privateBytes = new byte[commandLength - 4 /* identifier size */]; + sectionData.readBytes(privateBytes, 0, privateBytes.length); + return new PrivateCommand(identifier, privateBytes, ptsAdjustment); + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(ptsAdjustment); + dest.writeLong(identifier); + dest.writeByteArray(commandBytes); + } + + public static final Parcelable.Creator<PrivateCommand> CREATOR = + new Parcelable.Creator<PrivateCommand>() { + + @Override + public PrivateCommand createFromParcel(Parcel in) { + return new PrivateCommand(in); + } + + @Override + public PrivateCommand[] newArray(int size) { + return new PrivateCommand[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceCommand.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceCommand.java new file mode 100644 index 0000000000..866a7ec8bc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceCommand.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; + +/** + * Superclass for SCTE35 splice commands. + */ +public abstract class SpliceCommand implements Metadata.Entry { + + @Override + public String toString() { + return "SCTE-35 splice command: type=" + getClass().getSimpleName(); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java new file mode 100644 index 0000000000..a90bddb078 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceInfoDecoder.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.MetadataInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.nio.ByteBuffer; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Decodes splice info sections and produces splice commands. + */ +public final class SpliceInfoDecoder implements MetadataDecoder { + + private static final int TYPE_SPLICE_NULL = 0x00; + private static final int TYPE_SPLICE_SCHEDULE = 0x04; + private static final int TYPE_SPLICE_INSERT = 0x05; + private static final int TYPE_TIME_SIGNAL = 0x06; + private static final int TYPE_PRIVATE_COMMAND = 0xFF; + + private final ParsableByteArray sectionData; + private final ParsableBitArray sectionHeader; + + @MonotonicNonNull private TimestampAdjuster timestampAdjuster; + + public SpliceInfoDecoder() { + sectionData = new ParsableByteArray(); + sectionHeader = new ParsableBitArray(); + } + + @SuppressWarnings("ByteBufferBackingArray") + @Override + public Metadata decode(MetadataInputBuffer inputBuffer) { + ByteBuffer buffer = Assertions.checkNotNull(inputBuffer.data); + + // Internal timestamps adjustment. + if (timestampAdjuster == null + || inputBuffer.subsampleOffsetUs != timestampAdjuster.getTimestampOffsetUs()) { + timestampAdjuster = new TimestampAdjuster(inputBuffer.timeUs); + timestampAdjuster.adjustSampleTimestamp(inputBuffer.timeUs - inputBuffer.subsampleOffsetUs); + } + + byte[] data = buffer.array(); + int size = buffer.limit(); + sectionData.reset(data, size); + sectionHeader.reset(data, size); + // table_id(8), section_syntax_indicator(1), private_indicator(1), reserved(2), + // section_length(12), protocol_version(8), encrypted_packet(1), encryption_algorithm(6). + sectionHeader.skipBits(39); + long ptsAdjustment = sectionHeader.readBits(1); + ptsAdjustment = (ptsAdjustment << 32) | sectionHeader.readBits(32); + // cw_index(8), tier(12). + sectionHeader.skipBits(20); + int spliceCommandLength = sectionHeader.readBits(12); + int spliceCommandType = sectionHeader.readBits(8); + @Nullable SpliceCommand command = null; + // Go to the start of the command by skipping all fields up to command_type. + sectionData.skipBytes(14); + switch (spliceCommandType) { + case TYPE_SPLICE_NULL: + command = new SpliceNullCommand(); + break; + case TYPE_SPLICE_SCHEDULE: + command = SpliceScheduleCommand.parseFromSection(sectionData); + break; + case TYPE_SPLICE_INSERT: + command = SpliceInsertCommand.parseFromSection(sectionData, ptsAdjustment, + timestampAdjuster); + break; + case TYPE_TIME_SIGNAL: + command = TimeSignalCommand.parseFromSection(sectionData, ptsAdjustment, timestampAdjuster); + break; + case TYPE_PRIVATE_COMMAND: + command = PrivateCommand.parseFromSection(sectionData, spliceCommandLength, ptsAdjustment); + break; + default: + // Do nothing. + break; + } + return command == null ? new Metadata() : new Metadata(command); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceInsertCommand.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceInsertCommand.java new file mode 100644 index 0000000000..5993efb10f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceInsertCommand.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a splice insert command defined in SCTE35, Section 9.3.3. + */ +public final class SpliceInsertCommand extends SpliceCommand { + + /** + * The splice event id. + */ + public final long spliceEventId; + /** + * True if the event with id {@link #spliceEventId} has been canceled. + */ + public final boolean spliceEventCancelIndicator; + /** + * If true, the splice event is an opportunity to exit from the network feed. If false, indicates + * an opportunity to return to the network feed. + */ + public final boolean outOfNetworkIndicator; + /** + * Whether the splice mode is program splice mode, whereby all PIDs/components are to be spliced. + * If false, splicing is done per PID/component. + */ + public final boolean programSpliceFlag; + /** + * Whether splicing should be done at the nearest opportunity. If false, splicing should be done + * at the moment indicated by {@link #programSplicePlaybackPositionUs} or + * {@link ComponentSplice#componentSplicePlaybackPositionUs}, depending on + * {@link #programSpliceFlag}. + */ + public final boolean spliceImmediateFlag; + /** + * If {@link #programSpliceFlag} is true, the PTS at which the program splice should occur. + * {@link C#TIME_UNSET} otherwise. + */ + public final long programSplicePts; + /** + * Equivalent to {@link #programSplicePts} but in the playback timebase. + */ + public final long programSplicePlaybackPositionUs; + /** + * If {@link #programSpliceFlag} is false, a non-empty list containing the + * {@link ComponentSplice}s. Otherwise, an empty list. + */ + public final List<ComponentSplice> componentSpliceList; + /** + * If {@link #breakDurationUs} is not {@link C#TIME_UNSET}, defines whether + * {@link #breakDurationUs} should be used to know when to return to the network feed. If + * {@link #breakDurationUs} is {@link C#TIME_UNSET}, the value is undefined. + */ + public final boolean autoReturn; + /** + * The duration of the splice in microseconds, or {@link C#TIME_UNSET} if no duration is present. + */ + public final long breakDurationUs; + /** + * The unique program id as defined in SCTE35, Section 9.3.3. + */ + public final int uniqueProgramId; + /** + * Holds the value of {@code avail_num} as defined in SCTE35, Section 9.3.3. + */ + public final int availNum; + /** + * Holds the value of {@code avails_expected} as defined in SCTE35, Section 9.3.3. + */ + public final int availsExpected; + + private SpliceInsertCommand(long spliceEventId, boolean spliceEventCancelIndicator, + boolean outOfNetworkIndicator, boolean programSpliceFlag, boolean spliceImmediateFlag, + long programSplicePts, long programSplicePlaybackPositionUs, + List<ComponentSplice> componentSpliceList, boolean autoReturn, long breakDurationUs, + int uniqueProgramId, int availNum, int availsExpected) { + this.spliceEventId = spliceEventId; + this.spliceEventCancelIndicator = spliceEventCancelIndicator; + this.outOfNetworkIndicator = outOfNetworkIndicator; + this.programSpliceFlag = programSpliceFlag; + this.spliceImmediateFlag = spliceImmediateFlag; + this.programSplicePts = programSplicePts; + this.programSplicePlaybackPositionUs = programSplicePlaybackPositionUs; + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + this.autoReturn = autoReturn; + this.breakDurationUs = breakDurationUs; + this.uniqueProgramId = uniqueProgramId; + this.availNum = availNum; + this.availsExpected = availsExpected; + } + + private SpliceInsertCommand(Parcel in) { + spliceEventId = in.readLong(); + spliceEventCancelIndicator = in.readByte() == 1; + outOfNetworkIndicator = in.readByte() == 1; + programSpliceFlag = in.readByte() == 1; + spliceImmediateFlag = in.readByte() == 1; + programSplicePts = in.readLong(); + programSplicePlaybackPositionUs = in.readLong(); + int componentSpliceListSize = in.readInt(); + List<ComponentSplice> componentSpliceList = new ArrayList<>(componentSpliceListSize); + for (int i = 0; i < componentSpliceListSize; i++) { + componentSpliceList.add(ComponentSplice.createFromParcel(in)); + } + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + autoReturn = in.readByte() == 1; + breakDurationUs = in.readLong(); + uniqueProgramId = in.readInt(); + availNum = in.readInt(); + availsExpected = in.readInt(); + } + + /* package */ static SpliceInsertCommand parseFromSection(ParsableByteArray sectionData, + long ptsAdjustment, TimestampAdjuster timestampAdjuster) { + long spliceEventId = sectionData.readUnsignedInt(); + // splice_event_cancel_indicator(1), reserved(7). + boolean spliceEventCancelIndicator = (sectionData.readUnsignedByte() & 0x80) != 0; + boolean outOfNetworkIndicator = false; + boolean programSpliceFlag = false; + boolean spliceImmediateFlag = false; + long programSplicePts = C.TIME_UNSET; + List<ComponentSplice> componentSplices = Collections.emptyList(); + int uniqueProgramId = 0; + int availNum = 0; + int availsExpected = 0; + boolean autoReturn = false; + long breakDurationUs = C.TIME_UNSET; + if (!spliceEventCancelIndicator) { + int headerByte = sectionData.readUnsignedByte(); + outOfNetworkIndicator = (headerByte & 0x80) != 0; + programSpliceFlag = (headerByte & 0x40) != 0; + boolean durationFlag = (headerByte & 0x20) != 0; + spliceImmediateFlag = (headerByte & 0x10) != 0; + if (programSpliceFlag && !spliceImmediateFlag) { + programSplicePts = TimeSignalCommand.parseSpliceTime(sectionData, ptsAdjustment); + } + if (!programSpliceFlag) { + int componentCount = sectionData.readUnsignedByte(); + componentSplices = new ArrayList<>(componentCount); + for (int i = 0; i < componentCount; i++) { + int componentTag = sectionData.readUnsignedByte(); + long componentSplicePts = C.TIME_UNSET; + if (!spliceImmediateFlag) { + componentSplicePts = TimeSignalCommand.parseSpliceTime(sectionData, ptsAdjustment); + } + componentSplices.add(new ComponentSplice(componentTag, componentSplicePts, + timestampAdjuster.adjustTsTimestamp(componentSplicePts))); + } + } + if (durationFlag) { + long firstByte = sectionData.readUnsignedByte(); + autoReturn = (firstByte & 0x80) != 0; + long breakDuration90khz = ((firstByte & 0x01) << 32) | sectionData.readUnsignedInt(); + breakDurationUs = breakDuration90khz * 1000 / 90; + } + uniqueProgramId = sectionData.readUnsignedShort(); + availNum = sectionData.readUnsignedByte(); + availsExpected = sectionData.readUnsignedByte(); + } + return new SpliceInsertCommand(spliceEventId, spliceEventCancelIndicator, outOfNetworkIndicator, + programSpliceFlag, spliceImmediateFlag, programSplicePts, + timestampAdjuster.adjustTsTimestamp(programSplicePts), componentSplices, autoReturn, + breakDurationUs, uniqueProgramId, availNum, availsExpected); + } + + /** + * Holds splicing information for specific splice insert command components. + */ + public static final class ComponentSplice { + + public final int componentTag; + public final long componentSplicePts; + public final long componentSplicePlaybackPositionUs; + + private ComponentSplice(int componentTag, long componentSplicePts, + long componentSplicePlaybackPositionUs) { + this.componentTag = componentTag; + this.componentSplicePts = componentSplicePts; + this.componentSplicePlaybackPositionUs = componentSplicePlaybackPositionUs; + } + + public void writeToParcel(Parcel dest) { + dest.writeInt(componentTag); + dest.writeLong(componentSplicePts); + dest.writeLong(componentSplicePlaybackPositionUs); + } + + public static ComponentSplice createFromParcel(Parcel in) { + return new ComponentSplice(in.readInt(), in.readLong(), in.readLong()); + } + + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(spliceEventId); + dest.writeByte((byte) (spliceEventCancelIndicator ? 1 : 0)); + dest.writeByte((byte) (outOfNetworkIndicator ? 1 : 0)); + dest.writeByte((byte) (programSpliceFlag ? 1 : 0)); + dest.writeByte((byte) (spliceImmediateFlag ? 1 : 0)); + dest.writeLong(programSplicePts); + dest.writeLong(programSplicePlaybackPositionUs); + int componentSpliceListSize = componentSpliceList.size(); + dest.writeInt(componentSpliceListSize); + for (int i = 0; i < componentSpliceListSize; i++) { + componentSpliceList.get(i).writeToParcel(dest); + } + dest.writeByte((byte) (autoReturn ? 1 : 0)); + dest.writeLong(breakDurationUs); + dest.writeInt(uniqueProgramId); + dest.writeInt(availNum); + dest.writeInt(availsExpected); + } + + public static final Parcelable.Creator<SpliceInsertCommand> CREATOR = + new Parcelable.Creator<SpliceInsertCommand>() { + + @Override + public SpliceInsertCommand createFromParcel(Parcel in) { + return new SpliceInsertCommand(in); + } + + @Override + public SpliceInsertCommand[] newArray(int size) { + return new SpliceInsertCommand[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceNullCommand.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceNullCommand.java new file mode 100644 index 0000000000..afc88bbeab --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceNullCommand.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; + +/** + * Represents a splice null command as defined in SCTE35, Section 9.3.1. + */ +public final class SpliceNullCommand extends SpliceCommand { + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + // Do nothing. + } + + public static final Creator<SpliceNullCommand> CREATOR = + new Creator<SpliceNullCommand>() { + + @Override + public SpliceNullCommand createFromParcel(Parcel in) { + return new SpliceNullCommand(); + } + + @Override + public SpliceNullCommand[] newArray(int size) { + return new SpliceNullCommand[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceScheduleCommand.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceScheduleCommand.java new file mode 100644 index 0000000000..e1d369bc87 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/SpliceScheduleCommand.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents a splice schedule command as defined in SCTE35, Section 9.3.2. + */ +public final class SpliceScheduleCommand extends SpliceCommand { + + /** + * Represents a splice event as contained in a {@link SpliceScheduleCommand}. + */ + public static final class Event { + + /** + * The splice event id. + */ + public final long spliceEventId; + /** + * True if the event with id {@link #spliceEventId} has been canceled. + */ + public final boolean spliceEventCancelIndicator; + /** + * If true, the splice event is an opportunity to exit from the network feed. If false, + * indicates an opportunity to return to the network feed. + */ + public final boolean outOfNetworkIndicator; + /** + * Whether the splice mode is program splice mode, whereby all PIDs/components are to be + * spliced. If false, splicing is done per PID/component. + */ + public final boolean programSpliceFlag; + /** + * Represents the time of the signaled splice event as the number of seconds since 00 hours UTC, + * January 6th, 1980, with the count of intervening leap seconds included. + */ + public final long utcSpliceTime; + /** + * If {@link #programSpliceFlag} is false, a non-empty list containing the + * {@link ComponentSplice}s. Otherwise, an empty list. + */ + public final List<ComponentSplice> componentSpliceList; + /** + * If {@link #breakDurationUs} is not {@link C#TIME_UNSET}, defines whether + * {@link #breakDurationUs} should be used to know when to return to the network feed. If + * {@link #breakDurationUs} is {@link C#TIME_UNSET}, the value is undefined. + */ + public final boolean autoReturn; + /** + * The duration of the splice in microseconds, or {@link C#TIME_UNSET} if no duration is + * present. + */ + public final long breakDurationUs; + /** + * The unique program id as defined in SCTE35, Section 9.3.2. + */ + public final int uniqueProgramId; + /** + * Holds the value of {@code avail_num} as defined in SCTE35, Section 9.3.2. + */ + public final int availNum; + /** + * Holds the value of {@code avails_expected} as defined in SCTE35, Section 9.3.2. + */ + public final int availsExpected; + + private Event(long spliceEventId, boolean spliceEventCancelIndicator, + boolean outOfNetworkIndicator, boolean programSpliceFlag, + List<ComponentSplice> componentSpliceList, long utcSpliceTime, boolean autoReturn, + long breakDurationUs, int uniqueProgramId, int availNum, int availsExpected) { + this.spliceEventId = spliceEventId; + this.spliceEventCancelIndicator = spliceEventCancelIndicator; + this.outOfNetworkIndicator = outOfNetworkIndicator; + this.programSpliceFlag = programSpliceFlag; + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + this.utcSpliceTime = utcSpliceTime; + this.autoReturn = autoReturn; + this.breakDurationUs = breakDurationUs; + this.uniqueProgramId = uniqueProgramId; + this.availNum = availNum; + this.availsExpected = availsExpected; + } + + private Event(Parcel in) { + this.spliceEventId = in.readLong(); + this.spliceEventCancelIndicator = in.readByte() == 1; + this.outOfNetworkIndicator = in.readByte() == 1; + this.programSpliceFlag = in.readByte() == 1; + int componentSpliceListLength = in.readInt(); + ArrayList<ComponentSplice> componentSpliceList = new ArrayList<>(componentSpliceListLength); + for (int i = 0; i < componentSpliceListLength; i++) { + componentSpliceList.add(ComponentSplice.createFromParcel(in)); + } + this.componentSpliceList = Collections.unmodifiableList(componentSpliceList); + this.utcSpliceTime = in.readLong(); + this.autoReturn = in.readByte() == 1; + this.breakDurationUs = in.readLong(); + this.uniqueProgramId = in.readInt(); + this.availNum = in.readInt(); + this.availsExpected = in.readInt(); + } + + private static Event parseFromSection(ParsableByteArray sectionData) { + long spliceEventId = sectionData.readUnsignedInt(); + // splice_event_cancel_indicator(1), reserved(7). + boolean spliceEventCancelIndicator = (sectionData.readUnsignedByte() & 0x80) != 0; + boolean outOfNetworkIndicator = false; + boolean programSpliceFlag = false; + long utcSpliceTime = C.TIME_UNSET; + ArrayList<ComponentSplice> componentSplices = new ArrayList<>(); + int uniqueProgramId = 0; + int availNum = 0; + int availsExpected = 0; + boolean autoReturn = false; + long breakDurationUs = C.TIME_UNSET; + if (!spliceEventCancelIndicator) { + int headerByte = sectionData.readUnsignedByte(); + outOfNetworkIndicator = (headerByte & 0x80) != 0; + programSpliceFlag = (headerByte & 0x40) != 0; + boolean durationFlag = (headerByte & 0x20) != 0; + if (programSpliceFlag) { + utcSpliceTime = sectionData.readUnsignedInt(); + } + if (!programSpliceFlag) { + int componentCount = sectionData.readUnsignedByte(); + componentSplices = new ArrayList<>(componentCount); + for (int i = 0; i < componentCount; i++) { + int componentTag = sectionData.readUnsignedByte(); + long componentUtcSpliceTime = sectionData.readUnsignedInt(); + componentSplices.add(new ComponentSplice(componentTag, componentUtcSpliceTime)); + } + } + if (durationFlag) { + long firstByte = sectionData.readUnsignedByte(); + autoReturn = (firstByte & 0x80) != 0; + long breakDuration90khz = ((firstByte & 0x01) << 32) | sectionData.readUnsignedInt(); + breakDurationUs = breakDuration90khz * 1000 / 90; + } + uniqueProgramId = sectionData.readUnsignedShort(); + availNum = sectionData.readUnsignedByte(); + availsExpected = sectionData.readUnsignedByte(); + } + return new Event(spliceEventId, spliceEventCancelIndicator, outOfNetworkIndicator, + programSpliceFlag, componentSplices, utcSpliceTime, autoReturn, breakDurationUs, + uniqueProgramId, availNum, availsExpected); + } + + private void writeToParcel(Parcel dest) { + dest.writeLong(spliceEventId); + dest.writeByte((byte) (spliceEventCancelIndicator ? 1 : 0)); + dest.writeByte((byte) (outOfNetworkIndicator ? 1 : 0)); + dest.writeByte((byte) (programSpliceFlag ? 1 : 0)); + int componentSpliceListSize = componentSpliceList.size(); + dest.writeInt(componentSpliceListSize); + for (int i = 0; i < componentSpliceListSize; i++) { + componentSpliceList.get(i).writeToParcel(dest); + } + dest.writeLong(utcSpliceTime); + dest.writeByte((byte) (autoReturn ? 1 : 0)); + dest.writeLong(breakDurationUs); + dest.writeInt(uniqueProgramId); + dest.writeInt(availNum); + dest.writeInt(availsExpected); + } + + private static Event createFromParcel(Parcel in) { + return new Event(in); + } + + } + + /** + * Holds splicing information for specific splice schedule command components. + */ + public static final class ComponentSplice { + + public final int componentTag; + public final long utcSpliceTime; + + private ComponentSplice(int componentTag, long utcSpliceTime) { + this.componentTag = componentTag; + this.utcSpliceTime = utcSpliceTime; + } + + private static ComponentSplice createFromParcel(Parcel in) { + return new ComponentSplice(in.readInt(), in.readLong()); + } + + private void writeToParcel(Parcel dest) { + dest.writeInt(componentTag); + dest.writeLong(utcSpliceTime); + } + + } + + /** + * The list of scheduled events. + */ + public final List<Event> events; + + private SpliceScheduleCommand(List<Event> events) { + this.events = Collections.unmodifiableList(events); + } + + private SpliceScheduleCommand(Parcel in) { + int eventsSize = in.readInt(); + ArrayList<Event> events = new ArrayList<>(eventsSize); + for (int i = 0; i < eventsSize; i++) { + events.add(Event.createFromParcel(in)); + } + this.events = Collections.unmodifiableList(events); + } + + /* package */ static SpliceScheduleCommand parseFromSection(ParsableByteArray sectionData) { + int spliceCount = sectionData.readUnsignedByte(); + ArrayList<Event> events = new ArrayList<>(spliceCount); + for (int i = 0; i < spliceCount; i++) { + events.add(Event.parseFromSection(sectionData)); + } + return new SpliceScheduleCommand(events); + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + int eventsSize = events.size(); + dest.writeInt(eventsSize); + for (int i = 0; i < eventsSize; i++) { + events.get(i).writeToParcel(dest); + } + } + + public static final Parcelable.Creator<SpliceScheduleCommand> CREATOR = + new Parcelable.Creator<SpliceScheduleCommand>() { + + @Override + public SpliceScheduleCommand createFromParcel(Parcel in) { + return new SpliceScheduleCommand(in); + } + + @Override + public SpliceScheduleCommand[] newArray(int size) { + return new SpliceScheduleCommand[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/TimeSignalCommand.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/TimeSignalCommand.java new file mode 100644 index 0000000000..f50a029f1b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/TimeSignalCommand.java @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import android.os.Parcel; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; + +/** + * Represents a time signal command as defined in SCTE35, Section 9.3.4. + */ +public final class TimeSignalCommand extends SpliceCommand { + + /** + * A PTS value, as defined in SCTE35, Section 9.3.4. + */ + public final long ptsTime; + /** + * Equivalent to {@link #ptsTime} but in the playback timebase. + */ + public final long playbackPositionUs; + + private TimeSignalCommand(long ptsTime, long playbackPositionUs) { + this.ptsTime = ptsTime; + this.playbackPositionUs = playbackPositionUs; + } + + /* package */ static TimeSignalCommand parseFromSection(ParsableByteArray sectionData, + long ptsAdjustment, TimestampAdjuster timestampAdjuster) { + long ptsTime = parseSpliceTime(sectionData, ptsAdjustment); + long playbackPositionUs = timestampAdjuster.adjustTsTimestamp(ptsTime); + return new TimeSignalCommand(ptsTime, playbackPositionUs); + } + + /** + * Parses pts_time from splice_time(), defined in Section 9.4.1. Returns {@link C#TIME_UNSET}, if + * time_specified_flag is false. + * + * @param sectionData The section data from which the pts_time is parsed. + * @param ptsAdjustment The pts adjustment provided by the splice info section header. + * @return The pts_time defined by splice_time(), or {@link C#TIME_UNSET}, if time_specified_flag + * is false. + */ + /* package */ static long parseSpliceTime(ParsableByteArray sectionData, long ptsAdjustment) { + long firstByte = sectionData.readUnsignedByte(); + long ptsTime = C.TIME_UNSET; + if ((firstByte & 0x80) != 0 /* time_specified_flag */) { + // See SCTE35 9.2.1 for more information about pts adjustment. + ptsTime = (firstByte & 0x01) << 32 | sectionData.readUnsignedInt(); + ptsTime += ptsAdjustment; + ptsTime &= 0x1FFFFFFFFL; + } + return ptsTime; + } + + // Parcelable implementation. + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(ptsTime); + dest.writeLong(playbackPositionUs); + } + + public static final Creator<TimeSignalCommand> CREATOR = + new Creator<TimeSignalCommand>() { + + @Override + public TimeSignalCommand createFromParcel(Parcel in) { + return new TimeSignalCommand(in.readLong(), in.readLong()); + } + + @Override + public TimeSignalCommand[] newArray(int size) { + return new TimeSignalCommand[size]; + } + + }; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/package-info.java new file mode 100644 index 0000000000..17ce76bb9f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/metadata/scte35/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.scte35; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java new file mode 100644 index 0000000000..5451ea5530 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFile.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.DownloadRequest.UnsupportedRequestException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.AtomicFile; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.DataInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads {@link DownloadRequest DownloadRequests} from legacy action files. + * + * @deprecated Legacy action files should be merged into download indices using {@link + * ActionFileUpgradeUtil}. + */ +@Deprecated +/* package */ final class ActionFile { + + private static final int VERSION = 0; + + private final AtomicFile atomicFile; + + /** + * @param actionFile The file from which {@link DownloadRequest DownloadRequests} will be loaded. + */ + public ActionFile(File actionFile) { + atomicFile = new AtomicFile(actionFile); + } + + /** Returns whether the file or its backup exists. */ + public boolean exists() { + return atomicFile.exists(); + } + + /** Deletes the action file and its backup. */ + public void delete() { + atomicFile.delete(); + } + + /** + * Loads {@link DownloadRequest DownloadRequests} from the file. + * + * @return The loaded {@link DownloadRequest DownloadRequests}, or an empty array if the file does + * not exist. + * @throws IOException If there is an error reading the file. + */ + public DownloadRequest[] load() throws IOException { + if (!exists()) { + return new DownloadRequest[0]; + } + @Nullable InputStream inputStream = null; + try { + inputStream = atomicFile.openRead(); + DataInputStream dataInputStream = new DataInputStream(inputStream); + int version = dataInputStream.readInt(); + if (version > VERSION) { + throw new IOException("Unsupported action file version: " + version); + } + int actionCount = dataInputStream.readInt(); + ArrayList<DownloadRequest> actions = new ArrayList<>(); + for (int i = 0; i < actionCount; i++) { + try { + actions.add(readDownloadRequest(dataInputStream)); + } catch (UnsupportedRequestException e) { + // remove DownloadRequest is not supported. Ignore and continue loading rest. + } + } + return actions.toArray(new DownloadRequest[0]); + } finally { + Util.closeQuietly(inputStream); + } + } + + private static DownloadRequest readDownloadRequest(DataInputStream input) throws IOException { + String type = input.readUTF(); + int version = input.readInt(); + + Uri uri = Uri.parse(input.readUTF()); + boolean isRemoveAction = input.readBoolean(); + + int dataLength = input.readInt(); + @Nullable byte[] data; + if (dataLength != 0) { + data = new byte[dataLength]; + input.readFully(data); + } else { + data = null; + } + + // Serialized version 0 progressive actions did not contain keys. + boolean isLegacyProgressive = version == 0 && DownloadRequest.TYPE_PROGRESSIVE.equals(type); + List<StreamKey> keys = new ArrayList<>(); + if (!isLegacyProgressive) { + int keyCount = input.readInt(); + for (int i = 0; i < keyCount; i++) { + keys.add(readKey(type, version, input)); + } + } + + // Serialized version 0 and 1 DASH/HLS/SS actions did not contain a custom cache key. + boolean isLegacySegmented = + version < 2 + && (DownloadRequest.TYPE_DASH.equals(type) + || DownloadRequest.TYPE_HLS.equals(type) + || DownloadRequest.TYPE_SS.equals(type)); + @Nullable String customCacheKey = null; + if (!isLegacySegmented) { + customCacheKey = input.readBoolean() ? input.readUTF() : null; + } + + // Serialized version 0, 1 and 2 did not contain an id. We need to generate one. + String id = version < 3 ? generateDownloadId(uri, customCacheKey) : input.readUTF(); + + if (isRemoveAction) { + // Remove actions are not supported anymore. + throw new UnsupportedRequestException(); + } + return new DownloadRequest(id, type, uri, keys, customCacheKey, data); + } + + private static StreamKey readKey(String type, int version, DataInputStream input) + throws IOException { + int periodIndex; + int groupIndex; + int trackIndex; + + // Serialized version 0 HLS/SS actions did not contain a period index. + if ((DownloadRequest.TYPE_HLS.equals(type) || DownloadRequest.TYPE_SS.equals(type)) + && version == 0) { + periodIndex = 0; + groupIndex = input.readInt(); + trackIndex = input.readInt(); + } else { + periodIndex = input.readInt(); + groupIndex = input.readInt(); + trackIndex = input.readInt(); + } + return new StreamKey(periodIndex, groupIndex, trackIndex); + } + + private static String generateDownloadId(Uri uri, @Nullable String customCacheKey) { + return customCacheKey != null ? customCacheKey : uri.toString(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java new file mode 100644 index 0000000000..aa66c73e6b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ActionFileUpgradeUtil.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_QUEUED; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.File; +import java.io.IOException; + +/** Utility class for upgrading legacy action files into {@link DefaultDownloadIndex}. */ +public final class ActionFileUpgradeUtil { + + /** Provides download IDs during action file upgrade. */ + public interface DownloadIdProvider { + + /** + * Returns a download id for given request. + * + * @param downloadRequest The request for which an ID is required. + * @return A corresponding download ID. + */ + String getId(DownloadRequest downloadRequest); + } + + private ActionFileUpgradeUtil() {} + + /** + * Merges {@link DownloadRequest DownloadRequests} contained in a legacy action file into a {@link + * DefaultDownloadIndex}, deleting the action file if the merge is successful or if {@code + * deleteOnFailure} is {@code true}. + * + * <p>This method must not be called while the {@link DefaultDownloadIndex} is being used by a + * {@link DownloadManager}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param actionFilePath The action file path. + * @param downloadIdProvider A download ID provider, or {@code null}. If {@code null} then ID of + * each download will be its custom cache key if one is specified, or else its URL. + * @param downloadIndex The index into which the requests will be merged. + * @param deleteOnFailure Whether to delete the action file if the merge fails. + * @param addNewDownloadsAsCompleted Whether to add new downloads as completed. + * @throws IOException If an error occurs loading or merging the requests. + */ + @WorkerThread + @SuppressWarnings("deprecation") + public static void upgradeAndDelete( + File actionFilePath, + @Nullable DownloadIdProvider downloadIdProvider, + DefaultDownloadIndex downloadIndex, + boolean deleteOnFailure, + boolean addNewDownloadsAsCompleted) + throws IOException { + ActionFile actionFile = new ActionFile(actionFilePath); + if (actionFile.exists()) { + boolean success = false; + try { + long nowMs = System.currentTimeMillis(); + for (DownloadRequest request : actionFile.load()) { + if (downloadIdProvider != null) { + request = request.copyWithId(downloadIdProvider.getId(request)); + } + mergeRequest(request, downloadIndex, addNewDownloadsAsCompleted, nowMs); + } + success = true; + } finally { + if (success || deleteOnFailure) { + actionFile.delete(); + } + } + } + } + + /** + * Merges a {@link DownloadRequest} into a {@link DefaultDownloadIndex}. + * + * @param request The request to be merged. + * @param downloadIndex The index into which the request will be merged. + * @param addNewDownloadAsCompleted Whether to add new downloads as completed. + * @throws IOException If an error occurs merging the request. + */ + /* package */ static void mergeRequest( + DownloadRequest request, + DefaultDownloadIndex downloadIndex, + boolean addNewDownloadAsCompleted, + long nowMs) + throws IOException { + @Nullable Download download = downloadIndex.getDownload(request.id); + if (download != null) { + download = DownloadManager.mergeRequest(download, request, download.stopReason, nowMs); + } else { + download = + new Download( + request, + addNewDownloadAsCompleted ? Download.STATE_COMPLETED : STATE_QUEUED, + /* startTimeMs= */ nowMs, + /* updateTimeMs= */ nowMs, + /* contentLength= */ C.LENGTH_UNSET, + Download.STOP_REASON_NONE, + Download.FAILURE_REASON_NONE); + } + downloadIndex.putDownload(download); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java new file mode 100644 index 0000000000..cc1a2873f5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloadIndex.java @@ -0,0 +1,452 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.net.Uri; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.FailureReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.State; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.List; + +/** A {@link DownloadIndex} that uses SQLite to persist {@link Download Downloads}. */ +public final class DefaultDownloadIndex implements WritableDownloadIndex { + + private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "Downloads"; + + @VisibleForTesting /* package */ static final int TABLE_VERSION = 2; + + private static final String COLUMN_ID = "id"; + private static final String COLUMN_TYPE = "title"; + private static final String COLUMN_URI = "uri"; + private static final String COLUMN_STREAM_KEYS = "stream_keys"; + private static final String COLUMN_CUSTOM_CACHE_KEY = "custom_cache_key"; + private static final String COLUMN_DATA = "data"; + private static final String COLUMN_STATE = "state"; + private static final String COLUMN_START_TIME_MS = "start_time_ms"; + private static final String COLUMN_UPDATE_TIME_MS = "update_time_ms"; + private static final String COLUMN_CONTENT_LENGTH = "content_length"; + private static final String COLUMN_STOP_REASON = "stop_reason"; + private static final String COLUMN_FAILURE_REASON = "failure_reason"; + private static final String COLUMN_PERCENT_DOWNLOADED = "percent_downloaded"; + private static final String COLUMN_BYTES_DOWNLOADED = "bytes_downloaded"; + + private static final int COLUMN_INDEX_ID = 0; + private static final int COLUMN_INDEX_TYPE = 1; + private static final int COLUMN_INDEX_URI = 2; + private static final int COLUMN_INDEX_STREAM_KEYS = 3; + private static final int COLUMN_INDEX_CUSTOM_CACHE_KEY = 4; + private static final int COLUMN_INDEX_DATA = 5; + private static final int COLUMN_INDEX_STATE = 6; + private static final int COLUMN_INDEX_START_TIME_MS = 7; + private static final int COLUMN_INDEX_UPDATE_TIME_MS = 8; + private static final int COLUMN_INDEX_CONTENT_LENGTH = 9; + private static final int COLUMN_INDEX_STOP_REASON = 10; + private static final int COLUMN_INDEX_FAILURE_REASON = 11; + private static final int COLUMN_INDEX_PERCENT_DOWNLOADED = 12; + private static final int COLUMN_INDEX_BYTES_DOWNLOADED = 13; + + private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?"; + private static final String WHERE_STATE_IS_DOWNLOADING = + COLUMN_STATE + " = " + Download.STATE_DOWNLOADING; + private static final String WHERE_STATE_IS_TERMINAL = + getStateQuery(Download.STATE_COMPLETED, Download.STATE_FAILED); + + private static final String[] COLUMNS = + new String[] { + COLUMN_ID, + COLUMN_TYPE, + COLUMN_URI, + COLUMN_STREAM_KEYS, + COLUMN_CUSTOM_CACHE_KEY, + COLUMN_DATA, + COLUMN_STATE, + COLUMN_START_TIME_MS, + COLUMN_UPDATE_TIME_MS, + COLUMN_CONTENT_LENGTH, + COLUMN_STOP_REASON, + COLUMN_FAILURE_REASON, + COLUMN_PERCENT_DOWNLOADED, + COLUMN_BYTES_DOWNLOADED, + }; + + private static final String TABLE_SCHEMA = + "(" + + COLUMN_ID + + " TEXT PRIMARY KEY NOT NULL," + + COLUMN_TYPE + + " TEXT NOT NULL," + + COLUMN_URI + + " TEXT NOT NULL," + + COLUMN_STREAM_KEYS + + " TEXT NOT NULL," + + COLUMN_CUSTOM_CACHE_KEY + + " TEXT," + + COLUMN_DATA + + " BLOB NOT NULL," + + COLUMN_STATE + + " INTEGER NOT NULL," + + COLUMN_START_TIME_MS + + " INTEGER NOT NULL," + + COLUMN_UPDATE_TIME_MS + + " INTEGER NOT NULL," + + COLUMN_CONTENT_LENGTH + + " INTEGER NOT NULL," + + COLUMN_STOP_REASON + + " INTEGER NOT NULL," + + COLUMN_FAILURE_REASON + + " INTEGER NOT NULL," + + COLUMN_PERCENT_DOWNLOADED + + " REAL NOT NULL," + + COLUMN_BYTES_DOWNLOADED + + " INTEGER NOT NULL)"; + + private static final String TRUE = "1"; + + private final String name; + private final String tableName; + private final DatabaseProvider databaseProvider; + + private boolean initialized; + + /** + * Creates an instance that stores the {@link Download Downloads} in an SQLite database provided + * by a {@link DatabaseProvider}. + * + * <p>Equivalent to calling {@link #DefaultDownloadIndex(DatabaseProvider, String)} with {@code + * name=""}. + * + * <p>Applications that only have one download index may use this constructor. Applications that + * have multiple download indices should call {@link #DefaultDownloadIndex(DatabaseProvider, + * String)} to specify a unique name for each index. + * + * @param databaseProvider Provides the SQLite database in which downloads are persisted. + */ + public DefaultDownloadIndex(DatabaseProvider databaseProvider) { + this(databaseProvider, ""); + } + + /** + * Creates an instance that stores the {@link Download Downloads} in an SQLite database provided + * by a {@link DatabaseProvider}. + * + * @param databaseProvider Provides the SQLite database in which downloads are persisted. + * @param name The name of the index. This name is incorporated into the names of the SQLite + * tables in which downloads are persisted. + */ + public DefaultDownloadIndex(DatabaseProvider databaseProvider, String name) { + this.name = name; + this.databaseProvider = databaseProvider; + tableName = TABLE_PREFIX + name; + } + + @Override + @Nullable + public Download getDownload(String id) throws DatabaseIOException { + ensureInitialized(); + try (Cursor cursor = getCursor(WHERE_ID_EQUALS, new String[] {id})) { + if (cursor.getCount() == 0) { + return null; + } + cursor.moveToNext(); + return getDownloadForCurrentRow(cursor); + } catch (SQLiteException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public DownloadCursor getDownloads(@Download.State int... states) throws DatabaseIOException { + ensureInitialized(); + Cursor cursor = getCursor(getStateQuery(states), /* selectionArgs= */ null); + return new DownloadCursorImpl(cursor); + } + + @Override + public void putDownload(Download download) throws DatabaseIOException { + ensureInitialized(); + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, download.request.id); + values.put(COLUMN_TYPE, download.request.type); + values.put(COLUMN_URI, download.request.uri.toString()); + values.put(COLUMN_STREAM_KEYS, encodeStreamKeys(download.request.streamKeys)); + values.put(COLUMN_CUSTOM_CACHE_KEY, download.request.customCacheKey); + values.put(COLUMN_DATA, download.request.data); + values.put(COLUMN_STATE, download.state); + values.put(COLUMN_START_TIME_MS, download.startTimeMs); + values.put(COLUMN_UPDATE_TIME_MS, download.updateTimeMs); + values.put(COLUMN_CONTENT_LENGTH, download.contentLength); + values.put(COLUMN_STOP_REASON, download.stopReason); + values.put(COLUMN_FAILURE_REASON, download.failureReason); + values.put(COLUMN_PERCENT_DOWNLOADED, download.getPercentDownloaded()); + values.put(COLUMN_BYTES_DOWNLOADED, download.getBytesDownloaded()); + try { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + } catch (SQLiteException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void removeDownload(String id) throws DatabaseIOException { + ensureInitialized(); + try { + databaseProvider.getWritableDatabase().delete(tableName, WHERE_ID_EQUALS, new String[] {id}); + } catch (SQLiteException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void setDownloadingStatesToQueued() throws DatabaseIOException { + ensureInitialized(); + try { + ContentValues values = new ContentValues(); + values.put(COLUMN_STATE, Download.STATE_QUEUED); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.update(tableName, values, WHERE_STATE_IS_DOWNLOADING, /* whereArgs= */ null); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void setStatesToRemoving() throws DatabaseIOException { + ensureInitialized(); + try { + ContentValues values = new ContentValues(); + values.put(COLUMN_STATE, Download.STATE_REMOVING); + // Only downloads in STATE_FAILED are allowed a failure reason, so we need to clear it here in + // case we're moving downloads from STATE_FAILED to STATE_REMOVING. + values.put(COLUMN_FAILURE_REASON, Download.FAILURE_REASON_NONE); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.update(tableName, values, /* whereClause= */ null, /* whereArgs= */ null); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void setStopReason(int stopReason) throws DatabaseIOException { + ensureInitialized(); + try { + ContentValues values = new ContentValues(); + values.put(COLUMN_STOP_REASON, stopReason); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.update(tableName, values, WHERE_STATE_IS_TERMINAL, /* whereArgs= */ null); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void setStopReason(String id, int stopReason) throws DatabaseIOException { + ensureInitialized(); + try { + ContentValues values = new ContentValues(); + values.put(COLUMN_STOP_REASON, stopReason); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.update( + tableName, + values, + WHERE_STATE_IS_TERMINAL + " AND " + WHERE_ID_EQUALS, + new String[] {id}); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + private void ensureInitialized() throws DatabaseIOException { + if (initialized) { + return; + } + try { + SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase(); + int version = VersionTable.getVersion(readableDatabase, VersionTable.FEATURE_OFFLINE, name); + if (version != TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + VersionTable.setVersion( + writableDatabase, VersionTable.FEATURE_OFFLINE, name, TABLE_VERSION); + writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName); + writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } + initialized = true; + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + // incompatible types in argument. + @SuppressWarnings("nullness:argument.type.incompatible") + private Cursor getCursor(String selection, @Nullable String[] selectionArgs) + throws DatabaseIOException { + try { + String sortOrder = COLUMN_START_TIME_MS + " ASC"; + return databaseProvider + .getReadableDatabase() + .query( + tableName, + COLUMNS, + selection, + selectionArgs, + /* groupBy= */ null, + /* having= */ null, + sortOrder); + } catch (SQLiteException e) { + throw new DatabaseIOException(e); + } + } + + private static String getStateQuery(@Download.State int... states) { + if (states.length == 0) { + return TRUE; + } + StringBuilder selectionBuilder = new StringBuilder(); + selectionBuilder.append(COLUMN_STATE).append(" IN ("); + for (int i = 0; i < states.length; i++) { + if (i > 0) { + selectionBuilder.append(','); + } + selectionBuilder.append(states[i]); + } + selectionBuilder.append(')'); + return selectionBuilder.toString(); + } + + private static Download getDownloadForCurrentRow(Cursor cursor) { + DownloadRequest request = + new DownloadRequest( + /* id= */ cursor.getString(COLUMN_INDEX_ID), + /* type= */ cursor.getString(COLUMN_INDEX_TYPE), + /* uri= */ Uri.parse(cursor.getString(COLUMN_INDEX_URI)), + /* streamKeys= */ decodeStreamKeys(cursor.getString(COLUMN_INDEX_STREAM_KEYS)), + /* customCacheKey= */ cursor.getString(COLUMN_INDEX_CUSTOM_CACHE_KEY), + /* data= */ cursor.getBlob(COLUMN_INDEX_DATA)); + DownloadProgress downloadProgress = new DownloadProgress(); + downloadProgress.bytesDownloaded = cursor.getLong(COLUMN_INDEX_BYTES_DOWNLOADED); + downloadProgress.percentDownloaded = cursor.getFloat(COLUMN_INDEX_PERCENT_DOWNLOADED); + @State int state = cursor.getInt(COLUMN_INDEX_STATE); + // It's possible the database contains failure reasons for non-failed downloads, which is + // invalid. Clear them here. See https://github.com/google/ExoPlayer/issues/6785. + @FailureReason + int failureReason = + state == Download.STATE_FAILED + ? cursor.getInt(COLUMN_INDEX_FAILURE_REASON) + : Download.FAILURE_REASON_NONE; + return new Download( + request, + state, + /* startTimeMs= */ cursor.getLong(COLUMN_INDEX_START_TIME_MS), + /* updateTimeMs= */ cursor.getLong(COLUMN_INDEX_UPDATE_TIME_MS), + /* contentLength= */ cursor.getLong(COLUMN_INDEX_CONTENT_LENGTH), + /* stopReason= */ cursor.getInt(COLUMN_INDEX_STOP_REASON), + failureReason, + downloadProgress); + } + + private static String encodeStreamKeys(List<StreamKey> streamKeys) { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < streamKeys.size(); i++) { + StreamKey streamKey = streamKeys.get(i); + stringBuilder + .append(streamKey.periodIndex) + .append('.') + .append(streamKey.groupIndex) + .append('.') + .append(streamKey.trackIndex) + .append(','); + } + if (stringBuilder.length() > 0) { + stringBuilder.setLength(stringBuilder.length() - 1); + } + return stringBuilder.toString(); + } + + private static List<StreamKey> decodeStreamKeys(String encodedStreamKeys) { + ArrayList<StreamKey> streamKeys = new ArrayList<>(); + if (encodedStreamKeys.isEmpty()) { + return streamKeys; + } + String[] streamKeysStrings = Util.split(encodedStreamKeys, ","); + for (String streamKeysString : streamKeysStrings) { + String[] indices = Util.split(streamKeysString, "\\."); + Assertions.checkState(indices.length == 3); + streamKeys.add( + new StreamKey( + Integer.parseInt(indices[0]), + Integer.parseInt(indices[1]), + Integer.parseInt(indices[2]))); + } + return streamKeys; + } + + private static final class DownloadCursorImpl implements DownloadCursor { + + private final Cursor cursor; + + private DownloadCursorImpl(Cursor cursor) { + this.cursor = cursor; + } + + @Override + public Download getDownload() { + return getDownloadForCurrentRow(cursor); + } + + @Override + public int getCount() { + return cursor.getCount(); + } + + @Override + public int getPosition() { + return cursor.getPosition(); + } + + @Override + public boolean moveToPosition(int position) { + return cursor.moveToPosition(position); + } + + @Override + public void close() { + cursor.close(); + } + + @Override + public boolean isClosed() { + return cursor.isClosed(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java new file mode 100644 index 0000000000..6391af8a95 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DefaultDownloaderFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.net.Uri; +import androidx.annotation.Nullable; +import java.lang.reflect.Constructor; +import java.util.List; + +/** + * Default {@link DownloaderFactory}, supporting creation of progressive, DASH, HLS and + * SmoothStreaming downloaders. Note that for the latter three, the corresponding library module + * must be built into the application. + */ +public class DefaultDownloaderFactory implements DownloaderFactory { + + @Nullable private static final Constructor<? extends Downloader> DASH_DOWNLOADER_CONSTRUCTOR; + @Nullable private static final Constructor<? extends Downloader> HLS_DOWNLOADER_CONSTRUCTOR; + @Nullable private static final Constructor<? extends Downloader> SS_DOWNLOADER_CONSTRUCTOR; + + static { + Constructor<? extends Downloader> dashDownloaderConstructor = null; + try { + // LINT.IfChange + dashDownloaderConstructor = + getDownloaderConstructor( + Class.forName("com.google.android.exoplayer2.source.dash.offline.DashDownloader")); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the app was built without the DASH module. + } + DASH_DOWNLOADER_CONSTRUCTOR = dashDownloaderConstructor; + Constructor<? extends Downloader> hlsDownloaderConstructor = null; + try { + // LINT.IfChange + hlsDownloaderConstructor = + getDownloaderConstructor( + Class.forName("com.google.android.exoplayer2.source.hls.offline.HlsDownloader")); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the app was built without the HLS module. + } + HLS_DOWNLOADER_CONSTRUCTOR = hlsDownloaderConstructor; + Constructor<? extends Downloader> ssDownloaderConstructor = null; + try { + // LINT.IfChange + ssDownloaderConstructor = + getDownloaderConstructor( + Class.forName( + "com.google.android.exoplayer2.source.smoothstreaming.offline.SsDownloader")); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the app was built without the SmoothStreaming module. + } + SS_DOWNLOADER_CONSTRUCTOR = ssDownloaderConstructor; + } + + private final DownloaderConstructorHelper downloaderConstructorHelper; + + /** @param downloaderConstructorHelper A helper for instantiating downloaders. */ + public DefaultDownloaderFactory(DownloaderConstructorHelper downloaderConstructorHelper) { + this.downloaderConstructorHelper = downloaderConstructorHelper; + } + + @Override + public Downloader createDownloader(DownloadRequest request) { + switch (request.type) { + case DownloadRequest.TYPE_PROGRESSIVE: + return new ProgressiveDownloader( + request.uri, request.customCacheKey, downloaderConstructorHelper); + case DownloadRequest.TYPE_DASH: + return createDownloader(request, DASH_DOWNLOADER_CONSTRUCTOR); + case DownloadRequest.TYPE_HLS: + return createDownloader(request, HLS_DOWNLOADER_CONSTRUCTOR); + case DownloadRequest.TYPE_SS: + return createDownloader(request, SS_DOWNLOADER_CONSTRUCTOR); + default: + throw new IllegalArgumentException("Unsupported type: " + request.type); + } + } + + private Downloader createDownloader( + DownloadRequest request, @Nullable Constructor<? extends Downloader> constructor) { + if (constructor == null) { + throw new IllegalStateException("Module missing for: " + request.type); + } + try { + return constructor.newInstance(request.uri, request.streamKeys, downloaderConstructorHelper); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate downloader for: " + request.type, e); + } + } + + // LINT.IfChange + private static Constructor<? extends Downloader> getDownloaderConstructor(Class<?> clazz) { + try { + return clazz + .asSubclass(Downloader.class) + .getConstructor(Uri.class, List.class, DownloaderConstructorHelper.class); + } catch (NoSuchMethodException e) { + // The downloader is present, but the expected constructor is missing. + throw new RuntimeException("Downloader constructor missing", e); + } + } + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java new file mode 100644 index 0000000000..a3bc253a6e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Download.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Represents state of a download. */ +public final class Download { + + /** + * Download states. One of {@link #STATE_QUEUED}, {@link #STATE_STOPPED}, {@link + * #STATE_DOWNLOADING}, {@link #STATE_COMPLETED}, {@link #STATE_FAILED}, {@link #STATE_REMOVING} + * or {@link #STATE_RESTARTING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + STATE_QUEUED, + STATE_STOPPED, + STATE_DOWNLOADING, + STATE_COMPLETED, + STATE_FAILED, + STATE_REMOVING, + STATE_RESTARTING + }) + public @interface State {} + // Important: These constants are persisted into DownloadIndex. Do not change them. + /** + * The download is waiting to be started. A download may be queued because the {@link + * DownloadManager} + * + * <ul> + * <li>Is {@link DownloadManager#getDownloadsPaused() paused} + * <li>Has {@link DownloadManager#getRequirements() Requirements} that are not met + * <li>Has already started {@link DownloadManager#getMaxParallelDownloads() + * maxParallelDownloads} + * </ul> + */ + public static final int STATE_QUEUED = 0; + /** The download is stopped for a specified {@link #stopReason}. */ + public static final int STATE_STOPPED = 1; + /** The download is currently started. */ + public static final int STATE_DOWNLOADING = 2; + /** The download completed. */ + public static final int STATE_COMPLETED = 3; + /** The download failed. */ + public static final int STATE_FAILED = 4; + /** The download is being removed. */ + public static final int STATE_REMOVING = 5; + /** The download will restart after all downloaded data is removed. */ + public static final int STATE_RESTARTING = 7; + + /** Failure reasons. Either {@link #FAILURE_REASON_NONE} or {@link #FAILURE_REASON_UNKNOWN}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({FAILURE_REASON_NONE, FAILURE_REASON_UNKNOWN}) + public @interface FailureReason {} + /** The download isn't failed. */ + public static final int FAILURE_REASON_NONE = 0; + /** The download is failed because of unknown reason. */ + public static final int FAILURE_REASON_UNKNOWN = 1; + + /** The download isn't stopped. */ + public static final int STOP_REASON_NONE = 0; + + /** The download request. */ + public final DownloadRequest request; + /** The state of the download. */ + @State public final int state; + /** The first time when download entry is created. */ + public final long startTimeMs; + /** The last update time. */ + public final long updateTimeMs; + /** The total size of the content in bytes, or {@link C#LENGTH_UNSET} if unknown. */ + public final long contentLength; + /** The reason the download is stopped, or {@link #STOP_REASON_NONE}. */ + public final int stopReason; + /** + * If {@link #state} is {@link #STATE_FAILED} then this is the cause, otherwise {@link + * #FAILURE_REASON_NONE}. + */ + @FailureReason public final int failureReason; + + /* package */ final DownloadProgress progress; + + public Download( + DownloadRequest request, + @State int state, + long startTimeMs, + long updateTimeMs, + long contentLength, + int stopReason, + @FailureReason int failureReason) { + this( + request, + state, + startTimeMs, + updateTimeMs, + contentLength, + stopReason, + failureReason, + new DownloadProgress()); + } + + public Download( + DownloadRequest request, + @State int state, + long startTimeMs, + long updateTimeMs, + long contentLength, + int stopReason, + @FailureReason int failureReason, + DownloadProgress progress) { + Assertions.checkNotNull(progress); + Assertions.checkArgument((failureReason == FAILURE_REASON_NONE) == (state != STATE_FAILED)); + if (stopReason != 0) { + Assertions.checkArgument(state != STATE_DOWNLOADING && state != STATE_QUEUED); + } + this.request = request; + this.state = state; + this.startTimeMs = startTimeMs; + this.updateTimeMs = updateTimeMs; + this.contentLength = contentLength; + this.stopReason = stopReason; + this.failureReason = failureReason; + this.progress = progress; + } + + /** Returns whether the download is completed or failed. These are terminal states. */ + public boolean isTerminalState() { + return state == STATE_COMPLETED || state == STATE_FAILED; + } + + /** Returns the total number of downloaded bytes. */ + public long getBytesDownloaded() { + return progress.bytesDownloaded; + } + + /** + * Returns the estimated download percentage, or {@link C#PERCENTAGE_UNSET} if no estimate is + * available. + */ + public float getPercentDownloaded() { + return progress.percentDownloaded; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java new file mode 100644 index 0000000000..9693e43002 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadCursor.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import java.io.Closeable; + +/** Provides random read-write access to the result set returned by a database query. */ +public interface DownloadCursor extends Closeable { + + /** Returns the download at the current position. */ + Download getDownload(); + + /** Returns the numbers of downloads in the cursor. */ + int getCount(); + + /** + * Returns the current position of the cursor in the download set. The value is zero-based. When + * the download set is first returned the cursor will be at positon -1, which is before the first + * download. After the last download is returned another call to next() will leave the cursor past + * the last entry, at a position of count(). + * + * @return the current cursor position. + */ + int getPosition(); + + /** + * Move the cursor to an absolute position. The valid range of values is -1 <= position <= + * count. + * + * <p>This method will return true if the request destination was reachable, otherwise, it returns + * false. + * + * @param position the zero-based position to move to. + * @return whether the requested move fully succeeded. + */ + boolean moveToPosition(int position); + + /** + * Move the cursor to the first download. + * + * <p>This method will return false if the cursor is empty. + * + * @return whether the move succeeded. + */ + default boolean moveToFirst() { + return moveToPosition(0); + } + + /** + * Move the cursor to the last download. + * + * <p>This method will return false if the cursor is empty. + * + * @return whether the move succeeded. + */ + default boolean moveToLast() { + return moveToPosition(getCount() - 1); + } + + /** + * Move the cursor to the next download. + * + * <p>This method will return false if the cursor is already past the last entry in the result + * set. + * + * @return whether the move succeeded. + */ + default boolean moveToNext() { + return moveToPosition(getPosition() + 1); + } + + /** + * Move the cursor to the previous download. + * + * <p>This method will return false if the cursor is already before the first entry in the result + * set. + * + * @return whether the move succeeded. + */ + default boolean moveToPrevious() { + return moveToPosition(getPosition() - 1); + } + + /** Returns whether the cursor is pointing to the first download. */ + default boolean isFirst() { + return getPosition() == 0 && getCount() != 0; + } + + /** Returns whether the cursor is pointing to the last download. */ + default boolean isLast() { + int count = getCount(); + return getPosition() == (count - 1) && count != 0; + } + + /** Returns whether the cursor is pointing to the position before the first download. */ + default boolean isBeforeFirst() { + if (getCount() == 0) { + return true; + } + return getPosition() == -1; + } + + /** Returns whether the cursor is pointing to the position after the last download. */ + default boolean isAfterLast() { + if (getCount() == 0) { + return true; + } + return getPosition() == getCount(); + } + + /** Returns whether the cursor is closed */ + boolean isClosed(); + + @Override + void close(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java new file mode 100644 index 0000000000..cd95b5f922 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadException.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import java.io.IOException; + +/** Thrown on an error during downloading. */ +public final class DownloadException extends IOException { + + /** @param message The message for the exception. */ + public DownloadException(String message) { + super(message); + } + + /** @param cause The cause for the exception. */ + public DownloadException(Throwable cause) { + super(cause); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java new file mode 100644 index 0000000000..6070b3a80f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadHelper.java @@ -0,0 +1,1174 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.util.SparseIntArray; +import androidx.annotation.Nullable; +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.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RenderersFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ProgressiveMediaSource; +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.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.BaseTrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector.Parameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +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.TrackSelectorResult; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource.Factory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * A helper for initializing and removing downloads. + * + * <p>The helper extracts track information from the media, selects tracks for downloading, and + * creates {@link DownloadRequest download requests} based on the selected tracks. + * + * <p>A typical usage of DownloadHelper follows these steps: + * + * <ol> + * <li>Build the helper using one of the {@code forXXX} methods. + * <li>Prepare the helper using {@link #prepare(Callback)} and wait for the callback. + * <li>Optional: Inspect the selected tracks using {@link #getMappedTrackInfo(int)} and {@link + * #getTrackSelections(int, int)}, and make adjustments using {@link + * #clearTrackSelections(int)}, {@link #replaceTrackSelections(int, Parameters)} and {@link + * #addTrackSelection(int, Parameters)}. + * <li>Create a download request for the selected track using {@link #getDownloadRequest(byte[])}. + * <li>Release the helper using {@link #release()}. + * </ol> + */ +public final class DownloadHelper { + + /** + * Default track selection parameters for downloading, but without any {@link Context} + * constraints. + * + * <p>If possible, use {@link #getDefaultTrackSelectorParameters(Context)} instead. + * + * @see Parameters#DEFAULT_WITHOUT_CONTEXT + */ + public static final Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT = + Parameters.DEFAULT_WITHOUT_CONTEXT.buildUpon().setForceHighestSupportedBitrate(true).build(); + + /** + * @deprecated This instance does not have {@link Context} constraints. Use {@link + * #getDefaultTrackSelectorParameters(Context)} instead. + */ + @Deprecated + public static final Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT = + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT; + + /** + * @deprecated This instance does not have {@link Context} constraints. Use {@link + * #getDefaultTrackSelectorParameters(Context)} instead. + */ + @Deprecated + public static final DefaultTrackSelector.Parameters DEFAULT_TRACK_SELECTOR_PARAMETERS = + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT; + + /** Returns the default parameters used for track selection for downloading. */ + public static DefaultTrackSelector.Parameters getDefaultTrackSelectorParameters(Context context) { + return Parameters.getDefaults(context) + .buildUpon() + .setForceHighestSupportedBitrate(true) + .build(); + } + + /** A callback to be notified when the {@link DownloadHelper} is prepared. */ + public interface Callback { + + /** + * Called when preparation completes. + * + * @param helper The reporting {@link DownloadHelper}. + */ + void onPrepared(DownloadHelper helper); + + /** + * Called when preparation fails. + * + * @param helper The reporting {@link DownloadHelper}. + * @param e The error. + */ + void onPrepareError(DownloadHelper helper, IOException e); + } + + /** Thrown at an attempt to download live content. */ + public static class LiveContentUnsupportedException extends IOException {} + + @Nullable + private static final Constructor<? extends MediaSourceFactory> DASH_FACTORY_CONSTRUCTOR = + getConstructor("com.google.android.exoplayer2.source.dash.DashMediaSource$Factory"); + + @Nullable + private static final Constructor<? extends MediaSourceFactory> SS_FACTORY_CONSTRUCTOR = + getConstructor("com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource$Factory"); + + @Nullable + private static final Constructor<? extends MediaSourceFactory> HLS_FACTORY_CONSTRUCTOR = + getConstructor("com.google.android.exoplayer2.source.hls.HlsMediaSource$Factory"); + + /** @deprecated Use {@link #forProgressive(Context, Uri)} */ + @Deprecated + @SuppressWarnings("deprecation") + public static DownloadHelper forProgressive(Uri uri) { + return forProgressive(uri, /* cacheKey= */ null); + } + + /** + * Creates a {@link DownloadHelper} for progressive streams. + * + * @param context Any {@link Context}. + * @param uri A stream {@link Uri}. + * @return A {@link DownloadHelper} for progressive streams. + */ + public static DownloadHelper forProgressive(Context context, Uri uri) { + return forProgressive(context, uri, /* cacheKey= */ null); + } + + /** @deprecated Use {@link #forProgressive(Context, Uri, String)} */ + @Deprecated + public static DownloadHelper forProgressive(Uri uri, @Nullable String cacheKey) { + return new DownloadHelper( + DownloadRequest.TYPE_PROGRESSIVE, + uri, + cacheKey, + /* mediaSource= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT, + /* rendererCapabilities= */ new RendererCapabilities[0]); + } + + /** + * Creates a {@link DownloadHelper} for progressive streams. + * + * @param context Any {@link Context}. + * @param uri A stream {@link Uri}. + * @param cacheKey An optional cache key. + * @return A {@link DownloadHelper} for progressive streams. + */ + public static DownloadHelper forProgressive(Context context, Uri uri, @Nullable String cacheKey) { + return new DownloadHelper( + DownloadRequest.TYPE_PROGRESSIVE, + uri, + cacheKey, + /* mediaSource= */ null, + getDefaultTrackSelectorParameters(context), + /* rendererCapabilities= */ new RendererCapabilities[0]); + } + + /** @deprecated Use {@link #forDash(Context, Uri, Factory, RenderersFactory)} */ + @Deprecated + public static DownloadHelper forDash( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forDash( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + } + + /** + * Creates a {@link DownloadHelper} for DASH streams. + * + * @param context Any {@link Context}. + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @return A {@link DownloadHelper} for DASH streams. + * @throws IllegalStateException If the DASH module is missing. + */ + public static DownloadHelper forDash( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forDash( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * Creates a {@link DownloadHelper} for DASH streams. + * + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which + * tracks can be selected. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @return A {@link DownloadHelper} for DASH streams. + * @throws IllegalStateException If the DASH module is missing. + */ + public static DownloadHelper forDash( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return new DownloadHelper( + DownloadRequest.TYPE_DASH, + uri, + /* cacheKey= */ null, + createMediaSourceInternal( + DASH_FACTORY_CONSTRUCTOR, + uri, + dataSourceFactory, + drmSessionManager, + /* streamKeys= */ null), + trackSelectorParameters, + Util.getRendererCapabilities(renderersFactory)); + } + + /** @deprecated Use {@link #forHls(Context, Uri, Factory, RenderersFactory)} */ + @Deprecated + public static DownloadHelper forHls( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forHls( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + } + + /** + * Creates a {@link DownloadHelper} for HLS streams. + * + * @param context Any {@link Context}. + * @param uri A playlist {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @return A {@link DownloadHelper} for HLS streams. + * @throws IllegalStateException If the HLS module is missing. + */ + public static DownloadHelper forHls( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forHls( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * Creates a {@link DownloadHelper} for HLS streams. + * + * @param uri A playlist {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the playlist. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which + * tracks can be selected. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @return A {@link DownloadHelper} for HLS streams. + * @throws IllegalStateException If the HLS module is missing. + */ + public static DownloadHelper forHls( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return new DownloadHelper( + DownloadRequest.TYPE_HLS, + uri, + /* cacheKey= */ null, + createMediaSourceInternal( + HLS_FACTORY_CONSTRUCTOR, + uri, + dataSourceFactory, + drmSessionManager, + /* streamKeys= */ null), + trackSelectorParameters, + Util.getRendererCapabilities(renderersFactory)); + } + + /** @deprecated Use {@link #forSmoothStreaming(Context, Uri, Factory, RenderersFactory)} */ + @Deprecated + public static DownloadHelper forSmoothStreaming( + Uri uri, DataSource.Factory dataSourceFactory, RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_VIEWPORT); + } + + /** + * Creates a {@link DownloadHelper} for SmoothStreaming streams. + * + * @param context Any {@link Context}. + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @return A {@link DownloadHelper} for SmoothStreaming streams. + * @throws IllegalStateException If the SmoothStreaming module is missing. + */ + public static DownloadHelper forSmoothStreaming( + Context context, + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory) { + return forSmoothStreaming( + uri, + dataSourceFactory, + renderersFactory, + /* drmSessionManager= */ null, + getDefaultTrackSelectorParameters(context)); + } + + /** + * Creates a {@link DownloadHelper} for SmoothStreaming streams. + * + * @param uri A manifest {@link Uri}. + * @param dataSourceFactory A {@link DataSource.Factory} used to load the manifest. + * @param renderersFactory A {@link RenderersFactory} creating the renderers for which tracks are + * selected. + * @param drmSessionManager An optional {@link DrmSessionManager}. Used to help determine which + * tracks can be selected. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @return A {@link DownloadHelper} for SmoothStreaming streams. + * @throws IllegalStateException If the SmoothStreaming module is missing. + */ + public static DownloadHelper forSmoothStreaming( + Uri uri, + DataSource.Factory dataSourceFactory, + RenderersFactory renderersFactory, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + DefaultTrackSelector.Parameters trackSelectorParameters) { + return new DownloadHelper( + DownloadRequest.TYPE_SS, + uri, + /* cacheKey= */ null, + createMediaSourceInternal( + SS_FACTORY_CONSTRUCTOR, + uri, + dataSourceFactory, + drmSessionManager, + /* streamKeys= */ null), + trackSelectorParameters, + Util.getRendererCapabilities(renderersFactory)); + } + + /** + * Equivalent to {@link #createMediaSource(DownloadRequest, Factory, DrmSessionManager) + * createMediaSource(downloadRequest, dataSourceFactory, null)}. + */ + public static MediaSource createMediaSource( + DownloadRequest downloadRequest, DataSource.Factory dataSourceFactory) { + return createMediaSource(downloadRequest, dataSourceFactory, /* drmSessionManager= */ null); + } + + /** + * Utility method to create a {@link MediaSource} that only exposes the tracks defined in {@code + * downloadRequest}. + * + * @param downloadRequest A {@link DownloadRequest}. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param drmSessionManager An optional {@link DrmSessionManager} to be passed to the {@link + * MediaSource}. + * @return A {@link MediaSource} that only exposes the tracks defined in {@code downloadRequest}. + */ + public static MediaSource createMediaSource( + DownloadRequest downloadRequest, + DataSource.Factory dataSourceFactory, + @Nullable DrmSessionManager<?> drmSessionManager) { + @Nullable Constructor<? extends MediaSourceFactory> constructor; + switch (downloadRequest.type) { + case DownloadRequest.TYPE_DASH: + constructor = DASH_FACTORY_CONSTRUCTOR; + break; + case DownloadRequest.TYPE_SS: + constructor = SS_FACTORY_CONSTRUCTOR; + break; + case DownloadRequest.TYPE_HLS: + constructor = HLS_FACTORY_CONSTRUCTOR; + break; + case DownloadRequest.TYPE_PROGRESSIVE: + return new ProgressiveMediaSource.Factory(dataSourceFactory) + .setCustomCacheKey(downloadRequest.customCacheKey) + .createMediaSource(downloadRequest.uri); + default: + throw new IllegalStateException("Unsupported type: " + downloadRequest.type); + } + return createMediaSourceInternal( + constructor, + downloadRequest.uri, + dataSourceFactory, + drmSessionManager, + downloadRequest.streamKeys); + } + + private final String downloadType; + private final Uri uri; + @Nullable private final String cacheKey; + @Nullable private final MediaSource mediaSource; + private final DefaultTrackSelector trackSelector; + private final RendererCapabilities[] rendererCapabilities; + private final SparseIntArray scratchSet; + private final Handler callbackHandler; + private final Timeline.Window window; + + private boolean isPreparedWithMedia; + private @MonotonicNonNull Callback callback; + private @MonotonicNonNull MediaPreparer mediaPreparer; + private TrackGroupArray @MonotonicNonNull [] trackGroupArrays; + private MappedTrackInfo @MonotonicNonNull [] mappedTrackInfos; + private List<TrackSelection> @MonotonicNonNull [][] trackSelectionsByPeriodAndRenderer; + private List<TrackSelection> @MonotonicNonNull [][] immutableTrackSelectionsByPeriodAndRenderer; + + /** + * Creates download helper. + * + * @param downloadType A download type. This value will be used as {@link DownloadRequest#type}. + * @param uri A {@link Uri}. + * @param cacheKey An optional cache key. + * @param mediaSource A {@link MediaSource} for which tracks are selected, or null if no track + * selection needs to be made. + * @param trackSelectorParameters {@link DefaultTrackSelector.Parameters} for selecting tracks for + * downloading. + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which tracks + * are selected. + */ + public DownloadHelper( + String downloadType, + Uri uri, + @Nullable String cacheKey, + @Nullable MediaSource mediaSource, + DefaultTrackSelector.Parameters trackSelectorParameters, + RendererCapabilities[] rendererCapabilities) { + this.downloadType = downloadType; + this.uri = uri; + this.cacheKey = cacheKey; + this.mediaSource = mediaSource; + this.trackSelector = + new DefaultTrackSelector(trackSelectorParameters, new DownloadTrackSelection.Factory()); + this.rendererCapabilities = rendererCapabilities; + this.scratchSet = new SparseIntArray(); + trackSelector.init(/* listener= */ () -> {}, new DummyBandwidthMeter()); + callbackHandler = new Handler(Util.getLooper()); + window = new Timeline.Window(); + } + + /** + * Initializes the helper for starting a download. + * + * @param callback A callback to be notified when preparation completes or fails. + * @throws IllegalStateException If the download helper has already been prepared. + */ + public void prepare(Callback callback) { + Assertions.checkState(this.callback == null); + this.callback = callback; + if (mediaSource != null) { + mediaPreparer = new MediaPreparer(mediaSource, /* downloadHelper= */ this); + } else { + callbackHandler.post(() -> callback.onPrepared(this)); + } + } + + /** Releases the helper and all resources it is holding. */ + public void release() { + if (mediaPreparer != null) { + mediaPreparer.release(); + } + } + + /** + * Returns the manifest, or null if no manifest is loaded. Must not be called until after + * preparation completes. + */ + @Nullable + public Object getManifest() { + if (mediaSource == null) { + return null; + } + assertPreparedWithMedia(); + return mediaPreparer.timeline.getWindowCount() > 0 + ? mediaPreparer.timeline.getWindow(/* windowIndex= */ 0, window).manifest + : null; + } + + /** + * Returns the number of periods for which media is available. Must not be called until after + * preparation completes. + */ + public int getPeriodCount() { + if (mediaSource == null) { + return 0; + } + assertPreparedWithMedia(); + return trackGroupArrays.length; + } + + /** + * Returns the track groups for the given period. Must not be called until after preparation + * completes. + * + * <p>Use {@link #getMappedTrackInfo(int)} to get the track groups mapped to renderers. + * + * @param periodIndex The period index. + * @return The track groups for the period. May be {@link TrackGroupArray#EMPTY} for single stream + * content. + */ + public TrackGroupArray getTrackGroups(int periodIndex) { + assertPreparedWithMedia(); + return trackGroupArrays[periodIndex]; + } + + /** + * Returns the mapped track info for the given period. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index. + * @return The {@link MappedTrackInfo} for the period. + */ + public MappedTrackInfo getMappedTrackInfo(int periodIndex) { + assertPreparedWithMedia(); + return mappedTrackInfos[periodIndex]; + } + + /** + * Returns all {@link TrackSelection track selections} for a period and renderer. Must not be + * called until after preparation completes. + * + * @param periodIndex The period index. + * @param rendererIndex The renderer index. + * @return A list of selected {@link TrackSelection track selections}. + */ + public List<TrackSelection> getTrackSelections(int periodIndex, int rendererIndex) { + assertPreparedWithMedia(); + return immutableTrackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]; + } + + /** + * Clears the selection of tracks for a period. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index for which track selections are cleared. + */ + public void clearTrackSelections(int periodIndex) { + assertPreparedWithMedia(); + for (int i = 0; i < rendererCapabilities.length; i++) { + trackSelectionsByPeriodAndRenderer[periodIndex][i].clear(); + } + } + + /** + * Replaces a selection of tracks to be downloaded. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index for which the track selection is replaced. + * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new + * selection of tracks. + */ + public void replaceTrackSelections( + int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) { + clearTrackSelections(periodIndex); + addTrackSelection(periodIndex, trackSelectorParameters); + } + + /** + * Adds a selection of tracks to be downloaded. Must not be called until after preparation + * completes. + * + * @param periodIndex The period index this track selection is added for. + * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new + * selection of tracks. + */ + public void addTrackSelection( + int periodIndex, DefaultTrackSelector.Parameters trackSelectorParameters) { + assertPreparedWithMedia(); + trackSelector.setParameters(trackSelectorParameters); + runTrackSelection(periodIndex); + } + + /** + * Convenience method to add selections of tracks for all specified audio languages. If an audio + * track in one of the specified languages is not available, the default fallback audio track is + * used instead. Must not be called until after preparation completes. + * + * @param languages A list of audio languages for which tracks should be added to the download + * selection, as IETF BCP 47 conformant tags. + */ + public void addAudioLanguagesToSelection(String... languages) { + assertPreparedWithMedia(); + for (int periodIndex = 0; periodIndex < mappedTrackInfos.length; periodIndex++) { + DefaultTrackSelector.ParametersBuilder parametersBuilder = + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT.buildUpon(); + MappedTrackInfo mappedTrackInfo = mappedTrackInfos[periodIndex]; + int rendererCount = mappedTrackInfo.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_AUDIO) { + parametersBuilder.setRendererDisabled(rendererIndex, /* disabled= */ true); + } + } + for (String language : languages) { + parametersBuilder.setPreferredAudioLanguage(language); + addTrackSelection(periodIndex, parametersBuilder.build()); + } + } + } + + /** + * Convenience method to add selections of tracks for all specified text languages. Must not be + * called until after preparation completes. + * + * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should be + * selected for downloading if no track with one of the specified {@code languages} is + * available. + * @param languages A list of text languages for which tracks should be added to the download + * selection, as IETF BCP 47 conformant tags. + */ + public void addTextLanguagesToSelection( + boolean selectUndeterminedTextLanguage, String... languages) { + assertPreparedWithMedia(); + for (int periodIndex = 0; periodIndex < mappedTrackInfos.length; periodIndex++) { + DefaultTrackSelector.ParametersBuilder parametersBuilder = + DEFAULT_TRACK_SELECTOR_PARAMETERS_WITHOUT_CONTEXT.buildUpon(); + MappedTrackInfo mappedTrackInfo = mappedTrackInfos[periodIndex]; + int rendererCount = mappedTrackInfo.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + if (mappedTrackInfo.getRendererType(rendererIndex) != C.TRACK_TYPE_TEXT) { + parametersBuilder.setRendererDisabled(rendererIndex, /* disabled= */ true); + } + } + parametersBuilder.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage); + for (String language : languages) { + parametersBuilder.setPreferredTextLanguage(language); + addTrackSelection(periodIndex, parametersBuilder.build()); + } + } + } + + /** + * Convenience method to add a selection of tracks to be downloaded for a single renderer. Must + * not be called until after preparation completes. + * + * @param periodIndex The period index the track selection is added for. + * @param rendererIndex The renderer index the track selection is added for. + * @param trackSelectorParameters The {@link DefaultTrackSelector.Parameters} to obtain the new + * selection of tracks. + * @param overrides A list of {@link SelectionOverride SelectionOverrides} to apply to the {@code + * trackSelectorParameters}. If empty, {@code trackSelectorParameters} are used as they are. + */ + public void addTrackSelectionForSingleRenderer( + int periodIndex, + int rendererIndex, + DefaultTrackSelector.Parameters trackSelectorParameters, + List<SelectionOverride> overrides) { + assertPreparedWithMedia(); + DefaultTrackSelector.ParametersBuilder builder = trackSelectorParameters.buildUpon(); + for (int i = 0; i < mappedTrackInfos[periodIndex].getRendererCount(); i++) { + builder.setRendererDisabled(/* rendererIndex= */ i, /* disabled= */ i != rendererIndex); + } + if (overrides.isEmpty()) { + addTrackSelection(periodIndex, builder.build()); + } else { + TrackGroupArray trackGroupArray = mappedTrackInfos[periodIndex].getTrackGroups(rendererIndex); + for (int i = 0; i < overrides.size(); i++) { + builder.setSelectionOverride(rendererIndex, trackGroupArray, overrides.get(i)); + addTrackSelection(periodIndex, builder.build()); + } + } + } + + /** + * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until + * after preparation completes. The uri of the {@link DownloadRequest} will be used as content id. + * + * @param data Application provided data to store in {@link DownloadRequest#data}. + * @return The built {@link DownloadRequest}. + */ + public DownloadRequest getDownloadRequest(@Nullable byte[] data) { + return getDownloadRequest(uri.toString(), data); + } + + /** + * Builds a {@link DownloadRequest} for downloading the selected tracks. Must not be called until + * after preparation completes. + * + * @param id The unique content id. + * @param data Application provided data to store in {@link DownloadRequest#data}. + * @return The built {@link DownloadRequest}. + */ + public DownloadRequest getDownloadRequest(String id, @Nullable byte[] data) { + if (mediaSource == null) { + return new DownloadRequest( + id, downloadType, uri, /* streamKeys= */ Collections.emptyList(), cacheKey, data); + } + assertPreparedWithMedia(); + List<StreamKey> streamKeys = new ArrayList<>(); + List<TrackSelection> allSelections = new ArrayList<>(); + int periodCount = trackSelectionsByPeriodAndRenderer.length; + for (int periodIndex = 0; periodIndex < periodCount; periodIndex++) { + allSelections.clear(); + int rendererCount = trackSelectionsByPeriodAndRenderer[periodIndex].length; + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + allSelections.addAll(trackSelectionsByPeriodAndRenderer[periodIndex][rendererIndex]); + } + streamKeys.addAll(mediaPreparer.mediaPeriods[periodIndex].getStreamKeys(allSelections)); + } + return new DownloadRequest(id, downloadType, uri, streamKeys, cacheKey, data); + } + + // Initialization of array of Lists. + @SuppressWarnings("unchecked") + private void onMediaPrepared() { + Assertions.checkNotNull(mediaPreparer); + Assertions.checkNotNull(mediaPreparer.mediaPeriods); + Assertions.checkNotNull(mediaPreparer.timeline); + int periodCount = mediaPreparer.mediaPeriods.length; + int rendererCount = rendererCapabilities.length; + trackSelectionsByPeriodAndRenderer = + (List<TrackSelection>[][]) new List<?>[periodCount][rendererCount]; + immutableTrackSelectionsByPeriodAndRenderer = + (List<TrackSelection>[][]) new List<?>[periodCount][rendererCount]; + for (int i = 0; i < periodCount; i++) { + for (int j = 0; j < rendererCount; j++) { + trackSelectionsByPeriodAndRenderer[i][j] = new ArrayList<>(); + immutableTrackSelectionsByPeriodAndRenderer[i][j] = + Collections.unmodifiableList(trackSelectionsByPeriodAndRenderer[i][j]); + } + } + trackGroupArrays = new TrackGroupArray[periodCount]; + mappedTrackInfos = new MappedTrackInfo[periodCount]; + for (int i = 0; i < periodCount; i++) { + trackGroupArrays[i] = mediaPreparer.mediaPeriods[i].getTrackGroups(); + TrackSelectorResult trackSelectorResult = runTrackSelection(/* periodIndex= */ i); + trackSelector.onSelectionActivated(trackSelectorResult.info); + mappedTrackInfos[i] = Assertions.checkNotNull(trackSelector.getCurrentMappedTrackInfo()); + } + setPreparedWithMedia(); + Assertions.checkNotNull(callbackHandler) + .post(() -> Assertions.checkNotNull(callback).onPrepared(this)); + } + + private void onMediaPreparationFailed(IOException error) { + Assertions.checkNotNull(callbackHandler) + .post(() -> Assertions.checkNotNull(callback).onPrepareError(this, error)); + } + + @RequiresNonNull({ + "trackGroupArrays", + "mappedTrackInfos", + "trackSelectionsByPeriodAndRenderer", + "immutableTrackSelectionsByPeriodAndRenderer", + "mediaPreparer", + "mediaPreparer.timeline", + "mediaPreparer.mediaPeriods" + }) + private void setPreparedWithMedia() { + isPreparedWithMedia = true; + } + + @EnsuresNonNull({ + "trackGroupArrays", + "mappedTrackInfos", + "trackSelectionsByPeriodAndRenderer", + "immutableTrackSelectionsByPeriodAndRenderer", + "mediaPreparer", + "mediaPreparer.timeline", + "mediaPreparer.mediaPeriods" + }) + @SuppressWarnings("nullness:contracts.postcondition.not.satisfied") + private void assertPreparedWithMedia() { + Assertions.checkState(isPreparedWithMedia); + } + + /** + * Runs the track selection for a given period index with the current parameters. The selected + * tracks will be added to {@link #trackSelectionsByPeriodAndRenderer}. + */ + // Intentional reference comparison of track group instances. + @SuppressWarnings("ReferenceEquality") + @RequiresNonNull({ + "trackGroupArrays", + "trackSelectionsByPeriodAndRenderer", + "mediaPreparer", + "mediaPreparer.timeline" + }) + private TrackSelectorResult runTrackSelection(int periodIndex) { + try { + TrackSelectorResult trackSelectorResult = + trackSelector.selectTracks( + rendererCapabilities, + trackGroupArrays[periodIndex], + new MediaPeriodId(mediaPreparer.timeline.getUidOfPeriod(periodIndex)), + mediaPreparer.timeline); + for (int i = 0; i < trackSelectorResult.length; i++) { + @Nullable TrackSelection newSelection = trackSelectorResult.selections.get(i); + if (newSelection == null) { + continue; + } + List<TrackSelection> existingSelectionList = + trackSelectionsByPeriodAndRenderer[periodIndex][i]; + boolean mergedWithExistingSelection = false; + for (int j = 0; j < existingSelectionList.size(); j++) { + TrackSelection existingSelection = existingSelectionList.get(j); + if (existingSelection.getTrackGroup() == newSelection.getTrackGroup()) { + // Merge with existing selection. + scratchSet.clear(); + for (int k = 0; k < existingSelection.length(); k++) { + scratchSet.put(existingSelection.getIndexInTrackGroup(k), 0); + } + for (int k = 0; k < newSelection.length(); k++) { + scratchSet.put(newSelection.getIndexInTrackGroup(k), 0); + } + int[] mergedTracks = new int[scratchSet.size()]; + for (int k = 0; k < scratchSet.size(); k++) { + mergedTracks[k] = scratchSet.keyAt(k); + } + existingSelectionList.set( + j, new DownloadTrackSelection(existingSelection.getTrackGroup(), mergedTracks)); + mergedWithExistingSelection = true; + break; + } + } + if (!mergedWithExistingSelection) { + existingSelectionList.add(newSelection); + } + } + return trackSelectorResult; + } catch (ExoPlaybackException e) { + // DefaultTrackSelector does not throw exceptions during track selection. + throw new UnsupportedOperationException(e); + } + } + + @Nullable + private static Constructor<? extends MediaSourceFactory> getConstructor(String className) { + try { + // LINT.IfChange + Class<? extends MediaSourceFactory> factoryClazz = + Class.forName(className).asSubclass(MediaSourceFactory.class); + return factoryClazz.getConstructor(Factory.class); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + } catch (ClassNotFoundException e) { + // Expected if the app was built without the respective module. + return null; + } catch (NoSuchMethodException e) { + // Something is wrong with the library or the proguard configuration. + throw new IllegalStateException(e); + } + } + + private static MediaSource createMediaSourceInternal( + @Nullable Constructor<? extends MediaSourceFactory> constructor, + Uri uri, + Factory dataSourceFactory, + @Nullable DrmSessionManager<?> drmSessionManager, + @Nullable List<StreamKey> streamKeys) { + if (constructor == null) { + throw new IllegalStateException("Module missing to create media source."); + } + try { + MediaSourceFactory factory = constructor.newInstance(dataSourceFactory); + if (drmSessionManager != null) { + factory.setDrmSessionManager(drmSessionManager); + } + if (streamKeys != null) { + factory.setStreamKeys(streamKeys); + } + return Assertions.checkNotNull(factory.createMediaSource(uri)); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate media source.", e); + } + } + + private static final class MediaPreparer + implements MediaSourceCaller, MediaPeriod.Callback, Handler.Callback { + + private static final int MESSAGE_PREPARE_SOURCE = 0; + private static final int MESSAGE_CHECK_FOR_FAILURE = 1; + private static final int MESSAGE_CONTINUE_LOADING = 2; + private static final int MESSAGE_RELEASE = 3; + + private static final int DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED = 0; + private static final int DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED = 1; + + private final MediaSource mediaSource; + private final DownloadHelper downloadHelper; + private final Allocator allocator; + private final ArrayList<MediaPeriod> pendingMediaPeriods; + private final Handler downloadHelperHandler; + private final HandlerThread mediaSourceThread; + private final Handler mediaSourceHandler; + + public @MonotonicNonNull Timeline timeline; + public MediaPeriod @MonotonicNonNull [] mediaPeriods; + + private boolean released; + + public MediaPreparer(MediaSource mediaSource, DownloadHelper downloadHelper) { + this.mediaSource = mediaSource; + this.downloadHelper = downloadHelper; + allocator = new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE); + pendingMediaPeriods = new ArrayList<>(); + @SuppressWarnings("methodref.receiver.bound.invalid") + Handler downloadThreadHandler = Util.createHandler(this::handleDownloadHelperCallbackMessage); + this.downloadHelperHandler = downloadThreadHandler; + mediaSourceThread = new HandlerThread("DownloadHelper"); + mediaSourceThread.start(); + mediaSourceHandler = Util.createHandler(mediaSourceThread.getLooper(), /* callback= */ this); + mediaSourceHandler.sendEmptyMessage(MESSAGE_PREPARE_SOURCE); + } + + public void release() { + if (released) { + return; + } + released = true; + mediaSourceHandler.sendEmptyMessage(MESSAGE_RELEASE); + } + + // Handler.Callback + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MESSAGE_PREPARE_SOURCE: + mediaSource.prepareSource(/* caller= */ this, /* mediaTransferListener= */ null); + mediaSourceHandler.sendEmptyMessage(MESSAGE_CHECK_FOR_FAILURE); + return true; + case MESSAGE_CHECK_FOR_FAILURE: + try { + if (mediaPeriods == null) { + mediaSource.maybeThrowSourceInfoRefreshError(); + } else { + for (int i = 0; i < pendingMediaPeriods.size(); i++) { + pendingMediaPeriods.get(i).maybeThrowPrepareError(); + } + } + mediaSourceHandler.sendEmptyMessageDelayed( + MESSAGE_CHECK_FOR_FAILURE, /* delayMillis= */ 100); + } catch (IOException e) { + downloadHelperHandler + .obtainMessage(DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED, /* obj= */ e) + .sendToTarget(); + } + return true; + case MESSAGE_CONTINUE_LOADING: + MediaPeriod mediaPeriod = (MediaPeriod) msg.obj; + if (pendingMediaPeriods.contains(mediaPeriod)) { + mediaPeriod.continueLoading(/* positionUs= */ 0); + } + return true; + case MESSAGE_RELEASE: + if (mediaPeriods != null) { + for (MediaPeriod period : mediaPeriods) { + mediaSource.releasePeriod(period); + } + } + mediaSource.releaseSource(this); + mediaSourceHandler.removeCallbacksAndMessages(null); + mediaSourceThread.quit(); + return true; + default: + return false; + } + } + + // MediaSource.MediaSourceCaller implementation. + + @Override + public void onSourceInfoRefreshed(MediaSource source, Timeline timeline) { + if (this.timeline != null) { + // Ignore dynamic updates. + return; + } + if (timeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()).isLive) { + downloadHelperHandler + .obtainMessage( + DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED, + /* obj= */ new LiveContentUnsupportedException()) + .sendToTarget(); + return; + } + this.timeline = timeline; + mediaPeriods = new MediaPeriod[timeline.getPeriodCount()]; + for (int i = 0; i < mediaPeriods.length; i++) { + MediaPeriod mediaPeriod = + mediaSource.createPeriod( + new MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ i)), + allocator, + /* startPositionUs= */ 0); + mediaPeriods[i] = mediaPeriod; + pendingMediaPeriods.add(mediaPeriod); + } + for (MediaPeriod mediaPeriod : mediaPeriods) { + mediaPeriod.prepare(/* callback= */ this, /* positionUs= */ 0); + } + } + + // MediaPeriod.Callback implementation. + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + pendingMediaPeriods.remove(mediaPeriod); + if (pendingMediaPeriods.isEmpty()) { + mediaSourceHandler.removeMessages(MESSAGE_CHECK_FOR_FAILURE); + downloadHelperHandler.sendEmptyMessage(DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED); + } + } + + @Override + public void onContinueLoadingRequested(MediaPeriod mediaPeriod) { + if (pendingMediaPeriods.contains(mediaPeriod)) { + mediaSourceHandler.obtainMessage(MESSAGE_CONTINUE_LOADING, mediaPeriod).sendToTarget(); + } + } + + private boolean handleDownloadHelperCallbackMessage(Message msg) { + if (released) { + // Stale message. + return false; + } + switch (msg.what) { + case DOWNLOAD_HELPER_CALLBACK_MESSAGE_PREPARED: + downloadHelper.onMediaPrepared(); + return true; + case DOWNLOAD_HELPER_CALLBACK_MESSAGE_FAILED: + release(); + downloadHelper.onMediaPreparationFailed((IOException) Util.castNonNull(msg.obj)); + return true; + default: + return false; + } + } + } + + private static final class DownloadTrackSelection extends BaseTrackSelection { + + private static final class Factory implements TrackSelection.Factory { + + @Override + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + @NullableType TrackSelection[] selections = new TrackSelection[definitions.length]; + for (int i = 0; i < definitions.length; i++) { + selections[i] = + definitions[i] == null + ? null + : new DownloadTrackSelection(definitions[i].group, definitions[i].tracks); + } + return selections; + } + } + + public DownloadTrackSelection(TrackGroup trackGroup, int[] tracks) { + super(trackGroup, tracks); + } + + @Override + public int getSelectedIndex() { + return 0; + } + + @Override + public int getSelectionReason() { + return C.SELECTION_REASON_UNKNOWN; + } + + @Nullable + @Override + public Object getSelectionData() { + return null; + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + // Do nothing. + } + } + + private static final class DummyBandwidthMeter implements BandwidthMeter { + + @Override + public long getBitrateEstimate() { + return 0; + } + + @Nullable + @Override + public TransferListener getTransferListener() { + return null; + } + + @Override + public void addEventListener(Handler eventHandler, EventListener eventListener) { + // Do nothing. + } + + @Override + public void removeEventListener(EventListener eventListener) { + // Do nothing. + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java new file mode 100644 index 0000000000..5fbb3e7c0b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadIndex.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import java.io.IOException; + +/** An index of {@link Download Downloads}. */ +@WorkerThread +public interface DownloadIndex { + + /** + * Returns the {@link Download} with the given {@code id}, or null. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param id ID of a {@link Download}. + * @return The {@link Download} with the given {@code id}, or null if a download state with this + * id doesn't exist. + * @throws IOException If an error occurs reading the state. + */ + @Nullable + Download getDownload(String id) throws IOException; + + /** + * Returns a {@link DownloadCursor} to {@link Download}s with the given {@code states}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param states Returns only the {@link Download}s with this states. If empty, returns all. + * @return A cursor to {@link Download}s with the given {@code states}. + * @throws IOException If an error occurs reading the state. + */ + DownloadCursor getDownloads(@Download.State int... states) throws IOException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java new file mode 100644 index 0000000000..a6ace12343 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadManager.java @@ -0,0 +1,1346 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.FAILURE_REASON_NONE; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.FAILURE_REASON_UNKNOWN; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_COMPLETED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_DOWNLOADING; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_FAILED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_QUEUED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_REMOVING; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_RESTARTING; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STATE_STOPPED; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; + +import android.content.Context; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.Requirements; +import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.RequirementsWatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource.Factory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheEvictor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.CopyOnWriteArraySet; + +/** + * Manages downloads. + * + * <p>Normally a download manager should be accessed via a {@link DownloadService}. When a download + * manager is used directly instead, downloads will be initially paused and so must be resumed by + * calling {@link #resumeDownloads()}. + * + * <p>A download manager instance must be accessed only from the thread that created it, unless that + * thread does not have a {@link Looper}. In that case, it must be accessed only from the + * application's main thread. Registered listeners will be called on the same thread. + */ +public final class DownloadManager { + + /** Listener for {@link DownloadManager} events. */ + public interface Listener { + + /** + * Called when all downloads have been restored. + * + * @param downloadManager The reporting instance. + */ + default void onInitialized(DownloadManager downloadManager) {} + + /** + * Called when downloads are ({@link #pauseDownloads() paused} or {@link #resumeDownloads() + * resumed}. + * + * @param downloadManager The reporting instance. + * @param downloadsPaused Whether downloads are currently paused. + */ + default void onDownloadsPausedChanged( + DownloadManager downloadManager, boolean downloadsPaused) {} + + /** + * Called when the state of a download changes. + * + * @param downloadManager The reporting instance. + * @param download The state of the download. + */ + default void onDownloadChanged(DownloadManager downloadManager, Download download) {} + + /** + * Called when a download is removed. + * + * @param downloadManager The reporting instance. + * @param download The last state of the download before it was removed. + */ + default void onDownloadRemoved(DownloadManager downloadManager, Download download) {} + + /** + * Called when there is no active download left. + * + * @param downloadManager The reporting instance. + */ + default void onIdle(DownloadManager downloadManager) {} + + /** + * Called when the download requirements state changed. + * + * @param downloadManager The reporting instance. + * @param requirements Requirements needed to be met to start downloads. + * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not + * met, or 0. + */ + default void onRequirementsStateChanged( + DownloadManager downloadManager, + Requirements requirements, + @Requirements.RequirementFlags int notMetRequirements) {} + + /** + * Called when there is a change in whether this manager has one or more downloads that are not + * progressing for the sole reason that the {@link #getRequirements() Requirements} are not met. + * See {@link #isWaitingForRequirements()} for more information. + * + * @param downloadManager The reporting instance. + * @param waitingForRequirements Whether this manager has one or more downloads that are not + * progressing for the sole reason that the {@link #getRequirements() Requirements} are not + * met. + */ + default void onWaitingForRequirementsChanged( + DownloadManager downloadManager, boolean waitingForRequirements) {} + } + + /** The default maximum number of parallel downloads. */ + public static final int DEFAULT_MAX_PARALLEL_DOWNLOADS = 3; + /** The default minimum number of times a download must be retried before failing. */ + public static final int DEFAULT_MIN_RETRY_COUNT = 5; + /** The default requirement is that the device has network connectivity. */ + public static final Requirements DEFAULT_REQUIREMENTS = new Requirements(Requirements.NETWORK); + + // Messages posted to the main handler. + private static final int MSG_INITIALIZED = 0; + private static final int MSG_PROCESSED = 1; + private static final int MSG_DOWNLOAD_UPDATE = 2; + + // Messages posted to the background handler. + private static final int MSG_INITIALIZE = 0; + private static final int MSG_SET_DOWNLOADS_PAUSED = 1; + private static final int MSG_SET_NOT_MET_REQUIREMENTS = 2; + private static final int MSG_SET_STOP_REASON = 3; + private static final int MSG_SET_MAX_PARALLEL_DOWNLOADS = 4; + private static final int MSG_SET_MIN_RETRY_COUNT = 5; + private static final int MSG_ADD_DOWNLOAD = 6; + private static final int MSG_REMOVE_DOWNLOAD = 7; + private static final int MSG_REMOVE_ALL_DOWNLOADS = 8; + private static final int MSG_TASK_STOPPED = 9; + private static final int MSG_CONTENT_LENGTH_CHANGED = 10; + private static final int MSG_UPDATE_PROGRESS = 11; + private static final int MSG_RELEASE = 12; + + private static final String TAG = "DownloadManager"; + + private final Context context; + private final WritableDownloadIndex downloadIndex; + private final Handler mainHandler; + private final InternalHandler internalHandler; + private final RequirementsWatcher.Listener requirementsListener; + private final CopyOnWriteArraySet<Listener> listeners; + + private int pendingMessages; + private int activeTaskCount; + private boolean initialized; + private boolean downloadsPaused; + private int maxParallelDownloads; + private int minRetryCount; + private int notMetRequirements; + private boolean waitingForRequirements; + private List<Download> downloads; + private RequirementsWatcher requirementsWatcher; + + /** + * Constructs a {@link DownloadManager}. + * + * @param context Any context. + * @param databaseProvider Provides the SQLite database in which downloads are persisted. + * @param cache A cache to be used to store downloaded data. The cache should be configured with + * an {@link CacheEvictor} that will not evict downloaded content, for example {@link + * NoOpCacheEvictor}. + * @param upstreamFactory A {@link Factory} for creating {@link DataSource}s for downloading data. + */ + public DownloadManager( + Context context, DatabaseProvider databaseProvider, Cache cache, Factory upstreamFactory) { + this( + context, + new DefaultDownloadIndex(databaseProvider), + new DefaultDownloaderFactory(new DownloaderConstructorHelper(cache, upstreamFactory))); + } + + /** + * Constructs a {@link DownloadManager}. + * + * @param context Any context. + * @param downloadIndex The download index used to hold the download information. + * @param downloaderFactory A factory for creating {@link Downloader}s. + */ + public DownloadManager( + Context context, WritableDownloadIndex downloadIndex, DownloaderFactory downloaderFactory) { + this.context = context.getApplicationContext(); + this.downloadIndex = downloadIndex; + + maxParallelDownloads = DEFAULT_MAX_PARALLEL_DOWNLOADS; + minRetryCount = DEFAULT_MIN_RETRY_COUNT; + downloadsPaused = true; + downloads = Collections.emptyList(); + listeners = new CopyOnWriteArraySet<>(); + + @SuppressWarnings("methodref.receiver.bound.invalid") + Handler mainHandler = Util.createHandler(this::handleMainMessage); + this.mainHandler = mainHandler; + HandlerThread internalThread = new HandlerThread("DownloadManager file i/o"); + internalThread.start(); + internalHandler = + new InternalHandler( + internalThread, + downloadIndex, + downloaderFactory, + mainHandler, + maxParallelDownloads, + minRetryCount, + downloadsPaused); + + @SuppressWarnings("methodref.receiver.bound.invalid") + RequirementsWatcher.Listener requirementsListener = this::onRequirementsStateChanged; + this.requirementsListener = requirementsListener; + requirementsWatcher = + new RequirementsWatcher(context, requirementsListener, DEFAULT_REQUIREMENTS); + notMetRequirements = requirementsWatcher.start(); + + pendingMessages = 1; + internalHandler + .obtainMessage(MSG_INITIALIZE, notMetRequirements, /* unused */ 0) + .sendToTarget(); + } + + /** Returns whether the manager has completed initialization. */ + public boolean isInitialized() { + return initialized; + } + + /** + * Returns whether the manager is currently idle. The manager is idle if all downloads are in a + * terminal state (i.e. completed or failed), or if no progress can be made (e.g. because the + * download requirements are not met). + */ + public boolean isIdle() { + return activeTaskCount == 0 && pendingMessages == 0; + } + + /** + * Returns whether this manager has one or more downloads that are not progressing for the sole + * reason that the {@link #getRequirements() Requirements} are not met. This is true if: + * + * <ul> + * <li>The {@link #getRequirements() Requirements} are not met. + * <li>The downloads are not paused (i.e. {@link #getDownloadsPaused()} is {@code false}). + * <li>There are downloads in the {@link Download#STATE_QUEUED queued state}. + * </ul> + */ + public boolean isWaitingForRequirements() { + return waitingForRequirements; + } + + /** + * Adds a {@link Listener}. + * + * @param listener The listener to be added. + */ + public void addListener(Listener listener) { + listeners.add(listener); + } + + /** + * Removes a {@link Listener}. + * + * @param listener The listener to be removed. + */ + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + /** Returns the requirements needed to be met to progress. */ + public Requirements getRequirements() { + return requirementsWatcher.getRequirements(); + } + + /** + * Returns the requirements needed for downloads to progress that are not currently met. + * + * @return The not met {@link Requirements.RequirementFlags}, or 0 if all requirements are met. + */ + @Requirements.RequirementFlags + public int getNotMetRequirements() { + return notMetRequirements; + } + + /** + * Sets the requirements that need to be met for downloads to progress. + * + * @param requirements A {@link Requirements}. + */ + public void setRequirements(Requirements requirements) { + if (requirements.equals(requirementsWatcher.getRequirements())) { + return; + } + requirementsWatcher.stop(); + requirementsWatcher = new RequirementsWatcher(context, requirementsListener, requirements); + int notMetRequirements = requirementsWatcher.start(); + onRequirementsStateChanged(requirementsWatcher, notMetRequirements); + } + + /** Returns the maximum number of parallel downloads. */ + public int getMaxParallelDownloads() { + return maxParallelDownloads; + } + + /** + * Sets the maximum number of parallel downloads. + * + * @param maxParallelDownloads The maximum number of parallel downloads. Must be greater than 0. + */ + public void setMaxParallelDownloads(int maxParallelDownloads) { + Assertions.checkArgument(maxParallelDownloads > 0); + if (this.maxParallelDownloads == maxParallelDownloads) { + return; + } + this.maxParallelDownloads = maxParallelDownloads; + pendingMessages++; + internalHandler + .obtainMessage(MSG_SET_MAX_PARALLEL_DOWNLOADS, maxParallelDownloads, /* unused */ 0) + .sendToTarget(); + } + + /** + * Returns the minimum number of times that a download will be retried. A download will fail if + * the specified number of retries is exceeded without any progress being made. + */ + public int getMinRetryCount() { + return minRetryCount; + } + + /** + * Sets the minimum number of times that a download will be retried. A download will fail if the + * specified number of retries is exceeded without any progress being made. + * + * @param minRetryCount The minimum number of times that a download will be retried. + */ + public void setMinRetryCount(int minRetryCount) { + Assertions.checkArgument(minRetryCount >= 0); + if (this.minRetryCount == minRetryCount) { + return; + } + this.minRetryCount = minRetryCount; + pendingMessages++; + internalHandler + .obtainMessage(MSG_SET_MIN_RETRY_COUNT, minRetryCount, /* unused */ 0) + .sendToTarget(); + } + + /** Returns the used {@link DownloadIndex}. */ + public DownloadIndex getDownloadIndex() { + return downloadIndex; + } + + /** + * Returns current downloads. Downloads that are in terminal states (i.e. completed or failed) are + * not included. To query all downloads including those in terminal states, use {@link + * #getDownloadIndex()} instead. + */ + public List<Download> getCurrentDownloads() { + return downloads; + } + + /** Returns whether downloads are currently paused. */ + public boolean getDownloadsPaused() { + return downloadsPaused; + } + + /** + * Resumes downloads. + * + * <p>If the {@link #setRequirements(Requirements) Requirements} are met up to {@link + * #getMaxParallelDownloads() maxParallelDownloads} will be started, excluding those with non-zero + * {@link Download#stopReason stopReasons}. + */ + public void resumeDownloads() { + setDownloadsPaused(/* downloadsPaused= */ false); + } + + /** + * Pauses downloads. Downloads that would otherwise be making progress will transition to {@link + * Download#STATE_QUEUED}. + */ + public void pauseDownloads() { + setDownloadsPaused(/* downloadsPaused= */ true); + } + + /** + * Sets the stop reason for one or all downloads. To clear the stop reason, pass {@link + * Download#STOP_REASON_NONE}. + * + * @param id The content id of the download to update, or {@code null} to set the stop reason for + * all downloads. + * @param stopReason The stop reason, or {@link Download#STOP_REASON_NONE}. + */ + public void setStopReason(@Nullable String id, int stopReason) { + pendingMessages++; + internalHandler + .obtainMessage(MSG_SET_STOP_REASON, stopReason, /* unused */ 0, id) + .sendToTarget(); + } + + /** + * Adds a download defined by the given request. + * + * @param request The download request. + */ + public void addDownload(DownloadRequest request) { + addDownload(request, STOP_REASON_NONE); + } + + /** + * Adds a download defined by the given request and with the specified stop reason. + * + * @param request The download request. + * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE} + * if the download should be started. + */ + public void addDownload(DownloadRequest request, int stopReason) { + pendingMessages++; + internalHandler + .obtainMessage(MSG_ADD_DOWNLOAD, stopReason, /* unused */ 0, request) + .sendToTarget(); + } + + /** + * Cancels the download with the {@code id} and removes all downloaded data. + * + * @param id The unique content id of the download to be started. + */ + public void removeDownload(String id) { + pendingMessages++; + internalHandler.obtainMessage(MSG_REMOVE_DOWNLOAD, id).sendToTarget(); + } + + /** Cancels all pending downloads and removes all downloaded data. */ + public void removeAllDownloads() { + pendingMessages++; + internalHandler.obtainMessage(MSG_REMOVE_ALL_DOWNLOADS).sendToTarget(); + } + + /** + * Stops the downloads and releases resources. Waits until the downloads are persisted to the + * download index. The manager must not be accessed after this method has been called. + */ + public void release() { + synchronized (internalHandler) { + if (internalHandler.released) { + return; + } + internalHandler.sendEmptyMessage(MSG_RELEASE); + boolean wasInterrupted = false; + while (!internalHandler.released) { + try { + internalHandler.wait(); + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); + } + mainHandler.removeCallbacksAndMessages(/* token= */ null); + // Reset state. + downloads = Collections.emptyList(); + pendingMessages = 0; + activeTaskCount = 0; + initialized = false; + notMetRequirements = 0; + waitingForRequirements = false; + } + } + + private void setDownloadsPaused(boolean downloadsPaused) { + if (this.downloadsPaused == downloadsPaused) { + return; + } + this.downloadsPaused = downloadsPaused; + pendingMessages++; + internalHandler + .obtainMessage(MSG_SET_DOWNLOADS_PAUSED, downloadsPaused ? 1 : 0, /* unused */ 0) + .sendToTarget(); + boolean waitingForRequirementsChanged = updateWaitingForRequirements(); + for (Listener listener : listeners) { + listener.onDownloadsPausedChanged(this, downloadsPaused); + } + if (waitingForRequirementsChanged) { + notifyWaitingForRequirementsChanged(); + } + } + + private void onRequirementsStateChanged( + RequirementsWatcher requirementsWatcher, + @Requirements.RequirementFlags int notMetRequirements) { + Requirements requirements = requirementsWatcher.getRequirements(); + if (this.notMetRequirements != notMetRequirements) { + this.notMetRequirements = notMetRequirements; + pendingMessages++; + internalHandler + .obtainMessage(MSG_SET_NOT_MET_REQUIREMENTS, notMetRequirements, /* unused */ 0) + .sendToTarget(); + } + boolean waitingForRequirementsChanged = updateWaitingForRequirements(); + for (Listener listener : listeners) { + listener.onRequirementsStateChanged(this, requirements, notMetRequirements); + } + if (waitingForRequirementsChanged) { + notifyWaitingForRequirementsChanged(); + } + } + + private boolean updateWaitingForRequirements() { + boolean waitingForRequirements = false; + if (!downloadsPaused && notMetRequirements != 0) { + for (int i = 0; i < downloads.size(); i++) { + if (downloads.get(i).state == STATE_QUEUED) { + waitingForRequirements = true; + break; + } + } + } + boolean waitingForRequirementsChanged = this.waitingForRequirements != waitingForRequirements; + this.waitingForRequirements = waitingForRequirements; + return waitingForRequirementsChanged; + } + + private void notifyWaitingForRequirementsChanged() { + for (Listener listener : listeners) { + listener.onWaitingForRequirementsChanged(this, waitingForRequirements); + } + } + + // Main thread message handling. + + @SuppressWarnings("unchecked") + private boolean handleMainMessage(Message message) { + switch (message.what) { + case MSG_INITIALIZED: + List<Download> downloads = (List<Download>) message.obj; + onInitialized(downloads); + break; + case MSG_DOWNLOAD_UPDATE: + DownloadUpdate update = (DownloadUpdate) message.obj; + onDownloadUpdate(update); + break; + case MSG_PROCESSED: + int processedMessageCount = message.arg1; + int activeTaskCount = message.arg2; + onMessageProcessed(processedMessageCount, activeTaskCount); + break; + default: + throw new IllegalStateException(); + } + return true; + } + + private void onInitialized(List<Download> downloads) { + initialized = true; + this.downloads = Collections.unmodifiableList(downloads); + boolean waitingForRequirementsChanged = updateWaitingForRequirements(); + for (Listener listener : listeners) { + listener.onInitialized(DownloadManager.this); + } + if (waitingForRequirementsChanged) { + notifyWaitingForRequirementsChanged(); + } + } + + private void onDownloadUpdate(DownloadUpdate update) { + downloads = Collections.unmodifiableList(update.downloads); + Download updatedDownload = update.download; + boolean waitingForRequirementsChanged = updateWaitingForRequirements(); + if (update.isRemove) { + for (Listener listener : listeners) { + listener.onDownloadRemoved(this, updatedDownload); + } + } else { + for (Listener listener : listeners) { + listener.onDownloadChanged(this, updatedDownload); + } + } + if (waitingForRequirementsChanged) { + notifyWaitingForRequirementsChanged(); + } + } + + private void onMessageProcessed(int processedMessageCount, int activeTaskCount) { + this.pendingMessages -= processedMessageCount; + this.activeTaskCount = activeTaskCount; + if (isIdle()) { + for (Listener listener : listeners) { + listener.onIdle(this); + } + } + } + + /* package */ static Download mergeRequest( + Download download, DownloadRequest request, int stopReason, long nowMs) { + @Download.State int state = download.state; + // Treat the merge as creating a new download if we're currently removing the existing one, or + // if the existing download is in a terminal state. Else treat the merge as updating the + // existing download. + long startTimeMs = + state == STATE_REMOVING || download.isTerminalState() ? nowMs : download.startTimeMs; + if (state == STATE_REMOVING || state == STATE_RESTARTING) { + state = STATE_RESTARTING; + } else if (stopReason != STOP_REASON_NONE) { + state = STATE_STOPPED; + } else { + state = STATE_QUEUED; + } + return new Download( + download.request.copyWithMergedRequest(request), + state, + startTimeMs, + /* updateTimeMs= */ nowMs, + /* contentLength= */ C.LENGTH_UNSET, + stopReason, + FAILURE_REASON_NONE); + } + + private static final class InternalHandler extends Handler { + + private static final int UPDATE_PROGRESS_INTERVAL_MS = 5000; + + public boolean released; + + private final HandlerThread thread; + private final WritableDownloadIndex downloadIndex; + private final DownloaderFactory downloaderFactory; + private final Handler mainHandler; + private final ArrayList<Download> downloads; + private final HashMap<String, Task> activeTasks; + + @Requirements.RequirementFlags private int notMetRequirements; + private boolean downloadsPaused; + private int maxParallelDownloads; + private int minRetryCount; + private int activeDownloadTaskCount; + + public InternalHandler( + HandlerThread thread, + WritableDownloadIndex downloadIndex, + DownloaderFactory downloaderFactory, + Handler mainHandler, + int maxParallelDownloads, + int minRetryCount, + boolean downloadsPaused) { + super(thread.getLooper()); + this.thread = thread; + this.downloadIndex = downloadIndex; + this.downloaderFactory = downloaderFactory; + this.mainHandler = mainHandler; + this.maxParallelDownloads = maxParallelDownloads; + this.minRetryCount = minRetryCount; + this.downloadsPaused = downloadsPaused; + downloads = new ArrayList<>(); + activeTasks = new HashMap<>(); + } + + @Override + public void handleMessage(Message message) { + boolean processedExternalMessage = true; + switch (message.what) { + case MSG_INITIALIZE: + int notMetRequirements = message.arg1; + initialize(notMetRequirements); + break; + case MSG_SET_DOWNLOADS_PAUSED: + boolean downloadsPaused = message.arg1 != 0; + setDownloadsPaused(downloadsPaused); + break; + case MSG_SET_NOT_MET_REQUIREMENTS: + notMetRequirements = message.arg1; + setNotMetRequirements(notMetRequirements); + break; + case MSG_SET_STOP_REASON: + String id = (String) message.obj; + int stopReason = message.arg1; + setStopReason(id, stopReason); + break; + case MSG_SET_MAX_PARALLEL_DOWNLOADS: + int maxParallelDownloads = message.arg1; + setMaxParallelDownloads(maxParallelDownloads); + break; + case MSG_SET_MIN_RETRY_COUNT: + int minRetryCount = message.arg1; + setMinRetryCount(minRetryCount); + break; + case MSG_ADD_DOWNLOAD: + DownloadRequest request = (DownloadRequest) message.obj; + stopReason = message.arg1; + addDownload(request, stopReason); + break; + case MSG_REMOVE_DOWNLOAD: + id = (String) message.obj; + removeDownload(id); + break; + case MSG_REMOVE_ALL_DOWNLOADS: + removeAllDownloads(); + break; + case MSG_TASK_STOPPED: + Task task = (Task) message.obj; + onTaskStopped(task); + processedExternalMessage = false; // This message is posted internally. + break; + case MSG_CONTENT_LENGTH_CHANGED: + task = (Task) message.obj; + onContentLengthChanged(task); + return; // No need to post back to mainHandler. + case MSG_UPDATE_PROGRESS: + updateProgress(); + return; // No need to post back to mainHandler. + case MSG_RELEASE: + release(); + return; // No need to post back to mainHandler. + default: + throw new IllegalStateException(); + } + mainHandler + .obtainMessage(MSG_PROCESSED, processedExternalMessage ? 1 : 0, activeTasks.size()) + .sendToTarget(); + } + + private void initialize(int notMetRequirements) { + this.notMetRequirements = notMetRequirements; + DownloadCursor cursor = null; + try { + downloadIndex.setDownloadingStatesToQueued(); + cursor = + downloadIndex.getDownloads( + STATE_QUEUED, STATE_STOPPED, STATE_DOWNLOADING, STATE_REMOVING, STATE_RESTARTING); + while (cursor.moveToNext()) { + downloads.add(cursor.getDownload()); + } + } catch (IOException e) { + Log.e(TAG, "Failed to load index.", e); + downloads.clear(); + } finally { + Util.closeQuietly(cursor); + } + // A copy must be used for the message to ensure that subsequent changes to the downloads list + // are not visible to the main thread when it processes the message. + ArrayList<Download> downloadsForMessage = new ArrayList<>(downloads); + mainHandler.obtainMessage(MSG_INITIALIZED, downloadsForMessage).sendToTarget(); + syncTasks(); + } + + private void setDownloadsPaused(boolean downloadsPaused) { + this.downloadsPaused = downloadsPaused; + syncTasks(); + } + + private void setNotMetRequirements(@Requirements.RequirementFlags int notMetRequirements) { + this.notMetRequirements = notMetRequirements; + syncTasks(); + } + + private void setStopReason(@Nullable String id, int stopReason) { + if (id == null) { + for (int i = 0; i < downloads.size(); i++) { + setStopReason(downloads.get(i), stopReason); + } + try { + // Set the stop reason for downloads in terminal states as well. + downloadIndex.setStopReason(stopReason); + } catch (IOException e) { + Log.e(TAG, "Failed to set manual stop reason", e); + } + } else { + @Nullable Download download = getDownload(id, /* loadFromIndex= */ false); + if (download != null) { + setStopReason(download, stopReason); + } else { + try { + // Set the stop reason if the download is in a terminal state. + downloadIndex.setStopReason(id, stopReason); + } catch (IOException e) { + Log.e(TAG, "Failed to set manual stop reason: " + id, e); + } + } + } + syncTasks(); + } + + private void setStopReason(Download download, int stopReason) { + if (stopReason == STOP_REASON_NONE) { + if (download.state == STATE_STOPPED) { + putDownloadWithState(download, STATE_QUEUED); + } + } else if (stopReason != download.stopReason) { + @Download.State int state = download.state; + if (state == STATE_QUEUED || state == STATE_DOWNLOADING) { + state = STATE_STOPPED; + } + putDownload( + new Download( + download.request, + state, + download.startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + download.contentLength, + stopReason, + FAILURE_REASON_NONE, + download.progress)); + } + } + + private void setMaxParallelDownloads(int maxParallelDownloads) { + this.maxParallelDownloads = maxParallelDownloads; + syncTasks(); + } + + private void setMinRetryCount(int minRetryCount) { + this.minRetryCount = minRetryCount; + } + + private void addDownload(DownloadRequest request, int stopReason) { + @Nullable Download download = getDownload(request.id, /* loadFromIndex= */ true); + long nowMs = System.currentTimeMillis(); + if (download != null) { + putDownload(mergeRequest(download, request, stopReason, nowMs)); + } else { + putDownload( + new Download( + request, + stopReason != STOP_REASON_NONE ? STATE_STOPPED : STATE_QUEUED, + /* startTimeMs= */ nowMs, + /* updateTimeMs= */ nowMs, + /* contentLength= */ C.LENGTH_UNSET, + stopReason, + FAILURE_REASON_NONE)); + } + syncTasks(); + } + + private void removeDownload(String id) { + @Nullable Download download = getDownload(id, /* loadFromIndex= */ true); + if (download == null) { + Log.e(TAG, "Failed to remove nonexistent download: " + id); + return; + } + putDownloadWithState(download, STATE_REMOVING); + syncTasks(); + } + + private void removeAllDownloads() { + List<Download> terminalDownloads = new ArrayList<>(); + try (DownloadCursor cursor = downloadIndex.getDownloads(STATE_COMPLETED, STATE_FAILED)) { + while (cursor.moveToNext()) { + terminalDownloads.add(cursor.getDownload()); + } + } catch (IOException e) { + Log.e(TAG, "Failed to load downloads."); + } + for (int i = 0; i < downloads.size(); i++) { + downloads.set(i, copyDownloadWithState(downloads.get(i), STATE_REMOVING)); + } + for (int i = 0; i < terminalDownloads.size(); i++) { + downloads.add(copyDownloadWithState(terminalDownloads.get(i), STATE_REMOVING)); + } + Collections.sort(downloads, InternalHandler::compareStartTimes); + try { + downloadIndex.setStatesToRemoving(); + } catch (IOException e) { + Log.e(TAG, "Failed to update index.", e); + } + ArrayList<Download> updateList = new ArrayList<>(downloads); + for (int i = 0; i < downloads.size(); i++) { + DownloadUpdate update = + new DownloadUpdate(downloads.get(i), /* isRemove= */ false, updateList); + mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); + } + syncTasks(); + } + + private void release() { + for (Task task : activeTasks.values()) { + task.cancel(/* released= */ true); + } + try { + downloadIndex.setDownloadingStatesToQueued(); + } catch (IOException e) { + Log.e(TAG, "Failed to update index.", e); + } + downloads.clear(); + thread.quit(); + synchronized (this) { + released = true; + notifyAll(); + } + } + + // Start and cancel tasks based on the current download and manager states. + + private void syncTasks() { + int accumulatingDownloadTaskCount = 0; + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + @Nullable Task activeTask = activeTasks.get(download.request.id); + switch (download.state) { + case STATE_STOPPED: + syncStoppedDownload(activeTask); + break; + case STATE_QUEUED: + activeTask = syncQueuedDownload(activeTask, download); + break; + case STATE_DOWNLOADING: + Assertions.checkNotNull(activeTask); + syncDownloadingDownload(activeTask, download, accumulatingDownloadTaskCount); + break; + case STATE_REMOVING: + case STATE_RESTARTING: + syncRemovingDownload(activeTask, download); + break; + case STATE_COMPLETED: + case STATE_FAILED: + default: + throw new IllegalStateException(); + } + if (activeTask != null && !activeTask.isRemove) { + accumulatingDownloadTaskCount++; + } + } + } + + private void syncStoppedDownload(@Nullable Task activeTask) { + if (activeTask != null) { + // We have a task, which must be a download task. Cancel it. + Assertions.checkState(!activeTask.isRemove); + activeTask.cancel(/* released= */ false); + } + } + + @Nullable + @CheckResult + private Task syncQueuedDownload(@Nullable Task activeTask, Download download) { + if (activeTask != null) { + // We have a task, which must be a download task. If the download state is queued we need to + // cancel it and start a new one, since a new request has been merged into the download. + Assertions.checkState(!activeTask.isRemove); + activeTask.cancel(/* released= */ false); + return activeTask; + } + + if (!canDownloadsRun() || activeDownloadTaskCount >= maxParallelDownloads) { + return null; + } + + // We can start a download task. + download = putDownloadWithState(download, STATE_DOWNLOADING); + Downloader downloader = downloaderFactory.createDownloader(download.request); + activeTask = + new Task( + download.request, + downloader, + download.progress, + /* isRemove= */ false, + minRetryCount, + /* internalHandler= */ this); + activeTasks.put(download.request.id, activeTask); + if (activeDownloadTaskCount++ == 0) { + sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, UPDATE_PROGRESS_INTERVAL_MS); + } + activeTask.start(); + return activeTask; + } + + private void syncDownloadingDownload( + Task activeTask, Download download, int accumulatingDownloadTaskCount) { + Assertions.checkState(!activeTask.isRemove); + if (!canDownloadsRun() || accumulatingDownloadTaskCount >= maxParallelDownloads) { + putDownloadWithState(download, STATE_QUEUED); + activeTask.cancel(/* released= */ false); + } + } + + private void syncRemovingDownload(@Nullable Task activeTask, Download download) { + if (activeTask != null) { + if (!activeTask.isRemove) { + // Cancel the downloading task. + activeTask.cancel(/* released= */ false); + } + // The activeTask is either a remove task, or a downloading task that we just cancelled. In + // the latter case we need to wait for the task to stop before we start a remove task. + return; + } + + // We can start a remove task. + Downloader downloader = downloaderFactory.createDownloader(download.request); + activeTask = + new Task( + download.request, + downloader, + download.progress, + /* isRemove= */ true, + minRetryCount, + /* internalHandler= */ this); + activeTasks.put(download.request.id, activeTask); + activeTask.start(); + } + + // Task event processing. + + private void onContentLengthChanged(Task task) { + String downloadId = task.request.id; + long contentLength = task.contentLength; + Download download = + Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false)); + if (contentLength == download.contentLength || contentLength == C.LENGTH_UNSET) { + return; + } + putDownload( + new Download( + download.request, + download.state, + download.startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + contentLength, + download.stopReason, + download.failureReason, + download.progress)); + } + + private void onTaskStopped(Task task) { + String downloadId = task.request.id; + activeTasks.remove(downloadId); + + boolean isRemove = task.isRemove; + if (!isRemove && --activeDownloadTaskCount == 0) { + removeMessages(MSG_UPDATE_PROGRESS); + } + + if (task.isCanceled) { + syncTasks(); + return; + } + + @Nullable Throwable finalError = task.finalError; + if (finalError != null) { + Log.e(TAG, "Task failed: " + task.request + ", " + isRemove, finalError); + } + + Download download = + Assertions.checkNotNull(getDownload(downloadId, /* loadFromIndex= */ false)); + switch (download.state) { + case STATE_DOWNLOADING: + Assertions.checkState(!isRemove); + onDownloadTaskStopped(download, finalError); + break; + case STATE_REMOVING: + case STATE_RESTARTING: + Assertions.checkState(isRemove); + onRemoveTaskStopped(download); + break; + case STATE_QUEUED: + case STATE_STOPPED: + case STATE_COMPLETED: + case STATE_FAILED: + default: + throw new IllegalStateException(); + } + + syncTasks(); + } + + private void onDownloadTaskStopped(Download download, @Nullable Throwable finalError) { + download = + new Download( + download.request, + finalError == null ? STATE_COMPLETED : STATE_FAILED, + download.startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + download.contentLength, + download.stopReason, + finalError == null ? FAILURE_REASON_NONE : FAILURE_REASON_UNKNOWN, + download.progress); + // The download is now in a terminal state, so should not be in the downloads list. + downloads.remove(getDownloadIndex(download.request.id)); + // We still need to update the download index and main thread. + try { + downloadIndex.putDownload(download); + } catch (IOException e) { + Log.e(TAG, "Failed to update index.", e); + } + DownloadUpdate update = + new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); + mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); + } + + private void onRemoveTaskStopped(Download download) { + if (download.state == STATE_RESTARTING) { + putDownloadWithState( + download, download.stopReason == STOP_REASON_NONE ? STATE_QUEUED : STATE_STOPPED); + syncTasks(); + } else { + int removeIndex = getDownloadIndex(download.request.id); + downloads.remove(removeIndex); + try { + downloadIndex.removeDownload(download.request.id); + } catch (IOException e) { + Log.e(TAG, "Failed to remove from database"); + } + DownloadUpdate update = + new DownloadUpdate(download, /* isRemove= */ true, new ArrayList<>(downloads)); + mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); + } + } + + // Progress updates. + + private void updateProgress() { + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (download.state == STATE_DOWNLOADING) { + try { + downloadIndex.putDownload(download); + } catch (IOException e) { + Log.e(TAG, "Failed to update index.", e); + } + } + } + sendEmptyMessageDelayed(MSG_UPDATE_PROGRESS, UPDATE_PROGRESS_INTERVAL_MS); + } + + // Helper methods. + + private boolean canDownloadsRun() { + return !downloadsPaused && notMetRequirements == 0; + } + + private Download putDownloadWithState(Download download, @Download.State int state) { + // Downloads in terminal states shouldn't be in the downloads list. This method cannot be used + // to set STATE_STOPPED either, because it doesn't have a stopReason argument. + Assertions.checkState( + state != STATE_COMPLETED && state != STATE_FAILED && state != STATE_STOPPED); + return putDownload(copyDownloadWithState(download, state)); + } + + private Download putDownload(Download download) { + // Downloads in terminal states shouldn't be in the downloads list. + Assertions.checkState(download.state != STATE_COMPLETED && download.state != STATE_FAILED); + int changedIndex = getDownloadIndex(download.request.id); + if (changedIndex == C.INDEX_UNSET) { + downloads.add(download); + Collections.sort(downloads, InternalHandler::compareStartTimes); + } else { + boolean needsSort = download.startTimeMs != downloads.get(changedIndex).startTimeMs; + downloads.set(changedIndex, download); + if (needsSort) { + Collections.sort(downloads, InternalHandler::compareStartTimes); + } + } + try { + downloadIndex.putDownload(download); + } catch (IOException e) { + Log.e(TAG, "Failed to update index.", e); + } + DownloadUpdate update = + new DownloadUpdate(download, /* isRemove= */ false, new ArrayList<>(downloads)); + mainHandler.obtainMessage(MSG_DOWNLOAD_UPDATE, update).sendToTarget(); + return download; + } + + @Nullable + private Download getDownload(String id, boolean loadFromIndex) { + int index = getDownloadIndex(id); + if (index != C.INDEX_UNSET) { + return downloads.get(index); + } + if (loadFromIndex) { + try { + return downloadIndex.getDownload(id); + } catch (IOException e) { + Log.e(TAG, "Failed to load download: " + id, e); + } + } + return null; + } + + private int getDownloadIndex(String id) { + for (int i = 0; i < downloads.size(); i++) { + Download download = downloads.get(i); + if (download.request.id.equals(id)) { + return i; + } + } + return C.INDEX_UNSET; + } + + private static Download copyDownloadWithState(Download download, @Download.State int state) { + return new Download( + download.request, + state, + download.startTimeMs, + /* updateTimeMs= */ System.currentTimeMillis(), + download.contentLength, + /* stopReason= */ 0, + FAILURE_REASON_NONE, + download.progress); + } + + private static int compareStartTimes(Download first, Download second) { + return Util.compareLong(first.startTimeMs, second.startTimeMs); + } + } + + private static class Task extends Thread implements Downloader.ProgressListener { + + private final DownloadRequest request; + private final Downloader downloader; + private final DownloadProgress downloadProgress; + private final boolean isRemove; + private final int minRetryCount; + + @Nullable private volatile InternalHandler internalHandler; + private volatile boolean isCanceled; + @Nullable private Throwable finalError; + + private long contentLength; + + private Task( + DownloadRequest request, + Downloader downloader, + DownloadProgress downloadProgress, + boolean isRemove, + int minRetryCount, + InternalHandler internalHandler) { + this.request = request; + this.downloader = downloader; + this.downloadProgress = downloadProgress; + this.isRemove = isRemove; + this.minRetryCount = minRetryCount; + this.internalHandler = internalHandler; + contentLength = C.LENGTH_UNSET; + } + + @SuppressWarnings("nullness:assignment.type.incompatible") + public void cancel(boolean released) { + if (released) { + // Download threads are GC roots for as long as they're running. The time taken for + // cancellation to complete depends on the implementation of the downloader being used. We + // null the handler reference here so that it doesn't prevent garbage collection of the + // download manager whilst cancellation is ongoing. + internalHandler = null; + } + if (!isCanceled) { + isCanceled = true; + downloader.cancel(); + interrupt(); + } + } + + // Methods running on download thread. + + @Override + public void run() { + try { + if (isRemove) { + downloader.remove(); + } else { + int errorCount = 0; + long errorPosition = C.LENGTH_UNSET; + while (!isCanceled) { + try { + downloader.download(/* progressListener= */ this); + break; + } catch (IOException e) { + if (!isCanceled) { + long bytesDownloaded = downloadProgress.bytesDownloaded; + if (bytesDownloaded != errorPosition) { + errorPosition = bytesDownloaded; + errorCount = 0; + } + if (++errorCount > minRetryCount) { + throw e; + } + Thread.sleep(getRetryDelayMillis(errorCount)); + } + } + } + } + } catch (Throwable e) { + finalError = e; + } + @Nullable Handler internalHandler = this.internalHandler; + if (internalHandler != null) { + internalHandler.obtainMessage(MSG_TASK_STOPPED, this).sendToTarget(); + } + } + + @Override + public void onProgress(long contentLength, long bytesDownloaded, float percentDownloaded) { + downloadProgress.bytesDownloaded = bytesDownloaded; + downloadProgress.percentDownloaded = percentDownloaded; + if (contentLength != this.contentLength) { + this.contentLength = contentLength; + @Nullable Handler internalHandler = this.internalHandler; + if (internalHandler != null) { + internalHandler.obtainMessage(MSG_CONTENT_LENGTH_CHANGED, this).sendToTarget(); + } + } + } + + private static int getRetryDelayMillis(int errorCount) { + return Math.min((errorCount - 1) * 1000, 5000); + } + } + + private static final class DownloadUpdate { + + public final Download download; + public final boolean isRemove; + public final List<Download> downloads; + + public DownloadUpdate(Download download, boolean isRemove, List<Download> downloads) { + this.download = download; + this.isRemove = isRemove; + this.downloads = downloads; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java new file mode 100644 index 0000000000..177698ec1e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadProgress.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** Mutable {@link Download} progress. */ +public class DownloadProgress { + + /** The number of bytes that have been downloaded. */ + public long bytesDownloaded; + + /** The percentage that has been downloaded, or {@link C#PERCENTAGE_UNSET} if unknown. */ + public float percentDownloaded; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java new file mode 100644 index 0000000000..31a441aa2d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadRequest.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** Defines content to be downloaded. */ +public final class DownloadRequest implements Parcelable { + + /** Thrown when the encoded request data belongs to an unsupported request type. */ + public static class UnsupportedRequestException extends IOException {} + + /** Type for progressive downloads. */ + public static final String TYPE_PROGRESSIVE = "progressive"; + /** Type for DASH downloads. */ + public static final String TYPE_DASH = "dash"; + /** Type for HLS downloads. */ + public static final String TYPE_HLS = "hls"; + /** Type for SmoothStreaming downloads. */ + public static final String TYPE_SS = "ss"; + + /** The unique content id. */ + public final String id; + /** The type of the request. */ + public final String type; + /** The uri being downloaded. */ + public final Uri uri; + /** Stream keys to be downloaded. If empty, all streams will be downloaded. */ + public final List<StreamKey> streamKeys; + /** + * Custom key for cache indexing, or null. Must be null for DASH, HLS and SmoothStreaming + * downloads. + */ + @Nullable public final String customCacheKey; + /** Application defined data associated with the download. May be empty. */ + public final byte[] data; + + /** + * @param id See {@link #id}. + * @param type See {@link #type}. + * @param uri See {@link #uri}. + * @param streamKeys See {@link #streamKeys}. + * @param customCacheKey See {@link #customCacheKey}. + * @param data See {@link #data}. + */ + public DownloadRequest( + String id, + String type, + Uri uri, + List<StreamKey> streamKeys, + @Nullable String customCacheKey, + @Nullable byte[] data) { + if (TYPE_DASH.equals(type) || TYPE_HLS.equals(type) || TYPE_SS.equals(type)) { + Assertions.checkArgument( + customCacheKey == null, "customCacheKey must be null for type: " + type); + } + this.id = id; + this.type = type; + this.uri = uri; + ArrayList<StreamKey> mutableKeys = new ArrayList<>(streamKeys); + Collections.sort(mutableKeys); + this.streamKeys = Collections.unmodifiableList(mutableKeys); + this.customCacheKey = customCacheKey; + this.data = data != null ? Arrays.copyOf(data, data.length) : Util.EMPTY_BYTE_ARRAY; + } + + /* package */ DownloadRequest(Parcel in) { + id = castNonNull(in.readString()); + type = castNonNull(in.readString()); + uri = Uri.parse(castNonNull(in.readString())); + int streamKeyCount = in.readInt(); + ArrayList<StreamKey> mutableStreamKeys = new ArrayList<>(streamKeyCount); + for (int i = 0; i < streamKeyCount; i++) { + mutableStreamKeys.add(in.readParcelable(StreamKey.class.getClassLoader())); + } + streamKeys = Collections.unmodifiableList(mutableStreamKeys); + customCacheKey = in.readString(); + data = castNonNull(in.createByteArray()); + } + + /** + * Returns a copy with the specified ID. + * + * @param id The ID of the copy. + * @return The copy with the specified ID. + */ + public DownloadRequest copyWithId(String id) { + return new DownloadRequest(id, type, uri, streamKeys, customCacheKey, data); + } + + /** + * Returns the result of merging {@code newRequest} into this request. The requests must have the + * same {@link #id} and {@link #type}. + * + * <p>If the requests have different {@link #uri}, {@link #customCacheKey} and {@link #data} + * values, then those from the request being merged are included in the result. + * + * @param newRequest The request being merged. + * @return The merged result. + * @throws IllegalArgumentException If the requests do not have the same {@link #id} and {@link + * #type}. + */ + public DownloadRequest copyWithMergedRequest(DownloadRequest newRequest) { + Assertions.checkArgument(id.equals(newRequest.id)); + Assertions.checkArgument(type.equals(newRequest.type)); + List<StreamKey> mergedKeys; + if (streamKeys.isEmpty() || newRequest.streamKeys.isEmpty()) { + // If either streamKeys is empty then all streams should be downloaded. + mergedKeys = Collections.emptyList(); + } else { + mergedKeys = new ArrayList<>(streamKeys); + for (int i = 0; i < newRequest.streamKeys.size(); i++) { + StreamKey newKey = newRequest.streamKeys.get(i); + if (!mergedKeys.contains(newKey)) { + mergedKeys.add(newKey); + } + } + } + return new DownloadRequest( + id, type, newRequest.uri, mergedKeys, newRequest.customCacheKey, newRequest.data); + } + + @Override + public String toString() { + return type + ":" + id; + } + + @Override + public boolean equals(@Nullable Object o) { + if (!(o instanceof DownloadRequest)) { + return false; + } + DownloadRequest that = (DownloadRequest) o; + return id.equals(that.id) + && type.equals(that.type) + && uri.equals(that.uri) + && streamKeys.equals(that.streamKeys) + && Util.areEqual(customCacheKey, that.customCacheKey) + && Arrays.equals(data, that.data); + } + + @Override + public final int hashCode() { + int result = type.hashCode(); + result = 31 * result + id.hashCode(); + result = 31 * result + type.hashCode(); + result = 31 * result + uri.hashCode(); + result = 31 * result + streamKeys.hashCode(); + result = 31 * result + (customCacheKey != null ? customCacheKey.hashCode() : 0); + result = 31 * result + Arrays.hashCode(data); + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(id); + dest.writeString(type); + dest.writeString(uri.toString()); + dest.writeInt(streamKeys.size()); + for (int i = 0; i < streamKeys.size(); i++) { + dest.writeParcelable(streamKeys.get(i), /* parcelableFlags= */ 0); + } + dest.writeString(customCacheKey); + dest.writeByteArray(data); + } + + public static final Parcelable.Creator<DownloadRequest> CREATOR = + new Parcelable.Creator<DownloadRequest>() { + + @Override + public DownloadRequest createFromParcel(Parcel in) { + return new DownloadRequest(in); + } + + @Override + public DownloadRequest[] newArray(int size) { + return new DownloadRequest[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java new file mode 100644 index 0000000000..a2d7d82438 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloadService.java @@ -0,0 +1,1049 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.offline.Download.STOP_REASON_NONE; + +import android.app.Notification; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.Requirements; +import org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler.Scheduler; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NotificationUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.HashMap; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A {@link Service} for downloading media. */ +public abstract class DownloadService extends Service { + + /** + * Starts a download service to resume any ongoing downloads. Extras: + * + * <ul> + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_INIT = + "com.google.android.exoplayer.downloadService.action.INIT"; + + /** Like {@link #ACTION_INIT}, but with {@link #KEY_FOREGROUND} implicitly set to true. */ + private static final String ACTION_RESTART = + "com.google.android.exoplayer.downloadService.action.RESTART"; + + /** + * Adds a new download. Extras: + * + * <ul> + * <li>{@link #KEY_DOWNLOAD_REQUEST} - A {@link DownloadRequest} defining the download to be + * added. + * <li>{@link #KEY_STOP_REASON} - An initial stop reason for the download. If omitted {@link + * Download#STOP_REASON_NONE} is used. + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_ADD_DOWNLOAD = + "com.google.android.exoplayer.downloadService.action.ADD_DOWNLOAD"; + + /** + * Removes a download. Extras: + * + * <ul> + * <li>{@link #KEY_CONTENT_ID} - The content id of a download to remove. + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_REMOVE_DOWNLOAD = + "com.google.android.exoplayer.downloadService.action.REMOVE_DOWNLOAD"; + + /** + * Removes all downloads. Extras: + * + * <ul> + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_REMOVE_ALL_DOWNLOADS = + "com.google.android.exoplayer.downloadService.action.REMOVE_ALL_DOWNLOADS"; + + /** + * Resumes all downloads except those that have a non-zero {@link Download#stopReason}. Extras: + * + * <ul> + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_RESUME_DOWNLOADS = + "com.google.android.exoplayer.downloadService.action.RESUME_DOWNLOADS"; + + /** + * Pauses all downloads. Extras: + * + * <ul> + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_PAUSE_DOWNLOADS = + "com.google.android.exoplayer.downloadService.action.PAUSE_DOWNLOADS"; + + /** + * Sets the stop reason for one or all downloads. To clear the stop reason, pass {@link + * Download#STOP_REASON_NONE}. Extras: + * + * <ul> + * <li>{@link #KEY_CONTENT_ID} - The content id of a single download to update with the stop + * reason. If omitted, all downloads will be updated. + * <li>{@link #KEY_STOP_REASON} - An application provided reason for stopping the download or + * downloads, or {@link Download#STOP_REASON_NONE} to clear the stop reason. + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_SET_STOP_REASON = + "com.google.android.exoplayer.downloadService.action.SET_STOP_REASON"; + + /** + * Sets the requirements that need to be met for downloads to progress. Extras: + * + * <ul> + * <li>{@link #KEY_REQUIREMENTS} - A {@link Requirements}. + * <li>{@link #KEY_FOREGROUND} - See {@link #KEY_FOREGROUND}. + * </ul> + */ + public static final String ACTION_SET_REQUIREMENTS = + "com.google.android.exoplayer.downloadService.action.SET_REQUIREMENTS"; + + /** Key for the {@link DownloadRequest} in {@link #ACTION_ADD_DOWNLOAD} intents. */ + public static final String KEY_DOWNLOAD_REQUEST = "download_request"; + + /** + * Key for the {@link String} content id in {@link #ACTION_SET_STOP_REASON} and {@link + * #ACTION_REMOVE_DOWNLOAD} intents. + */ + public static final String KEY_CONTENT_ID = "content_id"; + + /** + * Key for the integer stop reason in {@link #ACTION_SET_STOP_REASON} and {@link + * #ACTION_ADD_DOWNLOAD} intents. + */ + public static final String KEY_STOP_REASON = "stop_reason"; + + /** Key for the {@link Requirements} in {@link #ACTION_SET_REQUIREMENTS} intents. */ + public static final String KEY_REQUIREMENTS = "requirements"; + + /** + * Key for a boolean extra that can be set on any intent to indicate whether the service was + * started in the foreground. If set, the service is guaranteed to call {@link + * #startForeground(int, Notification)}. + */ + public static final String KEY_FOREGROUND = "foreground"; + + /** Invalid foreground notification id that can be used to run the service in the background. */ + public static final int FOREGROUND_NOTIFICATION_ID_NONE = 0; + + /** Default foreground notification update interval in milliseconds. */ + public static final long DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL = 1000; + + private static final String TAG = "DownloadService"; + + // Keep a DownloadManagerHelper for each DownloadService as long as the process is running. The + // helper is needed to restart the DownloadService when there's no scheduler. Even when there is a + // scheduler, the DownloadManagerHelper is typically able to restart the DownloadService faster. + private static final HashMap<Class<? extends DownloadService>, DownloadManagerHelper> + downloadManagerHelpers = new HashMap<>(); + + @Nullable private final ForegroundNotificationUpdater foregroundNotificationUpdater; + @Nullable private final String channelId; + @StringRes private final int channelNameResourceId; + @StringRes private final int channelDescriptionResourceId; + + @MonotonicNonNull private DownloadManager downloadManager; + private int lastStartId; + private boolean startedInForeground; + private boolean taskRemoved; + private boolean isStopped; + private boolean isDestroyed; + + /** + * Creates a DownloadService. + * + * <p>If {@code foregroundNotificationId} is {@link #FOREGROUND_NOTIFICATION_ID_NONE} then the + * service will only ever run in the background. No foreground notification will be displayed and + * {@link #getScheduler()} will not be called. + * + * <p>If {@code foregroundNotificationId} is not {@link #FOREGROUND_NOTIFICATION_ID_NONE} then the + * service will run in the foreground. The foreground notification will be updated at least as + * often as the interval specified by {@link #DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL}. + * + * @param foregroundNotificationId The notification id for the foreground notification, or {@link + * #FOREGROUND_NOTIFICATION_ID_NONE} if the service should only ever run in the background. + */ + protected DownloadService(int foregroundNotificationId) { + this(foregroundNotificationId, DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL); + } + + /** + * Creates a DownloadService. + * + * @param foregroundNotificationId The notification id for the foreground notification, or {@link + * #FOREGROUND_NOTIFICATION_ID_NONE} if the service should only ever run in the background. + * @param foregroundNotificationUpdateInterval The maximum interval between updates to the + * foreground notification, in milliseconds. Ignored if {@code foregroundNotificationId} is + * {@link #FOREGROUND_NOTIFICATION_ID_NONE}. + */ + protected DownloadService( + int foregroundNotificationId, long foregroundNotificationUpdateInterval) { + this( + foregroundNotificationId, + foregroundNotificationUpdateInterval, + /* channelId= */ null, + /* channelNameResourceId= */ 0, + /* channelDescriptionResourceId= */ 0); + } + + /** @deprecated Use {@link #DownloadService(int, long, String, int, int)}. */ + @Deprecated + protected DownloadService( + int foregroundNotificationId, + long foregroundNotificationUpdateInterval, + @Nullable String channelId, + @StringRes int channelNameResourceId) { + this( + foregroundNotificationId, + foregroundNotificationUpdateInterval, + channelId, + channelNameResourceId, + /* channelDescriptionResourceId= */ 0); + } + + /** + * Creates a DownloadService. + * + * @param foregroundNotificationId The notification id for the foreground notification, or {@link + * #FOREGROUND_NOTIFICATION_ID_NONE} if the service should only ever run in the background. + * @param foregroundNotificationUpdateInterval The maximum interval between updates to the + * foreground notification, in milliseconds. Ignored if {@code foregroundNotificationId} is + * {@link #FOREGROUND_NOTIFICATION_ID_NONE}. + * @param channelId An id for a low priority notification channel to create, or {@code null} if + * the app will take care of creating a notification channel if needed. If specified, must be + * unique per package. The value may be truncated if it's too long. Ignored if {@code + * foregroundNotificationId} is {@link #FOREGROUND_NOTIFICATION_ID_NONE}. + * @param channelNameResourceId A string resource identifier for the user visible name of the + * notification channel. The recommended maximum length is 40 characters. The value may be + * truncated if it's too long. Ignored if {@code channelId} is null or if {@code + * foregroundNotificationId} is {@link #FOREGROUND_NOTIFICATION_ID_NONE}. + * @param channelDescriptionResourceId A string resource identifier for the user visible + * description of the notification channel, or 0 if no description is provided. The + * recommended maximum length is 300 characters. The value may be truncated if it is too long. + * Ignored if {@code channelId} is null or if {@code foregroundNotificationId} is {@link + * #FOREGROUND_NOTIFICATION_ID_NONE}. + */ + protected DownloadService( + int foregroundNotificationId, + long foregroundNotificationUpdateInterval, + @Nullable String channelId, + @StringRes int channelNameResourceId, + @StringRes int channelDescriptionResourceId) { + if (foregroundNotificationId == FOREGROUND_NOTIFICATION_ID_NONE) { + this.foregroundNotificationUpdater = null; + this.channelId = null; + this.channelNameResourceId = 0; + this.channelDescriptionResourceId = 0; + } else { + this.foregroundNotificationUpdater = + new ForegroundNotificationUpdater( + foregroundNotificationId, foregroundNotificationUpdateInterval); + this.channelId = channelId; + this.channelNameResourceId = channelNameResourceId; + this.channelDescriptionResourceId = channelDescriptionResourceId; + } + } + + /** + * Builds an {@link Intent} for adding a new download. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param downloadRequest The request to be executed. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildAddDownloadIntent( + Context context, + Class<? extends DownloadService> clazz, + DownloadRequest downloadRequest, + boolean foreground) { + return buildAddDownloadIntent(context, clazz, downloadRequest, STOP_REASON_NONE, foreground); + } + + /** + * Builds an {@link Intent} for adding a new download. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param downloadRequest The request to be executed. + * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE} + * if the download should be started. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildAddDownloadIntent( + Context context, + Class<? extends DownloadService> clazz, + DownloadRequest downloadRequest, + int stopReason, + boolean foreground) { + return getIntent(context, clazz, ACTION_ADD_DOWNLOAD, foreground) + .putExtra(KEY_DOWNLOAD_REQUEST, downloadRequest) + .putExtra(KEY_STOP_REASON, stopReason); + } + + /** + * Builds an {@link Intent} for removing the download with the {@code id}. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param id The content id. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildRemoveDownloadIntent( + Context context, Class<? extends DownloadService> clazz, String id, boolean foreground) { + return getIntent(context, clazz, ACTION_REMOVE_DOWNLOAD, foreground) + .putExtra(KEY_CONTENT_ID, id); + } + + /** + * Builds an {@link Intent} for removing all downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildRemoveAllDownloadsIntent( + Context context, Class<? extends DownloadService> clazz, boolean foreground) { + return getIntent(context, clazz, ACTION_REMOVE_ALL_DOWNLOADS, foreground); + } + + /** + * Builds an {@link Intent} for resuming all downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildResumeDownloadsIntent( + Context context, Class<? extends DownloadService> clazz, boolean foreground) { + return getIntent(context, clazz, ACTION_RESUME_DOWNLOADS, foreground); + } + + /** + * Builds an {@link Intent} to pause all downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildPauseDownloadsIntent( + Context context, Class<? extends DownloadService> clazz, boolean foreground) { + return getIntent(context, clazz, ACTION_PAUSE_DOWNLOADS, foreground); + } + + /** + * Builds an {@link Intent} for setting the stop reason for one or all downloads. To clear the + * stop reason, pass {@link Download#STOP_REASON_NONE}. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param id The content id, or {@code null} to set the stop reason for all downloads. + * @param stopReason An application defined stop reason. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildSetStopReasonIntent( + Context context, + Class<? extends DownloadService> clazz, + @Nullable String id, + int stopReason, + boolean foreground) { + return getIntent(context, clazz, ACTION_SET_STOP_REASON, foreground) + .putExtra(KEY_CONTENT_ID, id) + .putExtra(KEY_STOP_REASON, stopReason); + } + + /** + * Builds an {@link Intent} for setting the requirements that need to be met for downloads to + * progress. + * + * @param context A {@link Context}. + * @param clazz The concrete download service being targeted by the intent. + * @param requirements A {@link Requirements}. + * @param foreground Whether this intent will be used to start the service in the foreground. + * @return The created intent. + */ + public static Intent buildSetRequirementsIntent( + Context context, + Class<? extends DownloadService> clazz, + Requirements requirements, + boolean foreground) { + return getIntent(context, clazz, ACTION_SET_REQUIREMENTS, foreground) + .putExtra(KEY_REQUIREMENTS, requirements); + } + + /** + * Starts the service if not started already and adds a new download. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param downloadRequest The request to be executed. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendAddDownload( + Context context, + Class<? extends DownloadService> clazz, + DownloadRequest downloadRequest, + boolean foreground) { + Intent intent = buildAddDownloadIntent(context, clazz, downloadRequest, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and adds a new download. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param downloadRequest The request to be executed. + * @param stopReason An initial stop reason for the download, or {@link Download#STOP_REASON_NONE} + * if the download should be started. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendAddDownload( + Context context, + Class<? extends DownloadService> clazz, + DownloadRequest downloadRequest, + int stopReason, + boolean foreground) { + Intent intent = buildAddDownloadIntent(context, clazz, downloadRequest, stopReason, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and removes a download. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param id The content id. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendRemoveDownload( + Context context, Class<? extends DownloadService> clazz, String id, boolean foreground) { + Intent intent = buildRemoveDownloadIntent(context, clazz, id, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and removes all downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendRemoveAllDownloads( + Context context, Class<? extends DownloadService> clazz, boolean foreground) { + Intent intent = buildRemoveAllDownloadsIntent(context, clazz, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and resumes all downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendResumeDownloads( + Context context, Class<? extends DownloadService> clazz, boolean foreground) { + Intent intent = buildResumeDownloadsIntent(context, clazz, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and pauses all downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendPauseDownloads( + Context context, Class<? extends DownloadService> clazz, boolean foreground) { + Intent intent = buildPauseDownloadsIntent(context, clazz, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and sets the stop reason for one or all downloads. To + * clear stop reason, pass {@link Download#STOP_REASON_NONE}. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param id The content id, or {@code null} to set the stop reason for all downloads. + * @param stopReason An application defined stop reason. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendSetStopReason( + Context context, + Class<? extends DownloadService> clazz, + @Nullable String id, + int stopReason, + boolean foreground) { + Intent intent = buildSetStopReasonIntent(context, clazz, id, stopReason, foreground); + startService(context, intent, foreground); + } + + /** + * Starts the service if not started already and sets the requirements that need to be met for + * downloads to progress. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @param requirements A {@link Requirements}. + * @param foreground Whether the service is started in the foreground. + */ + public static void sendSetRequirements( + Context context, + Class<? extends DownloadService> clazz, + Requirements requirements, + boolean foreground) { + Intent intent = buildSetRequirementsIntent(context, clazz, requirements, foreground); + startService(context, intent, foreground); + } + + /** + * Starts a download service to resume any ongoing downloads. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @see #startForeground(Context, Class) + */ + public static void start(Context context, Class<? extends DownloadService> clazz) { + context.startService(getIntent(context, clazz, ACTION_INIT)); + } + + /** + * Starts the service in the foreground without adding a new download request. If there are any + * not finished downloads and the requirements are met, the service resumes downloading. Otherwise + * it stops immediately. + * + * @param context A {@link Context}. + * @param clazz The concrete download service to be started. + * @see #start(Context, Class) + */ + public static void startForeground(Context context, Class<? extends DownloadService> clazz) { + Intent intent = getIntent(context, clazz, ACTION_INIT, true); + Util.startForegroundService(context, intent); + } + + @Override + public void onCreate() { + if (channelId != null) { + NotificationUtil.createNotificationChannel( + this, + channelId, + channelNameResourceId, + channelDescriptionResourceId, + NotificationUtil.IMPORTANCE_LOW); + } + Class<? extends DownloadService> clazz = getClass(); + @Nullable DownloadManagerHelper downloadManagerHelper = downloadManagerHelpers.get(clazz); + if (downloadManagerHelper == null) { + boolean foregroundAllowed = foregroundNotificationUpdater != null; + @Nullable Scheduler scheduler = foregroundAllowed ? getScheduler() : null; + downloadManager = getDownloadManager(); + downloadManager.resumeDownloads(); + downloadManagerHelper = + new DownloadManagerHelper( + getApplicationContext(), downloadManager, foregroundAllowed, scheduler, clazz); + downloadManagerHelpers.put(clazz, downloadManagerHelper); + } else { + downloadManager = downloadManagerHelper.downloadManager; + } + downloadManagerHelper.attachService(this); + } + + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + lastStartId = startId; + taskRemoved = false; + @Nullable String intentAction = null; + @Nullable String contentId = null; + if (intent != null) { + intentAction = intent.getAction(); + contentId = intent.getStringExtra(KEY_CONTENT_ID); + startedInForeground |= + intent.getBooleanExtra(KEY_FOREGROUND, false) || ACTION_RESTART.equals(intentAction); + } + // intentAction is null if the service is restarted or no action is specified. + if (intentAction == null) { + intentAction = ACTION_INIT; + } + DownloadManager downloadManager = Assertions.checkNotNull(this.downloadManager); + switch (intentAction) { + case ACTION_INIT: + case ACTION_RESTART: + // Do nothing. + break; + case ACTION_ADD_DOWNLOAD: + @Nullable + DownloadRequest downloadRequest = + Assertions.checkNotNull(intent).getParcelableExtra(KEY_DOWNLOAD_REQUEST); + if (downloadRequest == null) { + Log.e(TAG, "Ignored ADD_DOWNLOAD: Missing " + KEY_DOWNLOAD_REQUEST + " extra"); + } else { + int stopReason = intent.getIntExtra(KEY_STOP_REASON, Download.STOP_REASON_NONE); + downloadManager.addDownload(downloadRequest, stopReason); + } + break; + case ACTION_REMOVE_DOWNLOAD: + if (contentId == null) { + Log.e(TAG, "Ignored REMOVE_DOWNLOAD: Missing " + KEY_CONTENT_ID + " extra"); + } else { + downloadManager.removeDownload(contentId); + } + break; + case ACTION_REMOVE_ALL_DOWNLOADS: + downloadManager.removeAllDownloads(); + break; + case ACTION_RESUME_DOWNLOADS: + downloadManager.resumeDownloads(); + break; + case ACTION_PAUSE_DOWNLOADS: + downloadManager.pauseDownloads(); + break; + case ACTION_SET_STOP_REASON: + if (!Assertions.checkNotNull(intent).hasExtra(KEY_STOP_REASON)) { + Log.e(TAG, "Ignored SET_STOP_REASON: Missing " + KEY_STOP_REASON + " extra"); + } else { + int stopReason = intent.getIntExtra(KEY_STOP_REASON, /* defaultValue= */ 0); + downloadManager.setStopReason(contentId, stopReason); + } + break; + case ACTION_SET_REQUIREMENTS: + @Nullable + Requirements requirements = + Assertions.checkNotNull(intent).getParcelableExtra(KEY_REQUIREMENTS); + if (requirements == null) { + Log.e(TAG, "Ignored SET_REQUIREMENTS: Missing " + KEY_REQUIREMENTS + " extra"); + } else { + downloadManager.setRequirements(requirements); + } + break; + default: + Log.e(TAG, "Ignored unrecognized action: " + intentAction); + break; + } + + if (Util.SDK_INT >= 26 && startedInForeground && foregroundNotificationUpdater != null) { + // From API level 26, services started in the foreground are required to show a notification. + foregroundNotificationUpdater.showNotificationIfNotAlready(); + } + + isStopped = false; + if (downloadManager.isIdle()) { + stop(); + } + return START_STICKY; + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + taskRemoved = true; + } + + @Override + public void onDestroy() { + isDestroyed = true; + DownloadManagerHelper downloadManagerHelper = + Assertions.checkNotNull(downloadManagerHelpers.get(getClass())); + downloadManagerHelper.detachService(this); + if (foregroundNotificationUpdater != null) { + foregroundNotificationUpdater.stopPeriodicUpdates(); + } + } + + /** + * Throws {@link UnsupportedOperationException} because this service is not designed to be bound. + */ + @Nullable + @Override + public final IBinder onBind(Intent intent) { + throw new UnsupportedOperationException(); + } + + /** + * Returns a {@link DownloadManager} to be used to downloaded content. Called only once in the + * life cycle of the process. + */ + protected abstract DownloadManager getDownloadManager(); + + /** + * Returns a {@link Scheduler} to restart the service when requirements allowing downloads to take + * place are met. If {@code null}, the service will only be restarted if the process is still in + * memory when the requirements are met. + * + * <p>This method is not called for services whose {@code foregroundNotificationId} is set to + * {@link #FOREGROUND_NOTIFICATION_ID_NONE}. Such services will only be restarted if the process + * is still in memory and considered non-idle, meaning that it's either in the foreground or was + * backgrounded within the last few minutes. + */ + @Nullable + protected abstract Scheduler getScheduler(); + + /** + * Returns a notification to be displayed when this service running in the foreground. + * + * <p>Download services that do not wish to run in the foreground should be created by setting the + * {@code foregroundNotificationId} constructor argument to {@link + * #FOREGROUND_NOTIFICATION_ID_NONE}. This method is not called for such services, meaning it can + * be implemented to throw {@link UnsupportedOperationException}. + * + * @param downloads The current downloads. + * @return The foreground notification to display. + */ + protected abstract Notification getForegroundNotification(List<Download> downloads); + + /** + * Invalidates the current foreground notification and causes {@link + * #getForegroundNotification(List)} to be invoked again if the service isn't stopped. + */ + protected final void invalidateForegroundNotification() { + if (foregroundNotificationUpdater != null && !isDestroyed) { + foregroundNotificationUpdater.invalidate(); + } + } + + /** + * @deprecated Some state change events may not be delivered to this method. Instead, use {@link + * DownloadManager#addListener(DownloadManager.Listener)} to register a listener directly to + * the {@link DownloadManager} that you return through {@link #getDownloadManager()}. + */ + @Deprecated + protected void onDownloadChanged(Download download) { + // Do nothing. + } + + /** + * @deprecated Some download removal events may not be delivered to this method. Instead, use + * {@link DownloadManager#addListener(DownloadManager.Listener)} to register a listener + * directly to the {@link DownloadManager} that you return through {@link + * #getDownloadManager()}. + */ + @Deprecated + protected void onDownloadRemoved(Download download) { + // Do nothing. + } + + /** + * Called after the service is created, once the downloads are known. + * + * @param downloads The current downloads. + */ + private void notifyDownloads(List<Download> downloads) { + if (foregroundNotificationUpdater != null) { + for (int i = 0; i < downloads.size(); i++) { + if (needsStartedService(downloads.get(i).state)) { + foregroundNotificationUpdater.startPeriodicUpdates(); + break; + } + } + } + } + + /** + * Called when the state of a download changes. + * + * @param download The state of the download. + */ + @SuppressWarnings("deprecation") + private void notifyDownloadChanged(Download download) { + onDownloadChanged(download); + if (foregroundNotificationUpdater != null) { + if (needsStartedService(download.state)) { + foregroundNotificationUpdater.startPeriodicUpdates(); + } else { + foregroundNotificationUpdater.invalidate(); + } + } + } + + /** + * Called when a download is removed. + * + * @param download The last state of the download before it was removed. + */ + @SuppressWarnings("deprecation") + private void notifyDownloadRemoved(Download download) { + onDownloadRemoved(download); + if (foregroundNotificationUpdater != null) { + foregroundNotificationUpdater.invalidate(); + } + } + + /** Returns whether the service is stopped. */ + private boolean isStopped() { + return isStopped; + } + + private void stop() { + if (foregroundNotificationUpdater != null) { + foregroundNotificationUpdater.stopPeriodicUpdates(); + } + if (Util.SDK_INT < 28 && taskRemoved) { // See [Internal: b/74248644]. + stopSelf(); + isStopped = true; + } else { + isStopped |= stopSelfResult(lastStartId); + } + } + + private static boolean needsStartedService(@Download.State int state) { + return state == Download.STATE_DOWNLOADING + || state == Download.STATE_REMOVING + || state == Download.STATE_RESTARTING; + } + + private static Intent getIntent( + Context context, Class<? extends DownloadService> clazz, String action, boolean foreground) { + return getIntent(context, clazz, action).putExtra(KEY_FOREGROUND, foreground); + } + + private static Intent getIntent( + Context context, Class<? extends DownloadService> clazz, String action) { + return new Intent(context, clazz).setAction(action); + } + + private static void startService(Context context, Intent intent, boolean foreground) { + if (foreground) { + Util.startForegroundService(context, intent); + } else { + context.startService(intent); + } + } + + private final class ForegroundNotificationUpdater { + + private final int notificationId; + private final long updateInterval; + private final Handler handler; + + private boolean periodicUpdatesStarted; + private boolean notificationDisplayed; + + public ForegroundNotificationUpdater(int notificationId, long updateInterval) { + this.notificationId = notificationId; + this.updateInterval = updateInterval; + this.handler = new Handler(Looper.getMainLooper()); + } + + public void startPeriodicUpdates() { + periodicUpdatesStarted = true; + update(); + } + + public void stopPeriodicUpdates() { + periodicUpdatesStarted = false; + handler.removeCallbacksAndMessages(null); + } + + public void showNotificationIfNotAlready() { + if (!notificationDisplayed) { + update(); + } + } + + public void invalidate() { + if (notificationDisplayed) { + update(); + } + } + + private void update() { + List<Download> downloads = Assertions.checkNotNull(downloadManager).getCurrentDownloads(); + startForeground(notificationId, getForegroundNotification(downloads)); + notificationDisplayed = true; + if (periodicUpdatesStarted) { + handler.removeCallbacksAndMessages(null); + handler.postDelayed(this::update, updateInterval); + } + } + } + + private static final class DownloadManagerHelper implements DownloadManager.Listener { + + private final Context context; + private final DownloadManager downloadManager; + private final boolean foregroundAllowed; + @Nullable private final Scheduler scheduler; + private final Class<? extends DownloadService> serviceClass; + @Nullable private DownloadService downloadService; + + private DownloadManagerHelper( + Context context, + DownloadManager downloadManager, + boolean foregroundAllowed, + @Nullable Scheduler scheduler, + Class<? extends DownloadService> serviceClass) { + this.context = context; + this.downloadManager = downloadManager; + this.foregroundAllowed = foregroundAllowed; + this.scheduler = scheduler; + this.serviceClass = serviceClass; + downloadManager.addListener(this); + updateScheduler(); + } + + public void attachService(DownloadService downloadService) { + Assertions.checkState(this.downloadService == null); + this.downloadService = downloadService; + if (downloadManager.isInitialized()) { + // The call to DownloadService.notifyDownloads is posted to avoid it being called directly + // from DownloadService.onCreate. This is a good idea because it may in turn call + // DownloadService.getForegroundNotification, and concrete subclass implementations may + // not anticipate the possibility of this method being called before their onCreate + // implementation has finished executing. + new Handler() + .postAtFrontOfQueue( + () -> downloadService.notifyDownloads(downloadManager.getCurrentDownloads())); + } + } + + public void detachService(DownloadService downloadService) { + Assertions.checkState(this.downloadService == downloadService); + this.downloadService = null; + if (scheduler != null && !downloadManager.isWaitingForRequirements()) { + scheduler.cancel(); + } + } + + // DownloadManager.Listener implementation. + + @Override + public void onInitialized(DownloadManager downloadManager) { + if (downloadService != null) { + downloadService.notifyDownloads(downloadManager.getCurrentDownloads()); + } + } + + @Override + public void onDownloadChanged(DownloadManager downloadManager, Download download) { + if (downloadService != null) { + downloadService.notifyDownloadChanged(download); + } + if (serviceMayNeedRestart() && needsStartedService(download.state)) { + // This shouldn't happen unless (a) application code is changing the downloads by calling + // the DownloadManager directly rather than sending actions through the service, or (b) if + // the service is background only and a previous attempt to start it was prevented. Try and + // restart the service to robust against such cases. + Log.w(TAG, "DownloadService wasn't running. Restarting."); + restartService(); + } + } + + @Override + public void onDownloadRemoved(DownloadManager downloadManager, Download download) { + if (downloadService != null) { + downloadService.notifyDownloadRemoved(download); + } + } + + @Override + public final void onIdle(DownloadManager downloadManager) { + if (downloadService != null) { + downloadService.stop(); + } + } + + @Override + public void onWaitingForRequirementsChanged( + DownloadManager downloadManager, boolean waitingForRequirements) { + if (!waitingForRequirements + && !downloadManager.getDownloadsPaused() + && serviceMayNeedRestart()) { + // We're no longer waiting for requirements and downloads aren't paused, meaning the manager + // will be able to resume downloads that are currently queued. If there exist queued + // downloads then we should ensure the service is started. + List<Download> downloads = downloadManager.getCurrentDownloads(); + for (int i = 0; i < downloads.size(); i++) { + if (downloads.get(i).state == Download.STATE_QUEUED) { + restartService(); + break; + } + } + } + updateScheduler(); + } + + // Internal methods. + + private boolean serviceMayNeedRestart() { + return downloadService == null || downloadService.isStopped(); + } + + private void restartService() { + if (foregroundAllowed) { + Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_RESTART); + Util.startForegroundService(context, intent); + } else { + // The service is background only. Use ACTION_INIT rather than ACTION_RESTART because + // ACTION_RESTART is handled as though KEY_FOREGROUND is set to true. + try { + Intent intent = getIntent(context, serviceClass, DownloadService.ACTION_INIT); + context.startService(intent); + } catch (IllegalArgumentException e) { + // The process is classed as idle by the platform. Starting a background service is not + // allowed in this state. + Log.w(TAG, "Failed to restart DownloadService (process is idle)."); + } + } + } + + private void updateScheduler() { + if (scheduler == null) { + return; + } + if (downloadManager.isWaitingForRequirements()) { + String servicePackage = context.getPackageName(); + Requirements requirements = downloadManager.getRequirements(); + boolean success = scheduler.schedule(requirements, servicePackage, ACTION_RESTART); + if (!success) { + Log.e(TAG, "Scheduling downloads failed."); + } + } else { + scheduler.cancel(); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java new file mode 100644 index 0000000000..894d908e72 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/Downloader.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.IOException; + +/** Downloads and removes a piece of content. */ +public interface Downloader { + + /** Receives progress updates during download operations. */ + interface ProgressListener { + + /** + * Called when progress is made during a download operation. + * + * @param contentLength The length of the content in bytes, or {@link C#LENGTH_UNSET} if + * unknown. + * @param bytesDownloaded The number of bytes that have been downloaded. + * @param percentDownloaded The percentage of the content that has been downloaded, or {@link + * C#PERCENTAGE_UNSET}. + */ + void onProgress(long contentLength, long bytesDownloaded, float percentDownloaded); + } + + /** + * Downloads the content. + * + * @param progressListener A listener to receive progress updates, or {@code null}. + * @throws DownloadException Thrown if the content cannot be downloaded. + * @throws InterruptedException If the thread has been interrupted. + * @throws IOException Thrown when there is an io error while downloading. + */ + void download(@Nullable ProgressListener progressListener) + throws InterruptedException, IOException; + + /** Cancels the download operation and prevents future download operations from running. */ + void cancel(); + + /** + * Removes the content. + * + * @throws InterruptedException Thrown if the thread was interrupted. + */ + void remove() throws InterruptedException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java new file mode 100644 index 0000000000..5b2f579868 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderConstructorHelper.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DummyDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.FileDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.PriorityDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSinkFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; + +/** A helper class that holds necessary parameters for {@link Downloader} construction. */ +public final class DownloaderConstructorHelper { + + private final Cache cache; + @Nullable private final CacheKeyFactory cacheKeyFactory; + @Nullable private final PriorityTaskManager priorityTaskManager; + private final CacheDataSourceFactory onlineCacheDataSourceFactory; + private final CacheDataSourceFactory offlineCacheDataSourceFactory; + + /** + * @param cache Cache instance to be used to store downloaded data. + * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for + * downloading data. + */ + public DownloaderConstructorHelper(Cache cache, DataSource.Factory upstreamFactory) { + this( + cache, + upstreamFactory, + /* cacheReadDataSourceFactory= */ null, + /* cacheWriteDataSinkFactory= */ null, + /* priorityTaskManager= */ null); + } + + /** + * @param cache Cache instance to be used to store downloaded data. + * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for + * downloading data. + * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s + * for reading data from the cache. If null then a {@link FileDataSource.Factory} will be + * used. + * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s + * for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used. + * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null, + * downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst + * downloading. + */ + public DownloaderConstructorHelper( + Cache cache, + DataSource.Factory upstreamFactory, + @Nullable DataSource.Factory cacheReadDataSourceFactory, + @Nullable DataSink.Factory cacheWriteDataSinkFactory, + @Nullable PriorityTaskManager priorityTaskManager) { + this( + cache, + upstreamFactory, + cacheReadDataSourceFactory, + cacheWriteDataSinkFactory, + priorityTaskManager, + /* cacheKeyFactory= */ null); + } + + /** + * @param cache Cache instance to be used to store downloaded data. + * @param upstreamFactory A {@link DataSource.Factory} for creating {@link DataSource}s for + * downloading data. + * @param cacheReadDataSourceFactory A {@link DataSource.Factory} for creating {@link DataSource}s + * for reading data from the cache. If null then a {@link FileDataSource.Factory} will be + * used. + * @param cacheWriteDataSinkFactory A {@link DataSink.Factory} for creating {@link DataSource}s + * for writing data to the cache. If null then a {@link CacheDataSinkFactory} will be used. + * @param priorityTaskManager A {@link PriorityTaskManager} to use when downloading. If non-null, + * downloaders will register as tasks with priority {@link C#PRIORITY_DOWNLOAD} whilst + * downloading. + * @param cacheKeyFactory An optional factory for cache keys. + */ + public DownloaderConstructorHelper( + Cache cache, + DataSource.Factory upstreamFactory, + @Nullable DataSource.Factory cacheReadDataSourceFactory, + @Nullable DataSink.Factory cacheWriteDataSinkFactory, + @Nullable PriorityTaskManager priorityTaskManager, + @Nullable CacheKeyFactory cacheKeyFactory) { + if (priorityTaskManager != null) { + upstreamFactory = + new PriorityDataSourceFactory(upstreamFactory, priorityTaskManager, C.PRIORITY_DOWNLOAD); + } + DataSource.Factory readDataSourceFactory = + cacheReadDataSourceFactory != null + ? cacheReadDataSourceFactory + : new FileDataSource.Factory(); + if (cacheWriteDataSinkFactory == null) { + cacheWriteDataSinkFactory = + new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE); + } + onlineCacheDataSourceFactory = + new CacheDataSourceFactory( + cache, + upstreamFactory, + readDataSourceFactory, + cacheWriteDataSinkFactory, + CacheDataSource.FLAG_BLOCK_ON_CACHE, + /* eventListener= */ null, + cacheKeyFactory); + offlineCacheDataSourceFactory = + new CacheDataSourceFactory( + cache, + DummyDataSource.FACTORY, + readDataSourceFactory, + null, + CacheDataSource.FLAG_BLOCK_ON_CACHE, + /* eventListener= */ null, + cacheKeyFactory); + this.cache = cache; + this.priorityTaskManager = priorityTaskManager; + this.cacheKeyFactory = cacheKeyFactory; + } + + /** Returns the {@link Cache} instance. */ + public Cache getCache() { + return cache; + } + + /** Returns the {@link CacheKeyFactory}. */ + public CacheKeyFactory getCacheKeyFactory() { + return cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY; + } + + /** Returns a {@link PriorityTaskManager} instance. */ + public PriorityTaskManager getPriorityTaskManager() { + // Return a dummy PriorityTaskManager if none is provided. Create a new PriorityTaskManager + // each time so clients don't affect each other over the dummy PriorityTaskManager instance. + return priorityTaskManager != null ? priorityTaskManager : new PriorityTaskManager(); + } + + /** Returns a new {@link CacheDataSource} instance. */ + public CacheDataSource createCacheDataSource() { + return onlineCacheDataSourceFactory.createDataSource(); + } + + /** + * Returns a new {@link CacheDataSource} instance which accesses cache read-only and throws an + * exception on cache miss. + */ + public CacheDataSource createOfflineCacheDataSource() { + return offlineCacheDataSourceFactory.createDataSource(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java new file mode 100644 index 0000000000..944f55f161 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/DownloaderFactory.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +/** Creates {@link Downloader Downloaders} for given {@link DownloadRequest DownloadRequests}. */ +public interface DownloaderFactory { + + /** + * Creates a {@link Downloader} to perform the given {@link DownloadRequest}. + * + * @param action The action. + * @return The downloader. + */ + Downloader createDownloader(DownloadRequest action); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java new file mode 100644 index 0000000000..1bd32f7d45 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilterableManifest.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import java.util.List; + +/** + * A manifest that can generate copies of itself including only the streams specified by the given + * keys. + * + * @param <T> The manifest type. + */ +public interface FilterableManifest<T> { + + /** + * Returns a copy of the manifest including only the streams specified by the given keys. If the + * manifest is unchanged then the instance may return itself. + * + * @param streamKeys A non-empty list of stream keys. + * @return The filtered manifest. + */ + T copy(List<StreamKey> streamKeys); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java new file mode 100644 index 0000000000..a34d749039 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/FilteringManifestParser.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable.Parser; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * A manifest parser that includes only the streams identified by the given stream keys. + * + * @param <T> The {@link FilterableManifest} type. + */ +public final class FilteringManifestParser<T extends FilterableManifest<T>> implements Parser<T> { + + private final Parser<? extends T> parser; + @Nullable private final List<StreamKey> streamKeys; + + /** + * @param parser A parser for the manifest that will be filtered. + * @param streamKeys The stream keys. If null or empty then filtering will not occur. + */ + public FilteringManifestParser(Parser<? extends T> parser, @Nullable List<StreamKey> streamKeys) { + this.parser = parser; + this.streamKeys = streamKeys; + } + + @Override + public T parse(Uri uri, InputStream inputStream) throws IOException { + T manifest = parser.parse(uri, inputStream); + return streamKeys == null || streamKeys.isEmpty() ? manifest : manifest.copy(streamKeys); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java new file mode 100644 index 0000000000..7437dab5ca --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/ProgressiveDownloader.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * A downloader for progressive media streams. + * + * <p>The downloader attempts to download the entire media bytes referenced by a {@link Uri} into a + * cache as defined by {@link DownloaderConstructorHelper}. Callers can use the constructor to + * specify a custom cache key for the downloaded bytes. + * + * <p>The downloader will avoid downloading already-downloaded media bytes. + */ +public final class ProgressiveDownloader implements Downloader { + + private static final int BUFFER_SIZE_BYTES = 128 * 1024; + + private final DataSpec dataSpec; + private final Cache cache; + private final CacheDataSource dataSource; + private final CacheKeyFactory cacheKeyFactory; + private final PriorityTaskManager priorityTaskManager; + private final AtomicBoolean isCanceled; + + /** + * @param uri Uri of the data to be downloaded. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + */ + public ProgressiveDownloader( + Uri uri, @Nullable String customCacheKey, DownloaderConstructorHelper constructorHelper) { + this.dataSpec = + new DataSpec( + uri, + /* absoluteStreamPosition= */ 0, + C.LENGTH_UNSET, + customCacheKey, + /* flags= */ DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION); + this.cache = constructorHelper.getCache(); + this.dataSource = constructorHelper.createCacheDataSource(); + this.cacheKeyFactory = constructorHelper.getCacheKeyFactory(); + this.priorityTaskManager = constructorHelper.getPriorityTaskManager(); + isCanceled = new AtomicBoolean(); + } + + @Override + public void download(@Nullable ProgressListener progressListener) + throws InterruptedException, IOException { + priorityTaskManager.add(C.PRIORITY_DOWNLOAD); + try { + CacheUtil.cache( + dataSpec, + cache, + cacheKeyFactory, + dataSource, + new byte[BUFFER_SIZE_BYTES], + priorityTaskManager, + C.PRIORITY_DOWNLOAD, + progressListener == null ? null : new ProgressForwarder(progressListener), + isCanceled, + /* enableEOFException= */ true); + } finally { + priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); + } + } + + @Override + public void cancel() { + isCanceled.set(true); + } + + @Override + public void remove() { + CacheUtil.remove(dataSpec, cache, cacheKeyFactory); + } + + private static final class ProgressForwarder implements CacheUtil.ProgressListener { + + private final ProgressListener progessListener; + + public ProgressForwarder(ProgressListener progressListener) { + this.progessListener = progressListener; + } + + @Override + public void onProgress(long contentLength, long bytesCached, long newBytesCached) { + float percentDownloaded = + contentLength == C.LENGTH_UNSET || contentLength == 0 + ? C.PERCENTAGE_UNSET + : ((bytesCached * 100f) / contentLength); + progessListener.onProgress(contentLength, bytesCached, percentDownloaded); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java new file mode 100644 index 0000000000..92947b9bc9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/SegmentDownloader.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.net.Uri; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheKeyFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.CacheUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Base class for multi segment stream downloaders. + * + * @param <M> The type of the manifest object. + */ +public abstract class SegmentDownloader<M extends FilterableManifest<M>> implements Downloader { + + /** Smallest unit of content to be downloaded. */ + protected static class Segment implements Comparable<Segment> { + + /** The start time of the segment in microseconds. */ + public final long startTimeUs; + + /** The {@link DataSpec} of the segment. */ + public final DataSpec dataSpec; + + /** Constructs a Segment. */ + public Segment(long startTimeUs, DataSpec dataSpec) { + this.startTimeUs = startTimeUs; + this.dataSpec = dataSpec; + } + + @Override + public int compareTo(Segment other) { + return Util.compareLong(startTimeUs, other.startTimeUs); + } + } + + private static final int BUFFER_SIZE_BYTES = 128 * 1024; + + private final DataSpec manifestDataSpec; + private final Cache cache; + private final CacheDataSource dataSource; + private final CacheDataSource offlineDataSource; + private final CacheKeyFactory cacheKeyFactory; + private final PriorityTaskManager priorityTaskManager; + private final ArrayList<StreamKey> streamKeys; + private final AtomicBoolean isCanceled; + + /** + * @param manifestUri The {@link Uri} of the manifest to be downloaded. + * @param streamKeys Keys defining which streams in the manifest should be selected for download. + * If empty, all streams are downloaded. + * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + */ + public SegmentDownloader( + Uri manifestUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) { + this.manifestDataSpec = getCompressibleDataSpec(manifestUri); + this.streamKeys = new ArrayList<>(streamKeys); + this.cache = constructorHelper.getCache(); + this.dataSource = constructorHelper.createCacheDataSource(); + this.offlineDataSource = constructorHelper.createOfflineCacheDataSource(); + this.cacheKeyFactory = constructorHelper.getCacheKeyFactory(); + this.priorityTaskManager = constructorHelper.getPriorityTaskManager(); + isCanceled = new AtomicBoolean(); + } + + /** + * Downloads the selected streams in the media. If multiple streams are selected, they are + * downloaded in sync with one another. + * + * @throws IOException Thrown when there is an error downloading. + * @throws InterruptedException If the thread has been interrupted. + */ + @Override + public final void download(@Nullable ProgressListener progressListener) + throws IOException, InterruptedException { + priorityTaskManager.add(C.PRIORITY_DOWNLOAD); + try { + // Get the manifest and all of the segments. + M manifest = getManifest(dataSource, manifestDataSpec); + if (!streamKeys.isEmpty()) { + manifest = manifest.copy(streamKeys); + } + List<Segment> segments = getSegments(dataSource, manifest, /* allowIncompleteList= */ false); + + // Scan the segments, removing any that are fully downloaded. + int totalSegments = segments.size(); + int segmentsDownloaded = 0; + long contentLength = 0; + long bytesDownloaded = 0; + for (int i = segments.size() - 1; i >= 0; i--) { + Segment segment = segments.get(i); + Pair<Long, Long> segmentLengthAndBytesDownloaded = + CacheUtil.getCached(segment.dataSpec, cache, cacheKeyFactory); + long segmentLength = segmentLengthAndBytesDownloaded.first; + long segmentBytesDownloaded = segmentLengthAndBytesDownloaded.second; + bytesDownloaded += segmentBytesDownloaded; + if (segmentLength != C.LENGTH_UNSET) { + if (segmentLength == segmentBytesDownloaded) { + // The segment is fully downloaded. + segmentsDownloaded++; + segments.remove(i); + } + if (contentLength != C.LENGTH_UNSET) { + contentLength += segmentLength; + } + } else { + contentLength = C.LENGTH_UNSET; + } + } + Collections.sort(segments); + + // Download the segments. + @Nullable ProgressNotifier progressNotifier = null; + if (progressListener != null) { + progressNotifier = + new ProgressNotifier( + progressListener, + contentLength, + totalSegments, + bytesDownloaded, + segmentsDownloaded); + } + byte[] buffer = new byte[BUFFER_SIZE_BYTES]; + for (int i = 0; i < segments.size(); i++) { + CacheUtil.cache( + segments.get(i).dataSpec, + cache, + cacheKeyFactory, + dataSource, + buffer, + priorityTaskManager, + C.PRIORITY_DOWNLOAD, + progressNotifier, + isCanceled, + true); + if (progressNotifier != null) { + progressNotifier.onSegmentDownloaded(); + } + } + } finally { + priorityTaskManager.remove(C.PRIORITY_DOWNLOAD); + } + } + + @Override + public void cancel() { + isCanceled.set(true); + } + + @Override + public final void remove() throws InterruptedException { + try { + M manifest = getManifest(offlineDataSource, manifestDataSpec); + List<Segment> segments = getSegments(offlineDataSource, manifest, true); + for (int i = 0; i < segments.size(); i++) { + removeDataSpec(segments.get(i).dataSpec); + } + } catch (IOException e) { + // Ignore exceptions when removing. + } finally { + // Always attempt to remove the manifest. + removeDataSpec(manifestDataSpec); + } + } + + // Internal methods. + + /** + * Loads and parses the manifest. + * + * @param dataSource The {@link DataSource} through which to load. + * @param dataSpec The manifest {@link DataSpec}. + * @return The manifest. + * @throws IOException If an error occurs reading data. + */ + protected abstract M getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException; + + /** + * Returns a list of all downloadable {@link Segment}s for a given manifest. + * + * @param dataSource The {@link DataSource} through which to load any required data. + * @param manifest The manifest containing the segments. + * @param allowIncompleteList Whether to continue in the case that a load error prevents all + * segments from being listed. If true then a partial segment list will be returned. If false + * an {@link IOException} will be thrown. + * @return The list of downloadable {@link Segment}s. + * @throws InterruptedException Thrown if the thread was interrupted. + * @throws IOException Thrown if {@code allowPartialIndex} is false and a load error occurs, or if + * the media is not in a form that allows for its segments to be listed. + */ + protected abstract List<Segment> getSegments( + DataSource dataSource, M manifest, boolean allowIncompleteList) + throws InterruptedException, IOException; + + private void removeDataSpec(DataSpec dataSpec) { + CacheUtil.remove(dataSpec, cache, cacheKeyFactory); + } + + protected static DataSpec getCompressibleDataSpec(Uri uri) { + return new DataSpec( + uri, + /* absoluteStreamPosition= */ 0, + /* length= */ C.LENGTH_UNSET, + /* key= */ null, + /* flags= */ DataSpec.FLAG_ALLOW_GZIP); + } + + private static final class ProgressNotifier implements CacheUtil.ProgressListener { + + private final ProgressListener progressListener; + + private final long contentLength; + private final int totalSegments; + + private long bytesDownloaded; + private int segmentsDownloaded; + + public ProgressNotifier( + ProgressListener progressListener, + long contentLength, + int totalSegments, + long bytesDownloaded, + int segmentsDownloaded) { + this.progressListener = progressListener; + this.contentLength = contentLength; + this.totalSegments = totalSegments; + this.bytesDownloaded = bytesDownloaded; + this.segmentsDownloaded = segmentsDownloaded; + } + + @Override + public void onProgress(long requestLength, long bytesCached, long newBytesCached) { + bytesDownloaded += newBytesCached; + progressListener.onProgress(contentLength, bytesDownloaded, getPercentDownloaded()); + } + + public void onSegmentDownloaded() { + segmentsDownloaded++; + progressListener.onProgress(contentLength, bytesDownloaded, getPercentDownloaded()); + } + + private float getPercentDownloaded() { + if (contentLength != C.LENGTH_UNSET && contentLength != 0) { + return (bytesDownloaded * 100f) / contentLength; + } else if (totalSegments != 0) { + return (segmentsDownloaded * 100f) / totalSegments; + } else { + return C.PERCENTAGE_UNSET; + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java new file mode 100644 index 0000000000..acbcc9afa4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/StreamKey.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; + +/** + * A key for a subset of media which can be separately loaded (a "stream"). + * + * <p>The stream key consists of a period index, a group index within the period and a track index + * within the group. The interpretation of these indices depends on the type of media for which the + * stream key is used. + */ +public final class StreamKey implements Comparable<StreamKey>, Parcelable { + + /** The period index. */ + public final int periodIndex; + /** The group index. */ + public final int groupIndex; + /** The track index. */ + public final int trackIndex; + + /** + * @param groupIndex The group index. + * @param trackIndex The track index. + */ + public StreamKey(int groupIndex, int trackIndex) { + this(0, groupIndex, trackIndex); + } + + /** + * @param periodIndex The period index. + * @param groupIndex The group index. + * @param trackIndex The track index. + */ + public StreamKey(int periodIndex, int groupIndex, int trackIndex) { + this.periodIndex = periodIndex; + this.groupIndex = groupIndex; + this.trackIndex = trackIndex; + } + + /* package */ StreamKey(Parcel in) { + periodIndex = in.readInt(); + groupIndex = in.readInt(); + trackIndex = in.readInt(); + } + + @Override + public String toString() { + return periodIndex + "." + groupIndex + "." + trackIndex; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + StreamKey that = (StreamKey) o; + return periodIndex == that.periodIndex + && groupIndex == that.groupIndex + && trackIndex == that.trackIndex; + } + + @Override + public int hashCode() { + int result = periodIndex; + result = 31 * result + groupIndex; + result = 31 * result + trackIndex; + return result; + } + + // Comparable implementation. + + @Override + public int compareTo(StreamKey o) { + int result = periodIndex - o.periodIndex; + if (result == 0) { + result = groupIndex - o.groupIndex; + if (result == 0) { + result = trackIndex - o.trackIndex; + } + } + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(periodIndex); + dest.writeInt(groupIndex); + dest.writeInt(trackIndex); + } + + public static final Parcelable.Creator<StreamKey> CREATOR = + new Parcelable.Creator<StreamKey>() { + + @Override + public StreamKey createFromParcel(Parcel in) { + return new StreamKey(in); + } + + @Override + public StreamKey[] newArray(int size) { + return new StreamKey[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java new file mode 100644 index 0000000000..f57619f0c4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/WritableDownloadIndex.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import androidx.annotation.WorkerThread; +import java.io.IOException; + +/** A writable index of {@link Download Downloads}. */ +@WorkerThread +public interface WritableDownloadIndex extends DownloadIndex { + + /** + * Adds or replaces a {@link Download}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param download The {@link Download} to be added. + * @throws IOException If an error occurs setting the state. + */ + void putDownload(Download download) throws IOException; + + /** + * Removes the download with the given ID. Does nothing if a download with the given ID does not + * exist. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param id The ID of the download to remove. + * @throws IOException If an error occurs removing the state. + */ + void removeDownload(String id) throws IOException; + + /** + * Sets all {@link Download#STATE_DOWNLOADING} states to {@link Download#STATE_QUEUED}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @throws IOException If an error occurs updating the state. + */ + void setDownloadingStatesToQueued() throws IOException; + + /** + * Sets all states to {@link Download#STATE_REMOVING}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @throws IOException If an error occurs updating the state. + */ + void setStatesToRemoving() throws IOException; + + /** + * Sets the stop reason of the downloads in a terminal state ({@link Download#STATE_COMPLETED}, + * {@link Download#STATE_FAILED}). + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param stopReason The stop reason. + * @throws IOException If an error occurs updating the state. + */ + void setStopReason(int stopReason) throws IOException; + + /** + * Sets the stop reason of the download with the given ID in a terminal state ({@link + * Download#STATE_COMPLETED}, {@link Download#STATE_FAILED}). Does nothing if a download with the + * given ID does not exist, or if it's not in a terminal state. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param id The ID of the download to update. + * @param stopReason The stop reason. + * @throws IOException If an error occurs updating the state. + */ + void setStopReason(String id, int stopReason) throws IOException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java new file mode 100644 index 0000000000..a353e22107 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/offline/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.offline; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/package-info.java new file mode 100644 index 0000000000..d9cb1c1493 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/PlatformScheduler.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/PlatformScheduler.java new file mode 100644 index 0000000000..bb866944d4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/PlatformScheduler.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler; + +import android.annotation.TargetApi; +import android.app.job.JobInfo; +import android.app.job.JobParameters; +import android.app.job.JobScheduler; +import android.app.job.JobService; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.os.PersistableBundle; +import androidx.annotation.RequiresPermission; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * A {@link Scheduler} that uses {@link JobScheduler}. To use this scheduler, you must add {@link + * PlatformSchedulerService} to your manifest: + * + * <pre>{@literal + * <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> + * <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> + * + * <service android:name="com.google.android.exoplayer2.scheduler.PlatformScheduler$PlatformSchedulerService" + * android:permission="android.permission.BIND_JOB_SERVICE" + * android:exported="true"/> + * }</pre> + */ +@TargetApi(21) +public final class PlatformScheduler implements Scheduler { + + private static final boolean DEBUG = false; + private static final String TAG = "PlatformScheduler"; + private static final String KEY_SERVICE_ACTION = "service_action"; + private static final String KEY_SERVICE_PACKAGE = "service_package"; + private static final String KEY_REQUIREMENTS = "requirements"; + + private final int jobId; + private final ComponentName jobServiceComponentName; + private final JobScheduler jobScheduler; + + /** + * @param context Any context. + * @param jobId An identifier for the jobs scheduled by this instance. If the same identifier was + * used by a previous instance, anything scheduled by the previous instance will be canceled + * by this instance if {@link #schedule(Requirements, String, String)} or {@link #cancel()} + * are called. + */ + @RequiresPermission(android.Manifest.permission.RECEIVE_BOOT_COMPLETED) + public PlatformScheduler(Context context, int jobId) { + context = context.getApplicationContext(); + this.jobId = jobId; + jobServiceComponentName = new ComponentName(context, PlatformSchedulerService.class); + jobScheduler = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE); + } + + @Override + public boolean schedule(Requirements requirements, String servicePackage, String serviceAction) { + JobInfo jobInfo = + buildJobInfo(jobId, jobServiceComponentName, requirements, serviceAction, servicePackage); + int result = jobScheduler.schedule(jobInfo); + logd("Scheduling job: " + jobId + " result: " + result); + return result == JobScheduler.RESULT_SUCCESS; + } + + @Override + public boolean cancel() { + logd("Canceling job: " + jobId); + jobScheduler.cancel(jobId); + return true; + } + + // @RequiresPermission constructor annotation should ensure the permission is present. + @SuppressWarnings("MissingPermission") + private static JobInfo buildJobInfo( + int jobId, + ComponentName jobServiceComponentName, + Requirements requirements, + String serviceAction, + String servicePackage) { + JobInfo.Builder builder = new JobInfo.Builder(jobId, jobServiceComponentName); + + if (requirements.isUnmeteredNetworkRequired()) { + builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED); + } else if (requirements.isNetworkRequired()) { + builder.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresDeviceIdle(requirements.isIdleRequired()); + builder.setRequiresCharging(requirements.isChargingRequired()); + builder.setPersisted(true); + + PersistableBundle extras = new PersistableBundle(); + extras.putString(KEY_SERVICE_ACTION, serviceAction); + extras.putString(KEY_SERVICE_PACKAGE, servicePackage); + extras.putInt(KEY_REQUIREMENTS, requirements.getRequirements()); + builder.setExtras(extras); + + return builder.build(); + } + + private static void logd(String message) { + if (DEBUG) { + Log.d(TAG, message); + } + } + + /** A {@link JobService} that starts the target service if the requirements are met. */ + public static final class PlatformSchedulerService extends JobService { + @Override + public boolean onStartJob(JobParameters params) { + logd("PlatformSchedulerService started"); + PersistableBundle extras = params.getExtras(); + Requirements requirements = new Requirements(extras.getInt(KEY_REQUIREMENTS)); + if (requirements.checkRequirements(this)) { + logd("Requirements are met"); + String serviceAction = extras.getString(KEY_SERVICE_ACTION); + String servicePackage = extras.getString(KEY_SERVICE_PACKAGE); + Intent intent = + new Intent(Assertions.checkNotNull(serviceAction)).setPackage(servicePackage); + logd("Starting service action: " + serviceAction + " package: " + servicePackage); + Util.startForegroundService(this, intent); + } else { + logd("Requirements are not met"); + jobFinished(params, /* needsReschedule */ true); + } + return false; + } + + @Override + public boolean onStopJob(JobParameters params) { + return false; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/Requirements.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/Requirements.java new file mode 100644 index 0000000000..9ef8fdb3f6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/Requirements.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.os.BatteryManager; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.PowerManager; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Defines a set of device state requirements. */ +public final class Requirements implements Parcelable { + + /** + * Requirement flags. Possible flag values are {@link #NETWORK}, {@link #NETWORK_UNMETERED}, + * {@link #DEVICE_IDLE} and {@link #DEVICE_CHARGING}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {NETWORK, NETWORK_UNMETERED, DEVICE_IDLE, DEVICE_CHARGING}) + public @interface RequirementFlags {} + + /** Requirement that the device has network connectivity. */ + public static final int NETWORK = 1; + /** Requirement that the device has a network connection that is unmetered. */ + public static final int NETWORK_UNMETERED = 1 << 1; + /** Requirement that the device is idle. */ + public static final int DEVICE_IDLE = 1 << 2; + /** Requirement that the device is charging. */ + public static final int DEVICE_CHARGING = 1 << 3; + + @RequirementFlags private final int requirements; + + /** @param requirements A combination of requirement flags. */ + public Requirements(@RequirementFlags int requirements) { + if ((requirements & NETWORK_UNMETERED) != 0) { + // Make sure network requirement flags are consistent. + requirements |= NETWORK; + } + this.requirements = requirements; + } + + /** Returns the requirements. */ + @RequirementFlags + public int getRequirements() { + return requirements; + } + + /** Returns whether network connectivity is required. */ + public boolean isNetworkRequired() { + return (requirements & NETWORK) != 0; + } + + /** Returns whether un-metered network connectivity is required. */ + public boolean isUnmeteredNetworkRequired() { + return (requirements & NETWORK_UNMETERED) != 0; + } + + /** Returns whether the device is required to be charging. */ + public boolean isChargingRequired() { + return (requirements & DEVICE_CHARGING) != 0; + } + + /** Returns whether the device is required to be idle. */ + public boolean isIdleRequired() { + return (requirements & DEVICE_IDLE) != 0; + } + + /** + * Returns whether the requirements are met. + * + * @param context Any context. + * @return Whether the requirements are met. + */ + public boolean checkRequirements(Context context) { + return getNotMetRequirements(context) == 0; + } + + /** + * Returns requirements that are not met, or 0. + * + * @param context Any context. + * @return The requirements that are not met, or 0. + */ + @RequirementFlags + public int getNotMetRequirements(Context context) { + @RequirementFlags int notMetRequirements = getNotMetNetworkRequirements(context); + if (isChargingRequired() && !isDeviceCharging(context)) { + notMetRequirements |= DEVICE_CHARGING; + } + if (isIdleRequired() && !isDeviceIdle(context)) { + notMetRequirements |= DEVICE_IDLE; + } + return notMetRequirements; + } + + @RequirementFlags + private int getNotMetNetworkRequirements(Context context) { + if (!isNetworkRequired()) { + return 0; + } + + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo networkInfo = Assertions.checkNotNull(connectivityManager).getActiveNetworkInfo(); + if (networkInfo == null + || !networkInfo.isConnected() + || !isInternetConnectivityValidated(connectivityManager)) { + return requirements & (NETWORK | NETWORK_UNMETERED); + } + + if (isUnmeteredNetworkRequired() && connectivityManager.isActiveNetworkMetered()) { + return NETWORK_UNMETERED; + } + + return 0; + } + + private boolean isDeviceCharging(Context context) { + Intent batteryStatus = + context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED)); + if (batteryStatus == null) { + return false; + } + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + return status == BatteryManager.BATTERY_STATUS_CHARGING + || status == BatteryManager.BATTERY_STATUS_FULL; + } + + private boolean isDeviceIdle(Context context) { + PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + return Util.SDK_INT >= 23 + ? powerManager.isDeviceIdleMode() + : Util.SDK_INT >= 20 ? !powerManager.isInteractive() : !powerManager.isScreenOn(); + } + + private static boolean isInternetConnectivityValidated(ConnectivityManager connectivityManager) { + // It's possible to query NetworkCapabilities from API level 23, but RequirementsWatcher only + // fires an event to update its Requirements when NetworkCapabilities change from API level 24. + // Since Requirements won't be updated, we assume connectivity is validated on API level 23. + if (Util.SDK_INT < 24) { + return true; + } + Network activeNetwork = connectivityManager.getActiveNetwork(); + if (activeNetwork == null) { + return false; + } + NetworkCapabilities networkCapabilities = + connectivityManager.getNetworkCapabilities(activeNetwork); + return networkCapabilities != null + && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return requirements == ((Requirements) o).requirements; + } + + @Override + public int hashCode() { + return requirements; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(requirements); + } + + public static final Parcelable.Creator<Requirements> CREATOR = + new Creator<Requirements>() { + + @Override + public Requirements createFromParcel(Parcel in) { + return new Requirements(in.readInt()); + } + + @Override + public Requirements[] newArray(int size) { + return new Requirements[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java new file mode 100644 index 0000000000..edb860ac05 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/RequirementsWatcher.java @@ -0,0 +1,197 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler; + +import android.annotation.TargetApi; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.os.Handler; +import android.os.Looper; +import android.os.PowerManager; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Watches whether the {@link Requirements} are met and notifies the {@link Listener} on changes. + */ +public final class RequirementsWatcher { + + /** + * Notified when RequirementsWatcher instance first created and on changes whether the {@link + * Requirements} are met. + */ + public interface Listener { + /** + * Called when there is a change on the met requirements. + * + * @param requirementsWatcher Calling instance. + * @param notMetRequirements {@link Requirements.RequirementFlags RequirementFlags} that are not + * met, or 0. + */ + void onRequirementsStateChanged( + RequirementsWatcher requirementsWatcher, + @Requirements.RequirementFlags int notMetRequirements); + } + + private final Context context; + private final Listener listener; + private final Requirements requirements; + private final Handler handler; + + @Nullable private DeviceStatusChangeReceiver receiver; + + @Requirements.RequirementFlags private int notMetRequirements; + @Nullable private NetworkCallback networkCallback; + + /** + * @param context Any context. + * @param listener Notified whether the {@link Requirements} are met. + * @param requirements The requirements to watch. + */ + public RequirementsWatcher(Context context, Listener listener, Requirements requirements) { + this.context = context.getApplicationContext(); + this.listener = listener; + this.requirements = requirements; + handler = new Handler(Util.getLooper()); + } + + /** + * Starts watching for changes. Must be called from a thread that has an associated {@link + * Looper}. Listener methods are called on the caller thread. + * + * @return Initial {@link Requirements.RequirementFlags RequirementFlags} that are not met, or 0. + */ + @Requirements.RequirementFlags + public int start() { + notMetRequirements = requirements.getNotMetRequirements(context); + + IntentFilter filter = new IntentFilter(); + if (requirements.isNetworkRequired()) { + if (Util.SDK_INT >= 24) { + registerNetworkCallbackV24(); + } else { + filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + } + } + if (requirements.isChargingRequired()) { + filter.addAction(Intent.ACTION_POWER_CONNECTED); + filter.addAction(Intent.ACTION_POWER_DISCONNECTED); + } + if (requirements.isIdleRequired()) { + if (Util.SDK_INT >= 23) { + filter.addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED); + } else { + filter.addAction(Intent.ACTION_SCREEN_ON); + filter.addAction(Intent.ACTION_SCREEN_OFF); + } + } + receiver = new DeviceStatusChangeReceiver(); + context.registerReceiver(receiver, filter, null, handler); + return notMetRequirements; + } + + /** Stops watching for changes. */ + public void stop() { + context.unregisterReceiver(Assertions.checkNotNull(receiver)); + receiver = null; + if (Util.SDK_INT >= 24 && networkCallback != null) { + unregisterNetworkCallbackV24(); + } + } + + /** Returns watched {@link Requirements}. */ + public Requirements getRequirements() { + return requirements; + } + + @TargetApi(24) + private void registerNetworkCallbackV24() { + ConnectivityManager connectivityManager = + Assertions.checkNotNull( + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + networkCallback = new NetworkCallback(); + connectivityManager.registerDefaultNetworkCallback(networkCallback); + } + + @TargetApi(24) + private void unregisterNetworkCallbackV24() { + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + connectivityManager.unregisterNetworkCallback(Assertions.checkNotNull(networkCallback)); + networkCallback = null; + } + + private void checkRequirements() { + @Requirements.RequirementFlags + int notMetRequirements = requirements.getNotMetRequirements(context); + if (this.notMetRequirements != notMetRequirements) { + this.notMetRequirements = notMetRequirements; + listener.onRequirementsStateChanged(this, notMetRequirements); + } + } + + private class DeviceStatusChangeReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (!isInitialStickyBroadcast()) { + checkRequirements(); + } + } + } + + @RequiresApi(24) + private final class NetworkCallback extends ConnectivityManager.NetworkCallback { + boolean receivedCapabilitiesChange; + boolean networkValidated; + + @Override + public void onAvailable(Network network) { + onNetworkCallback(); + } + + @Override + public void onLost(Network network) { + onNetworkCallback(); + } + + @Override + public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCapabilities) { + boolean networkValidated = + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED); + if (!receivedCapabilitiesChange || this.networkValidated != networkValidated) { + receivedCapabilitiesChange = true; + this.networkValidated = networkValidated; + onNetworkCallback(); + } + } + + private void onNetworkCallback() { + handler.post( + () -> { + if (networkCallback != null) { + checkRequirements(); + } + }); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/Scheduler.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/Scheduler.java new file mode 100644 index 0000000000..c7a7afcd2d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/Scheduler.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler; + +import android.app.Notification; +import android.app.Service; +import android.content.Intent; + +/** Schedules a service to be started in the foreground when some {@link Requirements} are met. */ +public interface Scheduler { + + /** + * Schedules a service to be started in the foreground when some {@link Requirements} are met. + * Anything that was previously scheduled will be canceled. + * + * <p>The service to be started must be declared in the manifest of {@code servicePackage} with an + * intent filter containing {@code serviceAction}. Note that when started with {@code + * serviceAction}, the service must call {@link Service#startForeground(int, Notification)} to + * make itself a foreground service, as documented by {@link + * Service#startForegroundService(Intent)}. + * + * @param requirements The requirements. + * @param servicePackage The package name. + * @param serviceAction The action with which the service will be started. + * @return Whether scheduling was successful. + */ + boolean schedule(Requirements requirements, String servicePackage, String serviceAction); + + /** + * Cancels anything that was previously scheduled, or else does nothing. + * + * @return Whether cancellation was successful. + */ + boolean cancel(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/package-info.java new file mode 100644 index 0000000000..b4e68ebfff --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/scheduler/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.scheduler; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java new file mode 100644 index 0000000000..1f67f7e645 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AbstractConcatenatedTimeline.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.util.Pair; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** Abstract base class for the concatenation of one or more {@link Timeline}s. */ +/* package */ abstract class AbstractConcatenatedTimeline extends Timeline { + + private final int childCount; + private final ShuffleOrder shuffleOrder; + private final boolean isAtomic; + + /** + * Returns UID of child timeline from a concatenated period UID. + * + * @param concatenatedUid UID of a period in a concatenated timeline. + * @return UID of the child timeline this period belongs to. + */ + @SuppressWarnings("nullness:return.type.incompatible") + public static Object getChildTimelineUidFromConcatenatedUid(Object concatenatedUid) { + return ((Pair<?, ?>) concatenatedUid).first; + } + + /** + * Returns UID of the period in the child timeline from a concatenated period UID. + * + * @param concatenatedUid UID of a period in a concatenated timeline. + * @return UID of the period in the child timeline. + */ + @SuppressWarnings("nullness:return.type.incompatible") + public static Object getChildPeriodUidFromConcatenatedUid(Object concatenatedUid) { + return ((Pair<?, ?>) concatenatedUid).second; + } + + /** + * Returns a concatenated UID for a period or window in a child timeline. + * + * @param childTimelineUid UID of the child timeline this period or window belongs to. + * @param childPeriodOrWindowUid UID of the period or window in the child timeline. + * @return UID of the period or window in the concatenated timeline. + */ + public static Object getConcatenatedUid(Object childTimelineUid, Object childPeriodOrWindowUid) { + return Pair.create(childTimelineUid, childPeriodOrWindowUid); + } + + /** + * Sets up a concatenated timeline with a shuffle order of child timelines. + * + * @param isAtomic Whether the child timelines shall be treated as atomic, i.e., treated as a + * single item for repeating and shuffling. + * @param shuffleOrder A shuffle order of child timelines. The number of child timelines must + * match the number of elements in the shuffle order. + */ + public AbstractConcatenatedTimeline(boolean isAtomic, ShuffleOrder shuffleOrder) { + this.isAtomic = isAtomic; + this.shuffleOrder = shuffleOrder; + this.childCount = shuffleOrder.getLength(); + } + + @Override + public int getNextWindowIndex( + int windowIndex, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled) { + if (isAtomic) { + // Adapt repeat and shuffle mode to atomic concatenation. + repeatMode = repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_ALL : repeatMode; + shuffleModeEnabled = false; + } + // Find next window within current child. + int childIndex = getChildIndexByWindowIndex(windowIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int nextWindowIndexInChild = + getTimelineByChildIndex(childIndex) + .getNextWindowIndex( + windowIndex - firstWindowIndexInChild, + repeatMode == Player.REPEAT_MODE_ALL ? Player.REPEAT_MODE_OFF : repeatMode, + shuffleModeEnabled); + if (nextWindowIndexInChild != C.INDEX_UNSET) { + return firstWindowIndexInChild + nextWindowIndexInChild; + } + // If not found, find first window of next non-empty child. + int nextChildIndex = getNextChildIndex(childIndex, shuffleModeEnabled); + while (nextChildIndex != C.INDEX_UNSET && getTimelineByChildIndex(nextChildIndex).isEmpty()) { + nextChildIndex = getNextChildIndex(nextChildIndex, shuffleModeEnabled); + } + if (nextChildIndex != C.INDEX_UNSET) { + return getFirstWindowIndexByChildIndex(nextChildIndex) + + getTimelineByChildIndex(nextChildIndex).getFirstWindowIndex(shuffleModeEnabled); + } + // If not found, this is the last window. + if (repeatMode == Player.REPEAT_MODE_ALL) { + return getFirstWindowIndex(shuffleModeEnabled); + } + return C.INDEX_UNSET; + } + + @Override + public int getPreviousWindowIndex( + int windowIndex, @Player.RepeatMode int repeatMode, boolean shuffleModeEnabled) { + if (isAtomic) { + // Adapt repeat and shuffle mode to atomic concatenation. + repeatMode = repeatMode == Player.REPEAT_MODE_ONE ? Player.REPEAT_MODE_ALL : repeatMode; + shuffleModeEnabled = false; + } + // Find previous window within current child. + int childIndex = getChildIndexByWindowIndex(windowIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int previousWindowIndexInChild = + getTimelineByChildIndex(childIndex) + .getPreviousWindowIndex( + windowIndex - firstWindowIndexInChild, + repeatMode == Player.REPEAT_MODE_ALL ? Player.REPEAT_MODE_OFF : repeatMode, + shuffleModeEnabled); + if (previousWindowIndexInChild != C.INDEX_UNSET) { + return firstWindowIndexInChild + previousWindowIndexInChild; + } + // If not found, find last window of previous non-empty child. + int previousChildIndex = getPreviousChildIndex(childIndex, shuffleModeEnabled); + while (previousChildIndex != C.INDEX_UNSET + && getTimelineByChildIndex(previousChildIndex).isEmpty()) { + previousChildIndex = getPreviousChildIndex(previousChildIndex, shuffleModeEnabled); + } + if (previousChildIndex != C.INDEX_UNSET) { + return getFirstWindowIndexByChildIndex(previousChildIndex) + + getTimelineByChildIndex(previousChildIndex).getLastWindowIndex(shuffleModeEnabled); + } + // If not found, this is the first window. + if (repeatMode == Player.REPEAT_MODE_ALL) { + return getLastWindowIndex(shuffleModeEnabled); + } + return C.INDEX_UNSET; + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + if (childCount == 0) { + return C.INDEX_UNSET; + } + if (isAtomic) { + shuffleModeEnabled = false; + } + // Find last non-empty child. + int lastChildIndex = shuffleModeEnabled ? shuffleOrder.getLastIndex() : childCount - 1; + while (getTimelineByChildIndex(lastChildIndex).isEmpty()) { + lastChildIndex = getPreviousChildIndex(lastChildIndex, shuffleModeEnabled); + if (lastChildIndex == C.INDEX_UNSET) { + // All children are empty. + return C.INDEX_UNSET; + } + } + return getFirstWindowIndexByChildIndex(lastChildIndex) + + getTimelineByChildIndex(lastChildIndex).getLastWindowIndex(shuffleModeEnabled); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + if (childCount == 0) { + return C.INDEX_UNSET; + } + if (isAtomic) { + shuffleModeEnabled = false; + } + // Find first non-empty child. + int firstChildIndex = shuffleModeEnabled ? shuffleOrder.getFirstIndex() : 0; + while (getTimelineByChildIndex(firstChildIndex).isEmpty()) { + firstChildIndex = getNextChildIndex(firstChildIndex, shuffleModeEnabled); + if (firstChildIndex == C.INDEX_UNSET) { + // All children are empty. + return C.INDEX_UNSET; + } + } + return getFirstWindowIndexByChildIndex(firstChildIndex) + + getTimelineByChildIndex(firstChildIndex).getFirstWindowIndex(shuffleModeEnabled); + } + + @Override + public final Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + int childIndex = getChildIndexByWindowIndex(windowIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int firstPeriodIndexInChild = getFirstPeriodIndexByChildIndex(childIndex); + getTimelineByChildIndex(childIndex) + .getWindow(windowIndex - firstWindowIndexInChild, window, defaultPositionProjectionUs); + Object childUid = getChildUidByChildIndex(childIndex); + // Don't create new objects if the child is using SINGLE_WINDOW_UID. + window.uid = + Window.SINGLE_WINDOW_UID.equals(window.uid) + ? childUid + : getConcatenatedUid(childUid, window.uid); + window.firstPeriodIndex += firstPeriodIndexInChild; + window.lastPeriodIndex += firstPeriodIndexInChild; + return window; + } + + @Override + public final Period getPeriodByUid(Object uid, Period period) { + Object childUid = getChildTimelineUidFromConcatenatedUid(uid); + Object periodUid = getChildPeriodUidFromConcatenatedUid(uid); + int childIndex = getChildIndexByChildUid(childUid); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + getTimelineByChildIndex(childIndex).getPeriodByUid(periodUid, period); + period.windowIndex += firstWindowIndexInChild; + period.uid = uid; + return period; + } + + @Override + public final Period getPeriod(int periodIndex, Period period, boolean setIds) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstWindowIndexInChild = getFirstWindowIndexByChildIndex(childIndex); + int firstPeriodIndexInChild = getFirstPeriodIndexByChildIndex(childIndex); + getTimelineByChildIndex(childIndex) + .getPeriod(periodIndex - firstPeriodIndexInChild, period, setIds); + period.windowIndex += firstWindowIndexInChild; + if (setIds) { + period.uid = + getConcatenatedUid( + getChildUidByChildIndex(childIndex), Assertions.checkNotNull(period.uid)); + } + return period; + } + + @Override + public final int getIndexOfPeriod(Object uid) { + if (!(uid instanceof Pair)) { + return C.INDEX_UNSET; + } + Object childUid = getChildTimelineUidFromConcatenatedUid(uid); + Object periodUid = getChildPeriodUidFromConcatenatedUid(uid); + int childIndex = getChildIndexByChildUid(childUid); + if (childIndex == C.INDEX_UNSET) { + return C.INDEX_UNSET; + } + int periodIndexInChild = getTimelineByChildIndex(childIndex).getIndexOfPeriod(periodUid); + return periodIndexInChild == C.INDEX_UNSET + ? C.INDEX_UNSET + : getFirstPeriodIndexByChildIndex(childIndex) + periodIndexInChild; + } + + @Override + public final Object getUidOfPeriod(int periodIndex) { + int childIndex = getChildIndexByPeriodIndex(periodIndex); + int firstPeriodIndexInChild = getFirstPeriodIndexByChildIndex(childIndex); + Object periodUidInChild = + getTimelineByChildIndex(childIndex).getUidOfPeriod(periodIndex - firstPeriodIndexInChild); + return getConcatenatedUid(getChildUidByChildIndex(childIndex), periodUidInChild); + } + + /** + * Returns the index of the child timeline containing the given period index. + * + * @param periodIndex A valid period index within the bounds of the timeline. + */ + protected abstract int getChildIndexByPeriodIndex(int periodIndex); + + /** + * Returns the index of the child timeline containing the given window index. + * + * @param windowIndex A valid window index within the bounds of the timeline. + */ + protected abstract int getChildIndexByWindowIndex(int windowIndex); + + /** + * Returns the index of the child timeline with the given UID or {@link C#INDEX_UNSET} if not + * found. + * + * @param childUid A child UID. + * @return Index of child timeline or {@link C#INDEX_UNSET} if UID was not found. + */ + protected abstract int getChildIndexByChildUid(Object childUid); + + /** + * Returns the child timeline for the child with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract Timeline getTimelineByChildIndex(int childIndex); + + /** + * Returns the first period index belonging to the child timeline with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract int getFirstPeriodIndexByChildIndex(int childIndex); + + /** + * Returns the first window index belonging to the child timeline with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract int getFirstWindowIndexByChildIndex(int childIndex); + + /** + * Returns the UID of the child timeline with the given index. + * + * @param childIndex A valid child index within the bounds of the timeline. + */ + protected abstract Object getChildUidByChildIndex(int childIndex); + + private int getNextChildIndex(int childIndex, boolean shuffleModeEnabled) { + return shuffleModeEnabled + ? shuffleOrder.getNextIndex(childIndex) + : childIndex < childCount - 1 ? childIndex + 1 : C.INDEX_UNSET; + } + + private int getPreviousChildIndex(int childIndex, boolean shuffleModeEnabled) { + return shuffleModeEnabled + ? shuffleOrder.getPreviousIndex(childIndex) + : childIndex > 0 ? childIndex - 1 : C.INDEX_UNSET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java new file mode 100644 index 0000000000..dba911f622 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/AdaptiveMediaSourceEventListener.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * Interface for callbacks to be notified of {@link MediaSource} events. + * + * @deprecated Use {@link MediaSourceEventListener}. + */ +@Deprecated +public interface AdaptiveMediaSourceEventListener extends MediaSourceEventListener {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BaseMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BaseMediaSource.java new file mode 100644 index 0000000000..f9ca6ff311 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BaseMediaSource.java @@ -0,0 +1,191 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayList; +import java.util.HashSet; + +/** + * Base {@link MediaSource} implementation to handle parallel reuse and to keep a list of {@link + * MediaSourceEventListener}s. + * + * <p>Whenever an implementing subclass needs to provide a new timeline, it must call {@link + * #refreshSourceInfo(Timeline)} to notify all listeners. + */ +public abstract class BaseMediaSource implements MediaSource { + + private final ArrayList<MediaSourceCaller> mediaSourceCallers; + private final HashSet<MediaSourceCaller> enabledMediaSourceCallers; + private final MediaSourceEventListener.EventDispatcher eventDispatcher; + + @Nullable private Looper looper; + @Nullable private Timeline timeline; + + public BaseMediaSource() { + mediaSourceCallers = new ArrayList<>(/* initialCapacity= */ 1); + enabledMediaSourceCallers = new HashSet<>(/* initialCapacity= */ 1); + eventDispatcher = new MediaSourceEventListener.EventDispatcher(); + } + + /** + * Starts source preparation and enables the source, see {@link #prepareSource(MediaSourceCaller, + * TransferListener)}. This method is called at most once until the next call to {@link + * #releaseSourceInternal()}. + * + * @param mediaTransferListener The transfer listener which should be informed of any media data + * transfers. May be null if no listener is available. Note that this listener should usually + * be only informed of transfers related to the media loads and not of auxiliary loads for + * manifests and other data. + */ + protected abstract void prepareSourceInternal(@Nullable TransferListener mediaTransferListener); + + /** Enables the source, see {@link #enable(MediaSourceCaller)}. */ + protected void enableInternal() {} + + /** Disables the source, see {@link #disable(MediaSourceCaller)}. */ + protected void disableInternal() {} + + /** + * Releases the source, see {@link #releaseSource(MediaSourceCaller)}. This method is called + * exactly once after each call to {@link #prepareSourceInternal(TransferListener)}. + */ + protected abstract void releaseSourceInternal(); + + /** + * Updates timeline and manifest and notifies all listeners of the update. + * + * @param timeline The new {@link Timeline}. + */ + protected final void refreshSourceInfo(Timeline timeline) { + this.timeline = timeline; + for (MediaSourceCaller caller : mediaSourceCallers) { + caller.onSourceInfoRefreshed(/* source= */ this, timeline); + } + } + + /** + * Returns a {@link MediaSourceEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified media period id. + * + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. May be null, if + * the events do not belong to a specific media period. + * @return An event dispatcher with pre-configured media period id. + */ + protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( + @Nullable MediaPeriodId mediaPeriodId) { + return eventDispatcher.withParameters( + /* windowIndex= */ 0, mediaPeriodId, /* mediaTimeOffsetMs= */ 0); + } + + /** + * Returns a {@link MediaSourceEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified media period id and time offset. + * + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return An event dispatcher with pre-configured media period id and time offset. + */ + protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( + MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { + Assertions.checkArgument(mediaPeriodId != null); + return eventDispatcher.withParameters(/* windowIndex= */ 0, mediaPeriodId, mediaTimeOffsetMs); + } + + /** + * Returns a {@link MediaSourceEventListener.EventDispatcher} which dispatches all events to the + * registered listeners with the specified window index, media period id and time offset. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. May be null, if + * the events do not belong to a specific media period. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return An event dispatcher with pre-configured media period id and time offset. + */ + protected final MediaSourceEventListener.EventDispatcher createEventDispatcher( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { + return eventDispatcher.withParameters(windowIndex, mediaPeriodId, mediaTimeOffsetMs); + } + + /** Returns whether the source is enabled. */ + protected final boolean isEnabled() { + return !enabledMediaSourceCallers.isEmpty(); + } + + @Override + public final void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + eventDispatcher.addEventListener(handler, eventListener); + } + + @Override + public final void removeEventListener(MediaSourceEventListener eventListener) { + eventDispatcher.removeEventListener(eventListener); + } + + @Override + public final void prepareSource( + MediaSourceCaller caller, @Nullable TransferListener mediaTransferListener) { + Looper looper = Looper.myLooper(); + Assertions.checkArgument(this.looper == null || this.looper == looper); + Timeline timeline = this.timeline; + mediaSourceCallers.add(caller); + if (this.looper == null) { + this.looper = looper; + enabledMediaSourceCallers.add(caller); + prepareSourceInternal(mediaTransferListener); + } else if (timeline != null) { + enable(caller); + caller.onSourceInfoRefreshed(/* source= */ this, timeline); + } + } + + @Override + public final void enable(MediaSourceCaller caller) { + Assertions.checkNotNull(looper); + boolean wasDisabled = enabledMediaSourceCallers.isEmpty(); + enabledMediaSourceCallers.add(caller); + if (wasDisabled) { + enableInternal(); + } + } + + @Override + public final void disable(MediaSourceCaller caller) { + boolean wasEnabled = !enabledMediaSourceCallers.isEmpty(); + enabledMediaSourceCallers.remove(caller); + if (wasEnabled && enabledMediaSourceCallers.isEmpty()) { + disableInternal(); + } + } + + @Override + public final void releaseSource(MediaSourceCaller caller) { + mediaSourceCallers.remove(caller); + if (mediaSourceCallers.isEmpty()) { + looper = null; + timeline = null; + enabledMediaSourceCallers.clear(); + releaseSourceInternal(); + } else { + disable(caller); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BehindLiveWindowException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BehindLiveWindowException.java new file mode 100644 index 0000000000..d5eeeb89a6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/BehindLiveWindowException.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import java.io.IOException; + +/** + * Thrown when a live playback falls behind the available media window. + */ +public final class BehindLiveWindowException extends IOException { + + public BehindLiveWindowException() { + super(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaPeriod.java new file mode 100644 index 0000000000..7467d946cc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaPeriod.java @@ -0,0 +1,345 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Wraps a {@link MediaPeriod} and clips its {@link SampleStream}s to provide a subsequence of their + * samples. + */ +public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + /** + * The {@link MediaPeriod} wrapped by this clipping media period. + */ + public final MediaPeriod mediaPeriod; + + @Nullable private MediaPeriod.Callback callback; + private @NullableType ClippingSampleStream[] sampleStreams; + private long pendingInitialDiscontinuityPositionUs; + /* package */ long startUs; + /* package */ long endUs; + + /** + * Creates a new clipping media period that provides a clipped view of the specified {@link + * MediaPeriod}'s sample streams. + * + * <p>If the start point is guaranteed to be a key frame, pass {@code false} to {@code + * enableInitialPositionDiscontinuity} to suppress an initial discontinuity when the period is + * first read from. + * + * @param mediaPeriod The media period to clip. + * @param enableInitialDiscontinuity Whether the initial discontinuity should be enabled. + * @param startUs The clipping start time, in microseconds. + * @param endUs The clipping end time, in microseconds, or {@link C#TIME_END_OF_SOURCE} to + * indicate the end of the period. + */ + public ClippingMediaPeriod( + MediaPeriod mediaPeriod, boolean enableInitialDiscontinuity, long startUs, long endUs) { + this.mediaPeriod = mediaPeriod; + sampleStreams = new ClippingSampleStream[0]; + pendingInitialDiscontinuityPositionUs = enableInitialDiscontinuity ? startUs : C.TIME_UNSET; + this.startUs = startUs; + this.endUs = endUs; + } + + /** + * Updates the clipping start/end times for this period, in microseconds. + * + * @param startUs The clipping start time, in microseconds. + * @param endUs The clipping end time, in microseconds, or {@link C#TIME_END_OF_SOURCE} to + * indicate the end of the period. + */ + public void updateClipping(long startUs, long endUs) { + this.startUs = startUs; + this.endUs = endUs; + } + + @Override + public void prepare(MediaPeriod.Callback callback, long positionUs) { + this.callback = callback; + mediaPeriod.prepare(this, positionUs); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + mediaPeriod.maybeThrowPrepareError(); + } + + @Override + public TrackGroupArray getTrackGroups() { + return mediaPeriod.getTrackGroups(); + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + sampleStreams = new ClippingSampleStream[streams.length]; + @NullableType SampleStream[] childStreams = new SampleStream[streams.length]; + for (int i = 0; i < streams.length; i++) { + sampleStreams[i] = (ClippingSampleStream) streams[i]; + childStreams[i] = sampleStreams[i] != null ? sampleStreams[i].childStream : null; + } + long enablePositionUs = + mediaPeriod.selectTracks( + selections, mayRetainStreamFlags, childStreams, streamResetFlags, positionUs); + pendingInitialDiscontinuityPositionUs = + isPendingInitialDiscontinuity() + && positionUs == startUs + && shouldKeepInitialDiscontinuity(startUs, selections) + ? enablePositionUs + : C.TIME_UNSET; + Assertions.checkState( + enablePositionUs == positionUs + || (enablePositionUs >= startUs + && (endUs == C.TIME_END_OF_SOURCE || enablePositionUs <= endUs))); + for (int i = 0; i < streams.length; i++) { + if (childStreams[i] == null) { + sampleStreams[i] = null; + } else if (sampleStreams[i] == null || sampleStreams[i].childStream != childStreams[i]) { + sampleStreams[i] = new ClippingSampleStream(childStreams[i]); + } + streams[i] = sampleStreams[i]; + } + return enablePositionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + mediaPeriod.discardBuffer(positionUs, toKeyframe); + } + + @Override + public void reevaluateBuffer(long positionUs) { + mediaPeriod.reevaluateBuffer(positionUs); + } + + @Override + public long readDiscontinuity() { + if (isPendingInitialDiscontinuity()) { + long initialDiscontinuityUs = pendingInitialDiscontinuityPositionUs; + pendingInitialDiscontinuityPositionUs = C.TIME_UNSET; + // Always read an initial discontinuity from the child, and use it if set. + long childDiscontinuityUs = readDiscontinuity(); + return childDiscontinuityUs != C.TIME_UNSET ? childDiscontinuityUs : initialDiscontinuityUs; + } + long discontinuityUs = mediaPeriod.readDiscontinuity(); + if (discontinuityUs == C.TIME_UNSET) { + return C.TIME_UNSET; + } + Assertions.checkState(discontinuityUs >= startUs); + Assertions.checkState(endUs == C.TIME_END_OF_SOURCE || discontinuityUs <= endUs); + return discontinuityUs; + } + + @Override + public long getBufferedPositionUs() { + long bufferedPositionUs = mediaPeriod.getBufferedPositionUs(); + if (bufferedPositionUs == C.TIME_END_OF_SOURCE + || (endUs != C.TIME_END_OF_SOURCE && bufferedPositionUs >= endUs)) { + return C.TIME_END_OF_SOURCE; + } + return bufferedPositionUs; + } + + @Override + public long seekToUs(long positionUs) { + pendingInitialDiscontinuityPositionUs = C.TIME_UNSET; + for (ClippingSampleStream sampleStream : sampleStreams) { + if (sampleStream != null) { + sampleStream.clearSentEos(); + } + } + long seekUs = mediaPeriod.seekToUs(positionUs); + Assertions.checkState( + seekUs == positionUs + || (seekUs >= startUs && (endUs == C.TIME_END_OF_SOURCE || seekUs <= endUs))); + return seekUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + if (positionUs == startUs) { + // Never adjust seeks to the start of the clipped view. + return startUs; + } + SeekParameters clippedSeekParameters = clipSeekParameters(positionUs, seekParameters); + return mediaPeriod.getAdjustedSeekPositionUs(positionUs, clippedSeekParameters); + } + + @Override + public long getNextLoadPositionUs() { + long nextLoadPositionUs = mediaPeriod.getNextLoadPositionUs(); + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE + || (endUs != C.TIME_END_OF_SOURCE && nextLoadPositionUs >= endUs)) { + return C.TIME_END_OF_SOURCE; + } + return nextLoadPositionUs; + } + + @Override + public boolean continueLoading(long positionUs) { + return mediaPeriod.continueLoading(positionUs); + } + + @Override + public boolean isLoading() { + return mediaPeriod.isLoading(); + } + + // MediaPeriod.Callback implementation. + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + Assertions.checkNotNull(callback).onPrepared(this); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + + /* package */ boolean isPendingInitialDiscontinuity() { + return pendingInitialDiscontinuityPositionUs != C.TIME_UNSET; + } + + private SeekParameters clipSeekParameters(long positionUs, SeekParameters seekParameters) { + long toleranceBeforeUs = + Util.constrainValue( + seekParameters.toleranceBeforeUs, /* min= */ 0, /* max= */ positionUs - startUs); + long toleranceAfterUs = + Util.constrainValue( + seekParameters.toleranceAfterUs, + /* min= */ 0, + /* max= */ endUs == C.TIME_END_OF_SOURCE ? Long.MAX_VALUE : endUs - positionUs); + if (toleranceBeforeUs == seekParameters.toleranceBeforeUs + && toleranceAfterUs == seekParameters.toleranceAfterUs) { + return seekParameters; + } else { + return new SeekParameters(toleranceBeforeUs, toleranceAfterUs); + } + } + + private static boolean shouldKeepInitialDiscontinuity( + long startUs, @NullableType TrackSelection[] selections) { + // If the clipping start position is non-zero, the clipping sample streams will adjust + // timestamps on buffers they read from the unclipped sample streams. These adjusted buffer + // timestamps can be negative, because sample streams provide buffers starting at a key-frame, + // which may be before the clipping start point. When the renderer reads a buffer with a + // negative timestamp, its offset timestamp can jump backwards compared to the last timestamp + // read in the previous period. Renderer implementations may not allow this, so we signal a + // discontinuity which resets the renderers before they read the clipping sample stream. + // However, for audio-only track selections we assume to have random access seek behaviour and + // do not need an initial discontinuity to reset the renderer. + if (startUs != 0) { + for (TrackSelection trackSelection : selections) { + if (trackSelection != null) { + Format selectedFormat = trackSelection.getSelectedFormat(); + if (!MimeTypes.isAudio(selectedFormat.sampleMimeType)) { + return true; + } + } + } + } + return false; + } + + /** + * Wraps a {@link SampleStream} and clips its samples. + */ + private final class ClippingSampleStream implements SampleStream { + + public final SampleStream childStream; + + private boolean sentEos; + + public ClippingSampleStream(SampleStream childStream) { + this.childStream = childStream; + } + + public void clearSentEos() { + sentEos = false; + } + + @Override + public boolean isReady() { + return !isPendingInitialDiscontinuity() && childStream.isReady(); + } + + @Override + public void maybeThrowError() throws IOException { + childStream.maybeThrowError(); + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean requireFormat) { + if (isPendingInitialDiscontinuity()) { + return C.RESULT_NOTHING_READ; + } + if (sentEos) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + int result = childStream.readData(formatHolder, buffer, requireFormat); + if (result == C.RESULT_FORMAT_READ) { + Format format = Assertions.checkNotNull(formatHolder.format); + if (format.encoderDelay != 0 || format.encoderPadding != 0) { + // Clear gapless playback metadata if the start/end points don't match the media. + int encoderDelay = startUs != 0 ? 0 : format.encoderDelay; + int encoderPadding = endUs != C.TIME_END_OF_SOURCE ? 0 : format.encoderPadding; + formatHolder.format = format.copyWithGaplessInfo(encoderDelay, encoderPadding); + } + return C.RESULT_FORMAT_READ; + } + if (endUs != C.TIME_END_OF_SOURCE + && ((result == C.RESULT_BUFFER_READ && buffer.timeUs >= endUs) + || (result == C.RESULT_NOTHING_READ + && getBufferedPositionUs() == C.TIME_END_OF_SOURCE + && !buffer.waitingForKeys))) { + buffer.clear(); + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + sentEos = true; + return C.RESULT_BUFFER_READ; + } + return result; + } + + @Override + public int skipData(long positionUs) { + if (isPendingInitialDiscontinuity()) { + return C.RESULT_NOTHING_READ; + } + return childStream.skipData(positionUs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaSource.java new file mode 100644 index 0000000000..373076957d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ClippingMediaSource.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; + +/** + * {@link MediaSource} that wraps a source and clips its timeline based on specified start/end + * positions. The wrapped source must consist of a single period. + */ +public final class ClippingMediaSource extends CompositeMediaSource<Void> { + + /** Thrown when a {@link ClippingMediaSource} cannot clip its wrapped source. */ + public static final class IllegalClippingException extends IOException { + + /** + * The reason clipping failed. One of {@link #REASON_INVALID_PERIOD_COUNT}, {@link + * #REASON_NOT_SEEKABLE_TO_START} or {@link #REASON_START_EXCEEDS_END}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({REASON_INVALID_PERIOD_COUNT, REASON_NOT_SEEKABLE_TO_START, REASON_START_EXCEEDS_END}) + public @interface Reason {} + /** The wrapped source doesn't consist of a single period. */ + public static final int REASON_INVALID_PERIOD_COUNT = 0; + /** The wrapped source is not seekable and a non-zero clipping start position was specified. */ + public static final int REASON_NOT_SEEKABLE_TO_START = 1; + /** The wrapped source ends before the specified clipping start position. */ + public static final int REASON_START_EXCEEDS_END = 2; + + /** The reason clipping failed. */ + public final @Reason int reason; + + /** + * @param reason The reason clipping failed. + */ + public IllegalClippingException(@Reason int reason) { + super("Illegal clipping: " + getReasonDescription(reason)); + this.reason = reason; + } + + private static String getReasonDescription(@Reason int reason) { + switch (reason) { + case REASON_INVALID_PERIOD_COUNT: + return "invalid period count"; + case REASON_NOT_SEEKABLE_TO_START: + return "not seekable to start"; + case REASON_START_EXCEEDS_END: + return "start exceeds end"; + default: + return "unknown"; + } + } + } + + private final MediaSource mediaSource; + private final long startUs; + private final long endUs; + private final boolean enableInitialDiscontinuity; + private final boolean allowDynamicClippingUpdates; + private final boolean relativeToDefaultPosition; + private final ArrayList<ClippingMediaPeriod> mediaPeriods; + private final Timeline.Window window; + + @Nullable private ClippingTimeline clippingTimeline; + @Nullable private IllegalClippingException clippingError; + private long periodStartUs; + private long periodEndUs; + + /** + * Creates a new clipping source that wraps the specified source and provides samples between the + * specified start and end position. + * + * @param mediaSource The single-period source to wrap. + * @param startPositionUs The start position within {@code mediaSource}'s window at which to start + * providing samples, in microseconds. + * @param endPositionUs The end position within {@code mediaSource}'s window at which to stop + * providing samples, in microseconds. Specify {@link C#TIME_END_OF_SOURCE} to provide samples + * from the specified start point up to the end of the source. Specifying a position that + * exceeds the {@code mediaSource}'s duration will also result in the end of the source not + * being clipped. + */ + public ClippingMediaSource(MediaSource mediaSource, long startPositionUs, long endPositionUs) { + this( + mediaSource, + startPositionUs, + endPositionUs, + /* enableInitialDiscontinuity= */ true, + /* allowDynamicClippingUpdates= */ false, + /* relativeToDefaultPosition= */ false); + } + + /** + * Creates a new clipping source that wraps the specified source and provides samples from the + * default position for the specified duration. + * + * @param mediaSource The single-period source to wrap. + * @param durationUs The duration from the default position in the window in {@code mediaSource}'s + * timeline at which to stop providing samples. Specifying a duration that exceeds the {@code + * mediaSource}'s duration will result in the end of the source not being clipped. + */ + public ClippingMediaSource(MediaSource mediaSource, long durationUs) { + this( + mediaSource, + /* startPositionUs= */ 0, + /* endPositionUs= */ durationUs, + /* enableInitialDiscontinuity= */ true, + /* allowDynamicClippingUpdates= */ false, + /* relativeToDefaultPosition= */ true); + } + + /** + * Creates a new clipping source that wraps the specified source. + * + * <p>If the start point is guaranteed to be a key frame, pass {@code false} to {@code + * enableInitialPositionDiscontinuity} to suppress an initial discontinuity when a period is first + * read from. + * + * <p>For live streams, if the clipping positions should move with the live window, pass {@code + * true} to {@code allowDynamicClippingUpdates}. Otherwise, the live stream ends when the playback + * reaches {@code endPositionUs} in the last reported live window at the time a media period was + * created. + * + * @param mediaSource The single-period source to wrap. + * @param startPositionUs The start position at which to start providing samples, in microseconds. + * If {@code relativeToDefaultPosition} is {@code false}, this position is relative to the + * start of the window in {@code mediaSource}'s timeline. If {@code relativeToDefaultPosition} + * is {@code true}, this position is relative to the default position in the window in {@code + * mediaSource}'s timeline. + * @param endPositionUs The end position at which to stop providing samples, in microseconds. + * Specify {@link C#TIME_END_OF_SOURCE} to provide samples from the specified start point up + * to the end of the source. Specifying a position that exceeds the {@code mediaSource}'s + * duration will also result in the end of the source not being clipped. If {@code + * relativeToDefaultPosition} is {@code false}, the specified position is relative to the + * start of the window in {@code mediaSource}'s timeline. If {@code relativeToDefaultPosition} + * is {@code true}, this position is relative to the default position in the window in {@code + * mediaSource}'s timeline. + * @param enableInitialDiscontinuity Whether the initial discontinuity should be enabled. + * @param allowDynamicClippingUpdates Whether the clipping of active media periods moves with a + * live window. If {@code false}, playback ends when it reaches {@code endPositionUs} in the + * last reported live window at the time a media period was created. + * @param relativeToDefaultPosition Whether {@code startPositionUs} and {@code endPositionUs} are + * relative to the default position in the window in {@code mediaSource}'s timeline. + */ + public ClippingMediaSource( + MediaSource mediaSource, + long startPositionUs, + long endPositionUs, + boolean enableInitialDiscontinuity, + boolean allowDynamicClippingUpdates, + boolean relativeToDefaultPosition) { + Assertions.checkArgument(startPositionUs >= 0); + this.mediaSource = Assertions.checkNotNull(mediaSource); + startUs = startPositionUs; + endUs = endPositionUs; + this.enableInitialDiscontinuity = enableInitialDiscontinuity; + this.allowDynamicClippingUpdates = allowDynamicClippingUpdates; + this.relativeToDefaultPosition = relativeToDefaultPosition; + mediaPeriods = new ArrayList<>(); + window = new Timeline.Window(); + } + + @Override + @Nullable + public Object getTag() { + return mediaSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(/* id= */ null, mediaSource); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (clippingError != null) { + throw clippingError; + } + super.maybeThrowSourceInfoRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + ClippingMediaPeriod mediaPeriod = + new ClippingMediaPeriod( + mediaSource.createPeriod(id, allocator, startPositionUs), + enableInitialDiscontinuity, + periodStartUs, + periodEndUs); + mediaPeriods.add(mediaPeriod); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + Assertions.checkState(mediaPeriods.remove(mediaPeriod)); + mediaSource.releasePeriod(((ClippingMediaPeriod) mediaPeriod).mediaPeriod); + if (mediaPeriods.isEmpty() && !allowDynamicClippingUpdates) { + refreshClippedTimeline(Assertions.checkNotNull(clippingTimeline).timeline); + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + clippingError = null; + clippingTimeline = null; + } + + @Override + protected void onChildSourceInfoRefreshed(Void id, MediaSource mediaSource, Timeline timeline) { + if (clippingError != null) { + return; + } + refreshClippedTimeline(timeline); + } + + private void refreshClippedTimeline(Timeline timeline) { + long windowStartUs; + long windowEndUs; + timeline.getWindow(/* windowIndex= */ 0, window); + long windowPositionInPeriodUs = window.getPositionInFirstPeriodUs(); + if (clippingTimeline == null || mediaPeriods.isEmpty() || allowDynamicClippingUpdates) { + windowStartUs = startUs; + windowEndUs = endUs; + if (relativeToDefaultPosition) { + long windowDefaultPositionUs = window.getDefaultPositionUs(); + windowStartUs += windowDefaultPositionUs; + windowEndUs += windowDefaultPositionUs; + } + periodStartUs = windowPositionInPeriodUs + windowStartUs; + periodEndUs = + endUs == C.TIME_END_OF_SOURCE + ? C.TIME_END_OF_SOURCE + : windowPositionInPeriodUs + windowEndUs; + int count = mediaPeriods.size(); + for (int i = 0; i < count; i++) { + mediaPeriods.get(i).updateClipping(periodStartUs, periodEndUs); + } + } else { + // Keep window fixed at previous period position. + windowStartUs = periodStartUs - windowPositionInPeriodUs; + windowEndUs = + endUs == C.TIME_END_OF_SOURCE + ? C.TIME_END_OF_SOURCE + : periodEndUs - windowPositionInPeriodUs; + } + try { + clippingTimeline = new ClippingTimeline(timeline, windowStartUs, windowEndUs); + } catch (IllegalClippingException e) { + clippingError = e; + return; + } + refreshSourceInfo(clippingTimeline); + } + + @Override + protected long getMediaTimeForChildMediaTime(Void id, long mediaTimeMs) { + if (mediaTimeMs == C.TIME_UNSET) { + return C.TIME_UNSET; + } + long startMs = C.usToMs(startUs); + long clippedTimeMs = Math.max(0, mediaTimeMs - startMs); + if (endUs != C.TIME_END_OF_SOURCE) { + clippedTimeMs = Math.min(C.usToMs(endUs) - startMs, clippedTimeMs); + } + return clippedTimeMs; + } + + /** + * Provides a clipped view of a specified timeline. + */ + private static final class ClippingTimeline extends ForwardingTimeline { + + private final long startUs; + private final long endUs; + private final long durationUs; + private final boolean isDynamic; + + /** + * Creates a new clipping timeline that wraps the specified timeline. + * + * @param timeline The timeline to clip. + * @param startUs The number of microseconds to clip from the start of {@code timeline}. + * @param endUs The end position in microseconds for the clipped timeline relative to the start + * of {@code timeline}, or {@link C#TIME_END_OF_SOURCE} to clip no samples from the end. + * @throws IllegalClippingException If the timeline could not be clipped. + */ + public ClippingTimeline(Timeline timeline, long startUs, long endUs) + throws IllegalClippingException { + super(timeline); + if (timeline.getPeriodCount() != 1) { + throw new IllegalClippingException(IllegalClippingException.REASON_INVALID_PERIOD_COUNT); + } + Window window = timeline.getWindow(0, new Window()); + startUs = Math.max(0, startUs); + long resolvedEndUs = endUs == C.TIME_END_OF_SOURCE ? window.durationUs : Math.max(0, endUs); + if (window.durationUs != C.TIME_UNSET) { + if (resolvedEndUs > window.durationUs) { + resolvedEndUs = window.durationUs; + } + if (startUs != 0 && !window.isSeekable) { + throw new IllegalClippingException(IllegalClippingException.REASON_NOT_SEEKABLE_TO_START); + } + if (startUs > resolvedEndUs) { + throw new IllegalClippingException(IllegalClippingException.REASON_START_EXCEEDS_END); + } + } + this.startUs = startUs; + this.endUs = resolvedEndUs; + durationUs = resolvedEndUs == C.TIME_UNSET ? C.TIME_UNSET : (resolvedEndUs - startUs); + isDynamic = + window.isDynamic + && (resolvedEndUs == C.TIME_UNSET + || (window.durationUs != C.TIME_UNSET && resolvedEndUs == window.durationUs)); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + timeline.getWindow(/* windowIndex= */ 0, window, /* defaultPositionProjectionUs= */ 0); + window.positionInFirstPeriodUs += startUs; + window.durationUs = durationUs; + window.isDynamic = isDynamic; + if (window.defaultPositionUs != C.TIME_UNSET) { + window.defaultPositionUs = Math.max(window.defaultPositionUs, startUs); + window.defaultPositionUs = endUs == C.TIME_UNSET ? window.defaultPositionUs + : Math.min(window.defaultPositionUs, endUs); + window.defaultPositionUs -= startUs; + } + long startMs = C.usToMs(startUs); + if (window.presentationStartTimeMs != C.TIME_UNSET) { + window.presentationStartTimeMs += startMs; + } + if (window.windowStartTimeMs != C.TIME_UNSET) { + window.windowStartTimeMs += startMs; + } + return window; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + timeline.getPeriod(/* periodIndex= */ 0, period, setIds); + long positionInClippedWindowUs = period.getPositionInWindowUs() - startUs; + long periodDurationUs = + durationUs == C.TIME_UNSET ? C.TIME_UNSET : durationUs - positionInClippedWindowUs; + return period.set( + period.id, period.uid, /* windowIndex= */ 0, periodDurationUs, positionInClippedWindowUs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeMediaSource.java new file mode 100644 index 0000000000..ed46b8ee94 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeMediaSource.java @@ -0,0 +1,354 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.HashMap; + +/** + * Composite {@link MediaSource} consisting of multiple child sources. + * + * @param <T> The type of the id used to identify prepared child sources. + */ +public abstract class CompositeMediaSource<T> extends BaseMediaSource { + + private final HashMap<T, MediaSourceAndListener> childSources; + + @Nullable private Handler eventHandler; + @Nullable private TransferListener mediaTransferListener; + + /** Creates composite media source without child sources. */ + protected CompositeMediaSource() { + childSources = new HashMap<>(); + } + + @Override + @CallSuper + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + this.mediaTransferListener = mediaTransferListener; + eventHandler = new Handler(); + } + + @Override + @CallSuper + public void maybeThrowSourceInfoRefreshError() throws IOException { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.maybeThrowSourceInfoRefreshError(); + } + } + + @Override + @CallSuper + protected void enableInternal() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.enable(childSource.caller); + } + } + + @Override + @CallSuper + protected void disableInternal() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.disable(childSource.caller); + } + } + + @Override + @CallSuper + protected void releaseSourceInternal() { + for (MediaSourceAndListener childSource : childSources.values()) { + childSource.mediaSource.releaseSource(childSource.caller); + childSource.mediaSource.removeEventListener(childSource.eventListener); + } + childSources.clear(); + } + + /** + * Called when the source info of a child source has been refreshed. + * + * @param id The unique id used to prepare the child source. + * @param mediaSource The child source whose source info has been refreshed. + * @param timeline The timeline of the child source. + */ + protected abstract void onChildSourceInfoRefreshed( + T id, MediaSource mediaSource, Timeline timeline); + + /** + * Prepares a child source. + * + * <p>{@link #onChildSourceInfoRefreshed(Object, MediaSource, Timeline)} will be called when the + * child source updates its timeline with the same {@code id} passed to this method. + * + * <p>Any child sources that aren't explicitly released with {@link #releaseChildSource(Object)} + * will be released in {@link #releaseSourceInternal()}. + * + * @param id A unique id to identify the child source preparation. Null is allowed as an id. + * @param mediaSource The child {@link MediaSource}. + */ + protected final void prepareChildSource(final T id, MediaSource mediaSource) { + Assertions.checkArgument(!childSources.containsKey(id)); + MediaSourceCaller caller = + (source, timeline) -> onChildSourceInfoRefreshed(id, source, timeline); + MediaSourceEventListener eventListener = new ForwardingEventListener(id); + childSources.put(id, new MediaSourceAndListener(mediaSource, caller, eventListener)); + mediaSource.addEventListener(Assertions.checkNotNull(eventHandler), eventListener); + mediaSource.prepareSource(caller, mediaTransferListener); + if (!isEnabled()) { + mediaSource.disable(caller); + } + } + + /** + * Enables a child source. + * + * @param id The unique id used to prepare the child source. + */ + protected final void enableChildSource(final T id) { + MediaSourceAndListener enabledChild = Assertions.checkNotNull(childSources.get(id)); + enabledChild.mediaSource.enable(enabledChild.caller); + } + + /** + * Disables a child source. + * + * @param id The unique id used to prepare the child source. + */ + protected final void disableChildSource(final T id) { + MediaSourceAndListener disabledChild = Assertions.checkNotNull(childSources.get(id)); + disabledChild.mediaSource.disable(disabledChild.caller); + } + + /** + * Releases a child source. + * + * @param id The unique id used to prepare the child source. + */ + protected final void releaseChildSource(T id) { + MediaSourceAndListener removedChild = Assertions.checkNotNull(childSources.remove(id)); + removedChild.mediaSource.releaseSource(removedChild.caller); + removedChild.mediaSource.removeEventListener(removedChild.eventListener); + } + + /** + * Returns the window index in the composite source corresponding to the specified window index in + * a child source. The default implementation does not change the window index. + * + * @param id The unique id used to prepare the child source. + * @param windowIndex A window index of the child source. + * @return The corresponding window index in the composite source. + */ + protected int getWindowIndexForChildWindowIndex(T id, int windowIndex) { + return windowIndex; + } + + /** + * Returns the {@link MediaPeriodId} in the composite source corresponding to the specified {@link + * MediaPeriodId} in a child source. The default implementation does not change the media period + * id. + * + * @param id The unique id used to prepare the child source. + * @param mediaPeriodId A {@link MediaPeriodId} of the child source. + * @return The corresponding {@link MediaPeriodId} in the composite source. Null if no + * corresponding media period id can be determined. + */ + protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + T id, MediaPeriodId mediaPeriodId) { + return mediaPeriodId; + } + + /** + * Returns the media time in the composite source corresponding to the specified media time in a + * child source. The default implementation does not change the media time. + * + * @param id The unique id used to prepare the child source. + * @param mediaTimeMs A media time of the child source, in milliseconds. + * @return The corresponding media time in the composite source, in milliseconds. + */ + protected long getMediaTimeForChildMediaTime(@Nullable T id, long mediaTimeMs) { + return mediaTimeMs; + } + + /** + * Returns whether {@link MediaSourceEventListener#onMediaPeriodCreated(int, MediaPeriodId)} and + * {@link MediaSourceEventListener#onMediaPeriodReleased(int, MediaPeriodId)} events of the given + * media period should be reported. The default implementation is to always report these events. + * + * @param mediaPeriodId A {@link MediaPeriodId} in the composite media source. + * @return Whether create and release events for this media period should be reported. + */ + protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId) { + return true; + } + + private static final class MediaSourceAndListener { + + public final MediaSource mediaSource; + public final MediaSourceCaller caller; + public final MediaSourceEventListener eventListener; + + public MediaSourceAndListener( + MediaSource mediaSource, MediaSourceCaller caller, MediaSourceEventListener eventListener) { + this.mediaSource = mediaSource; + this.caller = caller; + this.eventListener = eventListener; + } + } + + private final class ForwardingEventListener implements MediaSourceEventListener { + + private final T id; + private EventDispatcher eventDispatcher; + + public ForwardingEventListener(T id) { + this.eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + this.id = id; + } + + @Override + public void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + if (shouldDispatchCreateOrReleaseEvent( + Assertions.checkNotNull(eventDispatcher.mediaPeriodId))) { + eventDispatcher.mediaPeriodCreated(); + } + } + } + + @Override + public void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + if (shouldDispatchCreateOrReleaseEvent( + Assertions.checkNotNull(eventDispatcher.mediaPeriodId))) { + eventDispatcher.mediaPeriodReleased(); + } + } + } + + @Override + public void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadStarted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadCompleted(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadCanceled(loadEventData, maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventData, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.loadError( + loadEventData, maybeUpdateMediaLoadData(mediaLoadData), error, wasCanceled); + } + } + + @Override + public void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.readingStarted(); + } + } + + @Override + public void onUpstreamDiscarded( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.upstreamDiscarded(maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + @Override + public void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + if (maybeUpdateEventDispatcher(windowIndex, mediaPeriodId)) { + eventDispatcher.downstreamFormatChanged(maybeUpdateMediaLoadData(mediaLoadData)); + } + } + + /** Updates the event dispatcher and returns whether the event should be dispatched. */ + private boolean maybeUpdateEventDispatcher( + int childWindowIndex, @Nullable MediaPeriodId childMediaPeriodId) { + MediaPeriodId mediaPeriodId = null; + if (childMediaPeriodId != null) { + mediaPeriodId = getMediaPeriodIdForChildMediaPeriodId(id, childMediaPeriodId); + if (mediaPeriodId == null) { + // Media period not found. Ignore event. + return false; + } + } + int windowIndex = getWindowIndexForChildWindowIndex(id, childWindowIndex); + if (eventDispatcher.windowIndex != windowIndex + || !Util.areEqual(eventDispatcher.mediaPeriodId, mediaPeriodId)) { + eventDispatcher = + createEventDispatcher(windowIndex, mediaPeriodId, /* mediaTimeOffsetMs= */ 0); + } + return true; + } + + private MediaLoadData maybeUpdateMediaLoadData(MediaLoadData mediaLoadData) { + long mediaStartTimeMs = getMediaTimeForChildMediaTime(id, mediaLoadData.mediaStartTimeMs); + long mediaEndTimeMs = getMediaTimeForChildMediaTime(id, mediaLoadData.mediaEndTimeMs); + if (mediaStartTimeMs == mediaLoadData.mediaStartTimeMs + && mediaEndTimeMs == mediaLoadData.mediaEndTimeMs) { + return mediaLoadData; + } + return new MediaLoadData( + mediaLoadData.dataType, + mediaLoadData.trackType, + mediaLoadData.trackFormat, + mediaLoadData.trackSelectionReason, + mediaLoadData.trackSelectionData, + mediaStartTimeMs, + mediaEndTimeMs); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java new file mode 100644 index 0000000000..9a72903528 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** + * A {@link SequenceableLoader} that encapsulates multiple other {@link SequenceableLoader}s. + */ +public class CompositeSequenceableLoader implements SequenceableLoader { + + protected final SequenceableLoader[] loaders; + + public CompositeSequenceableLoader(SequenceableLoader[] loaders) { + this.loaders = loaders; + } + + @Override + public final long getBufferedPositionUs() { + long bufferedPositionUs = Long.MAX_VALUE; + for (SequenceableLoader loader : loaders) { + long loaderBufferedPositionUs = loader.getBufferedPositionUs(); + if (loaderBufferedPositionUs != C.TIME_END_OF_SOURCE) { + bufferedPositionUs = Math.min(bufferedPositionUs, loaderBufferedPositionUs); + } + } + return bufferedPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : bufferedPositionUs; + } + + @Override + public final long getNextLoadPositionUs() { + long nextLoadPositionUs = Long.MAX_VALUE; + for (SequenceableLoader loader : loaders) { + long loaderNextLoadPositionUs = loader.getNextLoadPositionUs(); + if (loaderNextLoadPositionUs != C.TIME_END_OF_SOURCE) { + nextLoadPositionUs = Math.min(nextLoadPositionUs, loaderNextLoadPositionUs); + } + } + return nextLoadPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : nextLoadPositionUs; + } + + @Override + public final void reevaluateBuffer(long positionUs) { + for (SequenceableLoader loader : loaders) { + loader.reevaluateBuffer(positionUs); + } + } + + @Override + public boolean continueLoading(long positionUs) { + boolean madeProgress = false; + boolean madeProgressThisIteration; + do { + madeProgressThisIteration = false; + long nextLoadPositionUs = getNextLoadPositionUs(); + if (nextLoadPositionUs == C.TIME_END_OF_SOURCE) { + break; + } + for (SequenceableLoader loader : loaders) { + long loaderNextLoadPositionUs = loader.getNextLoadPositionUs(); + boolean isLoaderBehind = + loaderNextLoadPositionUs != C.TIME_END_OF_SOURCE + && loaderNextLoadPositionUs <= positionUs; + if (loaderNextLoadPositionUs == nextLoadPositionUs || isLoaderBehind) { + madeProgressThisIteration |= loader.continueLoading(positionUs); + } + } + madeProgress |= madeProgressThisIteration; + } while (madeProgressThisIteration); + return madeProgress; + } + + @Override + public boolean isLoading() { + for (SequenceableLoader loader : loaders) { + if (loader.isLoading()) { + return true; + } + } + return false; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoaderFactory.java new file mode 100644 index 0000000000..1ac76d6167 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/CompositeSequenceableLoaderFactory.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * A factory to create composite {@link SequenceableLoader}s. + */ +public interface CompositeSequenceableLoaderFactory { + + /** + * Creates a composite {@link SequenceableLoader}. + * + * @param loaders The sub-loaders that make up the {@link SequenceableLoader} to be built. + * @return A composite {@link SequenceableLoader} that comprises the given loaders. + */ + SequenceableLoader createCompositeSequenceableLoader(SequenceableLoader... loaders); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java new file mode 100644 index 0000000000..aa6f486473 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ConcatenatingMediaSource.java @@ -0,0 +1,1017 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import android.os.Message; +import androidx.annotation.GuardedBy; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ConcatenatingMediaSource.MediaSourceHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Concatenates multiple {@link MediaSource}s. The list of {@link MediaSource}s can be modified + * during playback. It is valid for the same {@link MediaSource} instance to be present more than + * once in the concatenation. Access to this class is thread-safe. + */ +public final class ConcatenatingMediaSource extends CompositeMediaSource<MediaSourceHolder> { + + private static final int MSG_ADD = 0; + private static final int MSG_REMOVE = 1; + private static final int MSG_MOVE = 2; + private static final int MSG_SET_SHUFFLE_ORDER = 3; + private static final int MSG_UPDATE_TIMELINE = 4; + private static final int MSG_ON_COMPLETION = 5; + + // Accessed on any thread. + @GuardedBy("this") + private final List<MediaSourceHolder> mediaSourcesPublic; + + @GuardedBy("this") + private final Set<HandlerAndRunnable> pendingOnCompletionActions; + + @GuardedBy("this") + @Nullable + private Handler playbackThreadHandler; + + // Accessed on the playback thread only. + private final List<MediaSourceHolder> mediaSourceHolders; + private final Map<MediaPeriod, MediaSourceHolder> mediaSourceByMediaPeriod; + private final Map<Object, MediaSourceHolder> mediaSourceByUid; + private final Set<MediaSourceHolder> enabledMediaSourceHolders; + private final boolean isAtomic; + private final boolean useLazyPreparation; + + private boolean timelineUpdateScheduled; + private Set<HandlerAndRunnable> nextTimelineUpdateOnCompletionActions; + private ShuffleOrder shuffleOrder; + + /** + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same + * {@link MediaSource} instance to be present more than once in the array. + */ + public ConcatenatingMediaSource(MediaSource... mediaSources) { + this(/* isAtomic= */ false, mediaSources); + } + + /** + * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated + * as a single item for repeating and shuffling. + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link + * MediaSource} instance to be present more than once in the array. + */ + public ConcatenatingMediaSource(boolean isAtomic, MediaSource... mediaSources) { + this(isAtomic, new DefaultShuffleOrder(0), mediaSources); + } + + /** + * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated + * as a single item for repeating and shuffling. + * @param shuffleOrder The {@link ShuffleOrder} to use when shuffling the child media sources. + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link + * MediaSource} instance to be present more than once in the array. + */ + public ConcatenatingMediaSource( + boolean isAtomic, ShuffleOrder shuffleOrder, MediaSource... mediaSources) { + this(isAtomic, /* useLazyPreparation= */ false, shuffleOrder, mediaSources); + } + + /** + * @param isAtomic Whether the concatenating media source will be treated as atomic, i.e., treated + * as a single item for repeating and shuffling. + * @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest + * loads and other initial preparation steps happen immediately. If true, these initial + * preparations are triggered only when the player starts buffering the media. + * @param shuffleOrder The {@link ShuffleOrder} to use when shuffling the child media sources. + * @param mediaSources The {@link MediaSource}s to concatenate. It is valid for the same {@link + * MediaSource} instance to be present more than once in the array. + */ + @SuppressWarnings("initialization") + public ConcatenatingMediaSource( + boolean isAtomic, + boolean useLazyPreparation, + ShuffleOrder shuffleOrder, + MediaSource... mediaSources) { + for (MediaSource mediaSource : mediaSources) { + Assertions.checkNotNull(mediaSource); + } + this.shuffleOrder = shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; + this.mediaSourceByMediaPeriod = new IdentityHashMap<>(); + this.mediaSourceByUid = new HashMap<>(); + this.mediaSourcesPublic = new ArrayList<>(); + this.mediaSourceHolders = new ArrayList<>(); + this.nextTimelineUpdateOnCompletionActions = new HashSet<>(); + this.pendingOnCompletionActions = new HashSet<>(); + this.enabledMediaSourceHolders = new HashSet<>(); + this.isAtomic = isAtomic; + this.useLazyPreparation = useLazyPreparation; + addMediaSources(Arrays.asList(mediaSources)); + } + + /** + * Appends a {@link MediaSource} to the playlist. + * + * @param mediaSource The {@link MediaSource} to be added to the list. + */ + public synchronized void addMediaSource(MediaSource mediaSource) { + addMediaSource(mediaSourcesPublic.size(), mediaSource); + } + + /** + * Appends a {@link MediaSource} to the playlist and executes a custom action on completion. + * + * @param mediaSource The {@link MediaSource} to be added to the list. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been added to the playlist. + */ + public synchronized void addMediaSource( + MediaSource mediaSource, Handler handler, Runnable onCompletionAction) { + addMediaSource(mediaSourcesPublic.size(), mediaSource, handler, onCompletionAction); + } + + /** + * Adds a {@link MediaSource} to the playlist. + * + * @param index The index at which the new {@link MediaSource} will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSource The {@link MediaSource} to be added to the list. + */ + public synchronized void addMediaSource(int index, MediaSource mediaSource) { + addPublicMediaSources( + index, + Collections.singletonList(mediaSource), + /* handler= */ null, + /* onCompletionAction= */ null); + } + + /** + * Adds a {@link MediaSource} to the playlist and executes a custom action on completion. + * + * @param index The index at which the new {@link MediaSource} will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSource The {@link MediaSource} to be added to the list. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been added to the playlist. + */ + public synchronized void addMediaSource( + int index, MediaSource mediaSource, Handler handler, Runnable onCompletionAction) { + addPublicMediaSources( + index, Collections.singletonList(mediaSource), handler, onCompletionAction); + } + + /** + * Appends multiple {@link MediaSource}s to the playlist. + * + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + */ + public synchronized void addMediaSources(Collection<MediaSource> mediaSources) { + addPublicMediaSources( + mediaSourcesPublic.size(), + mediaSources, + /* handler= */ null, + /* onCompletionAction= */ null); + } + + /** + * Appends multiple {@link MediaSource}s to the playlist and executes a custom action on + * completion. + * + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * sources have been added to the playlist. + */ + public synchronized void addMediaSources( + Collection<MediaSource> mediaSources, Handler handler, Runnable onCompletionAction) { + addPublicMediaSources(mediaSourcesPublic.size(), mediaSources, handler, onCompletionAction); + } + + /** + * Adds multiple {@link MediaSource}s to the playlist. + * + * @param index The index at which the new {@link MediaSource}s will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + */ + public synchronized void addMediaSources(int index, Collection<MediaSource> mediaSources) { + addPublicMediaSources(index, mediaSources, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Adds multiple {@link MediaSource}s to the playlist and executes a custom action on completion. + * + * @param index The index at which the new {@link MediaSource}s will be inserted. This index must + * be in the range of 0 <= index <= {@link #getSize()}. + * @param mediaSources A collection of {@link MediaSource}s to be added to the list. The media + * sources are added in the order in which they appear in this collection. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * sources have been added to the playlist. + */ + public synchronized void addMediaSources( + int index, + Collection<MediaSource> mediaSources, + Handler handler, + Runnable onCompletionAction) { + addPublicMediaSources(index, mediaSources, handler, onCompletionAction); + } + + /** + * Removes a {@link MediaSource} from the playlist. + * + * <p>Note: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int, + * int)} instead. + * + * <p>Note: If you want to remove a set of contiguous sources, it's preferable to use {@link + * #removeMediaSourceRange(int, int)} instead. + * + * @param index The index at which the media source will be removed. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @return The removed {@link MediaSource}. + */ + public synchronized MediaSource removeMediaSource(int index) { + MediaSource removedMediaSource = getMediaSource(index); + removePublicMediaSources(index, index + 1, /* handler= */ null, /* onCompletionAction= */ null); + return removedMediaSource; + } + + /** + * Removes a {@link MediaSource} from the playlist and executes a custom action on completion. + * + * <p>Note: If you want to move the instance, it's preferable to use {@link #moveMediaSource(int, + * int, Handler, Runnable)} instead. + * + * <p>Note: If you want to remove a set of contiguous sources, it's preferable to use {@link + * #removeMediaSourceRange(int, int, Handler, Runnable)} instead. + * + * @param index The index at which the media source will be removed. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been removed from the playlist. + * @return The removed {@link MediaSource}. + */ + public synchronized MediaSource removeMediaSource( + int index, Handler handler, Runnable onCompletionAction) { + MediaSource removedMediaSource = getMediaSource(index); + removePublicMediaSources(index, index + 1, handler, onCompletionAction); + return removedMediaSource; + } + + /** + * Removes a range of {@link MediaSource}s from the playlist, by specifying an initial index + * (included) and a final index (excluded). + * + * <p>Note: when specified range is empty, no actual media source is removed and no exception is + * thrown. + * + * @param fromIndex The initial range index, pointing to the first media source that will be + * removed. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param toIndex The final range index, pointing to the first media source that will be left + * untouched. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @throws IndexOutOfBoundsException When the range is malformed, i.e. {@code fromIndex} < 0, + * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} + */ + public synchronized void removeMediaSourceRange(int fromIndex, int toIndex) { + removePublicMediaSources( + fromIndex, toIndex, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Removes a range of {@link MediaSource}s from the playlist, by specifying an initial index + * (included) and a final index (excluded), and executes a custom action on completion. + * + * <p>Note: when specified range is empty, no actual media source is removed and no exception is + * thrown. + * + * @param fromIndex The initial range index, pointing to the first media source that will be + * removed. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param toIndex The final range index, pointing to the first media source that will be left + * untouched. This index must be in the range of 0 <= index <= {@link #getSize()}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source range has been removed from the playlist. + * @throws IllegalArgumentException When the range is malformed, i.e. {@code fromIndex} < 0, + * {@code toIndex} > {@link #getSize()}, {@code fromIndex} > {@code toIndex} + */ + public synchronized void removeMediaSourceRange( + int fromIndex, int toIndex, Handler handler, Runnable onCompletionAction) { + removePublicMediaSources(fromIndex, toIndex, handler, onCompletionAction); + } + + /** + * Moves an existing {@link MediaSource} within the playlist. + * + * @param currentIndex The current index of the media source in the playlist. This index must be + * in the range of 0 <= index < {@link #getSize()}. + * @param newIndex The target index of the media source in the playlist. This index must be in the + * range of 0 <= index < {@link #getSize()}. + */ + public synchronized void moveMediaSource(int currentIndex, int newIndex) { + movePublicMediaSource( + currentIndex, newIndex, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Moves an existing {@link MediaSource} within the playlist and executes a custom action on + * completion. + * + * @param currentIndex The current index of the media source in the playlist. This index must be + * in the range of 0 <= index < {@link #getSize()}. + * @param newIndex The target index of the media source in the playlist. This index must be in the + * range of 0 <= index < {@link #getSize()}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the media + * source has been moved. + */ + public synchronized void moveMediaSource( + int currentIndex, int newIndex, Handler handler, Runnable onCompletionAction) { + movePublicMediaSource(currentIndex, newIndex, handler, onCompletionAction); + } + + /** Clears the playlist. */ + public synchronized void clear() { + removeMediaSourceRange(0, getSize()); + } + + /** + * Clears the playlist and executes a custom action on completion. + * + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the playlist + * has been cleared. + */ + public synchronized void clear(Handler handler, Runnable onCompletionAction) { + removeMediaSourceRange(0, getSize(), handler, onCompletionAction); + } + + /** Returns the number of media sources in the playlist. */ + public synchronized int getSize() { + return mediaSourcesPublic.size(); + } + + /** + * Returns the {@link MediaSource} at a specified index. + * + * @param index An index in the range of 0 <= index <= {@link #getSize()}. + * @return The {@link MediaSource} at this index. + */ + public synchronized MediaSource getMediaSource(int index) { + return mediaSourcesPublic.get(index).mediaSource; + } + + /** + * Sets a new shuffle order to use when shuffling the child media sources. + * + * @param shuffleOrder A {@link ShuffleOrder}. + */ + public synchronized void setShuffleOrder(ShuffleOrder shuffleOrder) { + setPublicShuffleOrder(shuffleOrder, /* handler= */ null, /* onCompletionAction= */ null); + } + + /** + * Sets a new shuffle order to use when shuffling the child media sources. + * + * @param shuffleOrder A {@link ShuffleOrder}. + * @param handler The {@link Handler} to run {@code onCompletionAction}. + * @param onCompletionAction A {@link Runnable} which is executed immediately after the shuffle + * order has been changed. + */ + public synchronized void setShuffleOrder( + ShuffleOrder shuffleOrder, Handler handler, Runnable onCompletionAction) { + setPublicShuffleOrder(shuffleOrder, handler, onCompletionAction); + } + + // CompositeMediaSource implementation. + + @Override + @Nullable + public Object getTag() { + return null; + } + + @Override + protected synchronized void prepareSourceInternal( + @Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + playbackThreadHandler = new Handler(/* callback= */ this::handleMessage); + if (mediaSourcesPublic.isEmpty()) { + updateTimelineAndScheduleOnCompletionActions(); + } else { + shuffleOrder = shuffleOrder.cloneAndInsert(0, mediaSourcesPublic.size()); + addMediaSourcesInternal(0, mediaSourcesPublic); + scheduleTimelineUpdate(); + } + } + + @SuppressWarnings("MissingSuperCall") + @Override + protected void enableInternal() { + // Suppress enabling all child sources here as they can be lazily enabled when creating periods. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + Object mediaSourceHolderUid = getMediaSourceHolderUid(id.periodUid); + MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(getChildPeriodUid(id.periodUid)); + MediaSourceHolder holder = mediaSourceByUid.get(mediaSourceHolderUid); + if (holder == null) { + // Stale event. The media source has already been removed. + holder = new MediaSourceHolder(new DummyMediaSource(), useLazyPreparation); + holder.isRemoved = true; + prepareChildSource(holder, holder.mediaSource); + } + enableMediaSource(holder); + holder.activeMediaPeriodIds.add(childMediaPeriodId); + MediaPeriod mediaPeriod = + holder.mediaSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaSourceByMediaPeriod.put(mediaPeriod, holder); + disableUnusedMediaSources(); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MediaSourceHolder holder = + Assertions.checkNotNull(mediaSourceByMediaPeriod.remove(mediaPeriod)); + holder.mediaSource.releasePeriod(mediaPeriod); + holder.activeMediaPeriodIds.remove(((MaskingMediaPeriod) mediaPeriod).id); + if (!mediaSourceByMediaPeriod.isEmpty()) { + disableUnusedMediaSources(); + } + maybeReleaseChildSource(holder); + } + + @Override + protected void disableInternal() { + super.disableInternal(); + enabledMediaSourceHolders.clear(); + } + + @Override + protected synchronized void releaseSourceInternal() { + super.releaseSourceInternal(); + mediaSourceHolders.clear(); + enabledMediaSourceHolders.clear(); + mediaSourceByUid.clear(); + shuffleOrder = shuffleOrder.cloneAndClear(); + if (playbackThreadHandler != null) { + playbackThreadHandler.removeCallbacksAndMessages(null); + playbackThreadHandler = null; + } + timelineUpdateScheduled = false; + nextTimelineUpdateOnCompletionActions.clear(); + dispatchOnCompletionActions(pendingOnCompletionActions); + } + + @Override + protected void onChildSourceInfoRefreshed( + MediaSourceHolder mediaSourceHolder, MediaSource mediaSource, Timeline timeline) { + updateMediaSourceInternal(mediaSourceHolder, timeline); + } + + @Override + @Nullable + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + MediaSourceHolder mediaSourceHolder, MediaPeriodId mediaPeriodId) { + for (int i = 0; i < mediaSourceHolder.activeMediaPeriodIds.size(); i++) { + // Ensure the reported media period id has the same window sequence number as the one created + // by this media source. Otherwise it does not belong to this child source. + if (mediaSourceHolder.activeMediaPeriodIds.get(i).windowSequenceNumber + == mediaPeriodId.windowSequenceNumber) { + Object periodUid = getPeriodUid(mediaSourceHolder, mediaPeriodId.periodUid); + return mediaPeriodId.copyWithPeriodUid(periodUid); + } + } + return null; + } + + @Override + protected int getWindowIndexForChildWindowIndex( + MediaSourceHolder mediaSourceHolder, int windowIndex) { + return windowIndex + mediaSourceHolder.firstWindowIndexInChild; + } + + // Internal methods. Called from any thread. + + @GuardedBy("this") + private void addPublicMediaSources( + int index, + Collection<MediaSource> mediaSources, + @Nullable Handler handler, + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + for (MediaSource mediaSource : mediaSources) { + Assertions.checkNotNull(mediaSource); + } + List<MediaSourceHolder> mediaSourceHolders = new ArrayList<>(mediaSources.size()); + for (MediaSource mediaSource : mediaSources) { + mediaSourceHolders.add(new MediaSourceHolder(mediaSource, useLazyPreparation)); + } + mediaSourcesPublic.addAll(index, mediaSourceHolders); + if (playbackThreadHandler != null && !mediaSources.isEmpty()) { + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage(MSG_ADD, new MessageData<>(index, mediaSourceHolders, callbackAction)) + .sendToTarget(); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + + @GuardedBy("this") + private void removePublicMediaSources( + int fromIndex, + int toIndex, + @Nullable Handler handler, + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + Util.removeRange(mediaSourcesPublic, fromIndex, toIndex); + if (playbackThreadHandler != null) { + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage(MSG_REMOVE, new MessageData<>(fromIndex, toIndex, callbackAction)) + .sendToTarget(); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + + @GuardedBy("this") + private void movePublicMediaSource( + int currentIndex, + int newIndex, + @Nullable Handler handler, + @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + mediaSourcesPublic.add(newIndex, mediaSourcesPublic.remove(currentIndex)); + if (playbackThreadHandler != null) { + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage(MSG_MOVE, new MessageData<>(currentIndex, newIndex, callbackAction)) + .sendToTarget(); + } else if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + + @GuardedBy("this") + private void setPublicShuffleOrder( + ShuffleOrder shuffleOrder, @Nullable Handler handler, @Nullable Runnable onCompletionAction) { + Assertions.checkArgument((handler == null) == (onCompletionAction == null)); + Handler playbackThreadHandler = this.playbackThreadHandler; + if (playbackThreadHandler != null) { + int size = getSize(); + if (shuffleOrder.getLength() != size) { + shuffleOrder = + shuffleOrder + .cloneAndClear() + .cloneAndInsert(/* insertionIndex= */ 0, /* insertionCount= */ size); + } + HandlerAndRunnable callbackAction = createOnCompletionAction(handler, onCompletionAction); + playbackThreadHandler + .obtainMessage( + MSG_SET_SHUFFLE_ORDER, + new MessageData<>(/* index= */ 0, shuffleOrder, callbackAction)) + .sendToTarget(); + } else { + this.shuffleOrder = + shuffleOrder.getLength() > 0 ? shuffleOrder.cloneAndClear() : shuffleOrder; + if (onCompletionAction != null && handler != null) { + handler.post(onCompletionAction); + } + } + } + + @GuardedBy("this") + @Nullable + private HandlerAndRunnable createOnCompletionAction( + @Nullable Handler handler, @Nullable Runnable runnable) { + if (handler == null || runnable == null) { + return null; + } + HandlerAndRunnable handlerAndRunnable = new HandlerAndRunnable(handler, runnable); + pendingOnCompletionActions.add(handlerAndRunnable); + return handlerAndRunnable; + } + + // Internal methods. Called on the playback thread. + + @SuppressWarnings("unchecked") + private boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_ADD: + MessageData<Collection<MediaSourceHolder>> addMessage = + (MessageData<Collection<MediaSourceHolder>>) Util.castNonNull(msg.obj); + shuffleOrder = shuffleOrder.cloneAndInsert(addMessage.index, addMessage.customData.size()); + addMediaSourcesInternal(addMessage.index, addMessage.customData); + scheduleTimelineUpdate(addMessage.onCompletionAction); + break; + case MSG_REMOVE: + MessageData<Integer> removeMessage = (MessageData<Integer>) Util.castNonNull(msg.obj); + int fromIndex = removeMessage.index; + int toIndex = removeMessage.customData; + if (fromIndex == 0 && toIndex == shuffleOrder.getLength()) { + shuffleOrder = shuffleOrder.cloneAndClear(); + } else { + shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndex); + } + for (int index = toIndex - 1; index >= fromIndex; index--) { + removeMediaSourceInternal(index); + } + scheduleTimelineUpdate(removeMessage.onCompletionAction); + break; + case MSG_MOVE: + MessageData<Integer> moveMessage = (MessageData<Integer>) Util.castNonNull(msg.obj); + shuffleOrder = shuffleOrder.cloneAndRemove(moveMessage.index, moveMessage.index + 1); + shuffleOrder = shuffleOrder.cloneAndInsert(moveMessage.customData, 1); + moveMediaSourceInternal(moveMessage.index, moveMessage.customData); + scheduleTimelineUpdate(moveMessage.onCompletionAction); + break; + case MSG_SET_SHUFFLE_ORDER: + MessageData<ShuffleOrder> shuffleOrderMessage = + (MessageData<ShuffleOrder>) Util.castNonNull(msg.obj); + shuffleOrder = shuffleOrderMessage.customData; + scheduleTimelineUpdate(shuffleOrderMessage.onCompletionAction); + break; + case MSG_UPDATE_TIMELINE: + updateTimelineAndScheduleOnCompletionActions(); + break; + case MSG_ON_COMPLETION: + Set<HandlerAndRunnable> actions = (Set<HandlerAndRunnable>) Util.castNonNull(msg.obj); + dispatchOnCompletionActions(actions); + break; + default: + throw new IllegalStateException(); + } + return true; + } + + private void scheduleTimelineUpdate() { + scheduleTimelineUpdate(/* onCompletionAction= */ null); + } + + private void scheduleTimelineUpdate(@Nullable HandlerAndRunnable onCompletionAction) { + if (!timelineUpdateScheduled) { + getPlaybackThreadHandlerOnPlaybackThread().obtainMessage(MSG_UPDATE_TIMELINE).sendToTarget(); + timelineUpdateScheduled = true; + } + if (onCompletionAction != null) { + nextTimelineUpdateOnCompletionActions.add(onCompletionAction); + } + } + + private void updateTimelineAndScheduleOnCompletionActions() { + timelineUpdateScheduled = false; + Set<HandlerAndRunnable> onCompletionActions = nextTimelineUpdateOnCompletionActions; + nextTimelineUpdateOnCompletionActions = new HashSet<>(); + refreshSourceInfo(new ConcatenatedTimeline(mediaSourceHolders, shuffleOrder, isAtomic)); + getPlaybackThreadHandlerOnPlaybackThread() + .obtainMessage(MSG_ON_COMPLETION, onCompletionActions) + .sendToTarget(); + } + + @SuppressWarnings("GuardedBy") + private Handler getPlaybackThreadHandlerOnPlaybackThread() { + // Write access to this value happens on the playback thread only, so playback thread reads + // don't need to be synchronized. + return Assertions.checkNotNull(playbackThreadHandler); + } + + private synchronized void dispatchOnCompletionActions( + Set<HandlerAndRunnable> onCompletionActions) { + for (HandlerAndRunnable pendingAction : onCompletionActions) { + pendingAction.dispatch(); + } + pendingOnCompletionActions.removeAll(onCompletionActions); + } + + private void addMediaSourcesInternal( + int index, Collection<MediaSourceHolder> mediaSourceHolders) { + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + addMediaSourceInternal(index++, mediaSourceHolder); + } + } + + private void addMediaSourceInternal(int newIndex, MediaSourceHolder newMediaSourceHolder) { + if (newIndex > 0) { + MediaSourceHolder previousHolder = mediaSourceHolders.get(newIndex - 1); + Timeline previousTimeline = previousHolder.mediaSource.getTimeline(); + newMediaSourceHolder.reset( + newIndex, previousHolder.firstWindowIndexInChild + previousTimeline.getWindowCount()); + } else { + newMediaSourceHolder.reset(newIndex, /* firstWindowIndexInChild= */ 0); + } + Timeline newTimeline = newMediaSourceHolder.mediaSource.getTimeline(); + correctOffsets(newIndex, /* childIndexUpdate= */ 1, newTimeline.getWindowCount()); + mediaSourceHolders.add(newIndex, newMediaSourceHolder); + mediaSourceByUid.put(newMediaSourceHolder.uid, newMediaSourceHolder); + prepareChildSource(newMediaSourceHolder, newMediaSourceHolder.mediaSource); + if (isEnabled() && mediaSourceByMediaPeriod.isEmpty()) { + enabledMediaSourceHolders.add(newMediaSourceHolder); + } else { + disableChildSource(newMediaSourceHolder); + } + } + + private void updateMediaSourceInternal(MediaSourceHolder mediaSourceHolder, Timeline timeline) { + if (mediaSourceHolder == null) { + throw new IllegalArgumentException(); + } + if (mediaSourceHolder.childIndex + 1 < mediaSourceHolders.size()) { + MediaSourceHolder nextHolder = mediaSourceHolders.get(mediaSourceHolder.childIndex + 1); + int windowOffsetUpdate = + timeline.getWindowCount() + - (nextHolder.firstWindowIndexInChild - mediaSourceHolder.firstWindowIndexInChild); + if (windowOffsetUpdate != 0) { + correctOffsets( + mediaSourceHolder.childIndex + 1, /* childIndexUpdate= */ 0, windowOffsetUpdate); + } + } + scheduleTimelineUpdate(); + } + + private void removeMediaSourceInternal(int index) { + MediaSourceHolder holder = mediaSourceHolders.remove(index); + mediaSourceByUid.remove(holder.uid); + Timeline oldTimeline = holder.mediaSource.getTimeline(); + correctOffsets(index, /* childIndexUpdate= */ -1, -oldTimeline.getWindowCount()); + holder.isRemoved = true; + maybeReleaseChildSource(holder); + } + + private void moveMediaSourceInternal(int currentIndex, int newIndex) { + int startIndex = Math.min(currentIndex, newIndex); + int endIndex = Math.max(currentIndex, newIndex); + int windowOffset = mediaSourceHolders.get(startIndex).firstWindowIndexInChild; + mediaSourceHolders.add(newIndex, mediaSourceHolders.remove(currentIndex)); + for (int i = startIndex; i <= endIndex; i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + holder.childIndex = i; + holder.firstWindowIndexInChild = windowOffset; + windowOffset += holder.mediaSource.getTimeline().getWindowCount(); + } + } + + private void correctOffsets(int startIndex, int childIndexUpdate, int windowOffsetUpdate) { + // TODO: Replace window index with uid in reporting to get rid of this inefficient method and + // the childIndex and firstWindowIndexInChild variables. + for (int i = startIndex; i < mediaSourceHolders.size(); i++) { + MediaSourceHolder holder = mediaSourceHolders.get(i); + holder.childIndex += childIndexUpdate; + holder.firstWindowIndexInChild += windowOffsetUpdate; + } + } + + private void maybeReleaseChildSource(MediaSourceHolder mediaSourceHolder) { + // Release if the source has been removed from the playlist and no periods are still active. + if (mediaSourceHolder.isRemoved && mediaSourceHolder.activeMediaPeriodIds.isEmpty()) { + enabledMediaSourceHolders.remove(mediaSourceHolder); + releaseChildSource(mediaSourceHolder); + } + } + + private void enableMediaSource(MediaSourceHolder mediaSourceHolder) { + enabledMediaSourceHolders.add(mediaSourceHolder); + enableChildSource(mediaSourceHolder); + } + + private void disableUnusedMediaSources() { + Iterator<MediaSourceHolder> iterator = enabledMediaSourceHolders.iterator(); + while (iterator.hasNext()) { + MediaSourceHolder holder = iterator.next(); + if (holder.activeMediaPeriodIds.isEmpty()) { + disableChildSource(holder); + iterator.remove(); + } + } + } + + /** Return uid of media source holder from period uid of concatenated source. */ + private static Object getMediaSourceHolderUid(Object periodUid) { + return ConcatenatedTimeline.getChildTimelineUidFromConcatenatedUid(periodUid); + } + + /** Return uid of child period from period uid of concatenated source. */ + private static Object getChildPeriodUid(Object periodUid) { + return ConcatenatedTimeline.getChildPeriodUidFromConcatenatedUid(periodUid); + } + + private static Object getPeriodUid(MediaSourceHolder holder, Object childPeriodUid) { + return ConcatenatedTimeline.getConcatenatedUid(holder.uid, childPeriodUid); + } + + /** Data class to hold playlist media sources together with meta data needed to process them. */ + /* package */ static final class MediaSourceHolder { + + public final MaskingMediaSource mediaSource; + public final Object uid; + public final List<MediaPeriodId> activeMediaPeriodIds; + + public int childIndex; + public int firstWindowIndexInChild; + public boolean isRemoved; + + public MediaSourceHolder(MediaSource mediaSource, boolean useLazyPreparation) { + this.mediaSource = new MaskingMediaSource(mediaSource, useLazyPreparation); + this.activeMediaPeriodIds = new ArrayList<>(); + this.uid = new Object(); + } + + public void reset(int childIndex, int firstWindowIndexInChild) { + this.childIndex = childIndex; + this.firstWindowIndexInChild = firstWindowIndexInChild; + this.isRemoved = false; + this.activeMediaPeriodIds.clear(); + } + } + + /** Message used to post actions from app thread to playback thread. */ + private static final class MessageData<T> { + + public final int index; + public final T customData; + @Nullable public final HandlerAndRunnable onCompletionAction; + + public MessageData(int index, T customData, @Nullable HandlerAndRunnable onCompletionAction) { + this.index = index; + this.customData = customData; + this.onCompletionAction = onCompletionAction; + } + } + + /** Timeline exposing concatenated timelines of playlist media sources. */ + private static final class ConcatenatedTimeline extends AbstractConcatenatedTimeline { + + private final int windowCount; + private final int periodCount; + private final int[] firstPeriodInChildIndices; + private final int[] firstWindowInChildIndices; + private final Timeline[] timelines; + private final Object[] uids; + private final HashMap<Object, Integer> childIndexByUid; + + public ConcatenatedTimeline( + Collection<MediaSourceHolder> mediaSourceHolders, + ShuffleOrder shuffleOrder, + boolean isAtomic) { + super(isAtomic, shuffleOrder); + int childCount = mediaSourceHolders.size(); + firstPeriodInChildIndices = new int[childCount]; + firstWindowInChildIndices = new int[childCount]; + timelines = new Timeline[childCount]; + uids = new Object[childCount]; + childIndexByUid = new HashMap<>(); + int index = 0; + int windowCount = 0; + int periodCount = 0; + for (MediaSourceHolder mediaSourceHolder : mediaSourceHolders) { + timelines[index] = mediaSourceHolder.mediaSource.getTimeline(); + firstWindowInChildIndices[index] = windowCount; + firstPeriodInChildIndices[index] = periodCount; + windowCount += timelines[index].getWindowCount(); + periodCount += timelines[index].getPeriodCount(); + uids[index] = mediaSourceHolder.uid; + childIndexByUid.put(uids[index], index++); + } + this.windowCount = windowCount; + this.periodCount = periodCount; + } + + @Override + protected int getChildIndexByPeriodIndex(int periodIndex) { + return Util.binarySearchFloor(firstPeriodInChildIndices, periodIndex + 1, false, false); + } + + @Override + protected int getChildIndexByWindowIndex(int windowIndex) { + return Util.binarySearchFloor(firstWindowInChildIndices, windowIndex + 1, false, false); + } + + @Override + protected int getChildIndexByChildUid(Object childUid) { + Integer index = childIndexByUid.get(childUid); + return index == null ? C.INDEX_UNSET : index; + } + + @Override + protected Timeline getTimelineByChildIndex(int childIndex) { + return timelines[childIndex]; + } + + @Override + protected int getFirstPeriodIndexByChildIndex(int childIndex) { + return firstPeriodInChildIndices[childIndex]; + } + + @Override + protected int getFirstWindowIndexByChildIndex(int childIndex) { + return firstWindowInChildIndices[childIndex]; + } + + @Override + protected Object getChildUidByChildIndex(int childIndex) { + return uids[childIndex]; + } + + @Override + public int getWindowCount() { + return windowCount; + } + + @Override + public int getPeriodCount() { + return periodCount; + } + } + + /** Dummy media source which does nothing and does not support creating periods. */ + private static final class DummyMediaSource extends BaseMediaSource { + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + // Do nothing. + } + + @Override + @Nullable + public Object getTag() { + return null; + } + + @Override + protected void releaseSourceInternal() { + // Do nothing. + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + throw new UnsupportedOperationException(); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + // Do nothing. + } + } + + private static final class HandlerAndRunnable { + + private final Handler handler; + private final Runnable runnable; + + public HandlerAndRunnable(Handler handler, Runnable runnable) { + this.handler = handler; + this.runnable = runnable; + } + + public void dispatch() { + handler.post(runnable); + } + } +} + diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultCompositeSequenceableLoaderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultCompositeSequenceableLoaderFactory.java new file mode 100644 index 0000000000..237510bea3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultCompositeSequenceableLoaderFactory.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * Default implementation of {@link CompositeSequenceableLoaderFactory}. + */ +public final class DefaultCompositeSequenceableLoaderFactory + implements CompositeSequenceableLoaderFactory { + + @Override + public SequenceableLoader createCompositeSequenceableLoader(SequenceableLoader... loaders) { + return new CompositeSequenceableLoader(loaders); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultMediaSourceEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultMediaSourceEventListener.java new file mode 100644 index 0000000000..c25750247f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/DefaultMediaSourceEventListener.java @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +/** + * @deprecated Use {@link MediaSourceEventListener} interface directly for selective overrides as + * all methods are implemented as no-op default methods. + */ +@Deprecated +public abstract class DefaultMediaSourceEventListener implements MediaSourceEventListener {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/EmptySampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/EmptySampleStream.java new file mode 100644 index 0000000000..398c6b91fc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/EmptySampleStream.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import java.io.IOException; + +/** + * An empty {@link SampleStream}. + */ +public final class EmptySampleStream implements SampleStream { + + @Override + public boolean isReady() { + return true; + } + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + + @Override + public int skipData(long positionUs) { + return 0; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ExtractorMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ExtractorMediaSource.java new file mode 100644 index 0000000000..3b72f51c44 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ExtractorMediaSource.java @@ -0,0 +1,394 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** @deprecated Use {@link ProgressiveMediaSource} instead. */ +@Deprecated +@SuppressWarnings("deprecation") +public final class ExtractorMediaSource extends CompositeMediaSource<Void> { + + /** @deprecated Use {@link MediaSourceEventListener} instead. */ + @Deprecated + public interface EventListener { + + /** + * Called when an error occurs loading media data. + * <p> + * This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error and continue. Hence applications should + * <em>not</em> implement this method to display a user visible error or initiate an application + * level retry ({@link Player.EventListener#onPlayerError} is the appropriate place to implement + * such behavior). This method is called to provide the application with an opportunity to log + * the error if it wishes to do so. + * + * @param error The load error. + */ + void onLoadError(IOException error); + + } + + /** @deprecated Use {@link ProgressiveMediaSource.Factory} instead. */ + @Deprecated + public static final class Factory implements MediaSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + @Nullable private ExtractorsFactory extractorsFactory; + @Nullable private String customCacheKey; + @Nullable private Object tag; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private int continueLoadingCheckIntervalBytes; + private boolean isCreateCalled; + + /** + * Creates a new factory for {@link ExtractorMediaSource}s. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + continueLoadingCheckIntervalBytes = DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + } + + /** + * Sets the factory for {@link Extractor}s to process the media stream. The default value is an + * instance of {@link DefaultExtractorsFactory}. + * + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those + * formats. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setExtractorsFactory(ExtractorsFactory extractorsFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorsFactory = extractorsFactory; + return this; + } + + /** + * Sets the custom key that uniquely identifies the original stream. Used for cache indexing. + * The default value is {@code null}. + * + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for + * cache indexing. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setCustomCacheKey(String customCacheKey) { + Assertions.checkState(!isCreateCalled); + this.customCacheKey = customCacheKey; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the minimum number of times to retry if a loading error occurs. See {@link + * #setLoadErrorHandlingPolicy} for the default value. + * + * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with + * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int) + * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)} + * + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. + */ + @Deprecated + public Factory setMinLoadableRetryCount(int minLoadableRetryCount) { + return setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)); + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the number of bytes that should be loaded between each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. The default value is + * {@link #DEFAULT_LOADING_CHECK_INTERVAL_BYTES}. + * + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between + * each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setContinueLoadingCheckIntervalBytes(int continueLoadingCheckIntervalBytes) { + Assertions.checkState(!isCreateCalled); + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + return this; + } + + /** @deprecated Use {@link ProgressiveMediaSource.Factory#setDrmSessionManager} instead. */ + @Override + @Deprecated + public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) { + throw new UnsupportedOperationException(); + } + + /** + * Returns a new {@link ExtractorMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @return The new {@link ExtractorMediaSource}. + */ + @Override + public ExtractorMediaSource createMediaSource(Uri uri) { + isCreateCalled = true; + if (extractorsFactory == null) { + extractorsFactory = new DefaultExtractorsFactory(); + } + return new ExtractorMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + loadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + /** + * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * MediaSourceEventListener)} instead. + */ + @Deprecated + public ExtractorMediaSource createMediaSource( + Uri uri, @Nullable Handler eventHandler, @Nullable MediaSourceEventListener eventListener) { + ExtractorMediaSource mediaSource = createMediaSource(uri); + if (eventHandler != null && eventListener != null) { + mediaSource.addEventListener(eventHandler, eventListener); + } + return mediaSource; + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_OTHER}; + } + } + + /** + * @deprecated Use {@link ProgressiveMediaSource#DEFAULT_LOADING_CHECK_INTERVAL_BYTES} instead. + */ + @Deprecated + public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = + ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + + private final ProgressiveMediaSource progressiveMediaSource; + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + @Nullable Handler eventHandler, + @Nullable EventListener eventListener) { + this(uri, dataSourceFactory, extractorsFactory, eventHandler, eventListener, null); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + @Nullable Handler eventHandler, + @Nullable EventListener eventListener, + @Nullable String customCacheKey) { + this( + uri, + dataSourceFactory, + extractorsFactory, + eventHandler, + eventListener, + customCacheKey, + DEFAULT_LOADING_CHECK_INTERVAL_BYTES); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those formats. + * Otherwise, pass a {@link DefaultExtractorsFactory} to use default extractors. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each + * invocation of {@link MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + @Nullable Handler eventHandler, + @Nullable EventListener eventListener, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes) { + this( + uri, + dataSourceFactory, + extractorsFactory, + new DefaultLoadErrorHandlingPolicy(), + customCacheKey, + continueLoadingCheckIntervalBytes, + /* tag= */ null); + if (eventListener != null && eventHandler != null) { + addEventListener(eventHandler, new EventListenerWrapper(eventListener)); + } + } + + private ExtractorMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes, + @Nullable Object tag) { + progressiveMediaSource = + new ProgressiveMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + DrmSessionManager.getDummyDrmSessionManager(), + loadableLoadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + @Override + @Nullable + public Object getTag() { + return progressiveMediaSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(/* id= */ null, progressiveMediaSource); + } + + @Override + protected void onChildSourceInfoRefreshed( + @Nullable Void id, MediaSource mediaSource, Timeline timeline) { + refreshSourceInfo(timeline); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return progressiveMediaSource.createPeriod(id, allocator, startPositionUs); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + progressiveMediaSource.releasePeriod(mediaPeriod); + } + + @Deprecated + private static final class EventListenerWrapper implements MediaSourceEventListener { + + private final EventListener eventListener; + + public EventListenerWrapper(EventListener eventListener) { + this.eventListener = Assertions.checkNotNull(eventListener); + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + eventListener.onLoadError(error); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ForwardingTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ForwardingTimeline.java new file mode 100644 index 0000000000..ce985708d0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ForwardingTimeline.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; + +/** + * An overridable {@link Timeline} implementation forwarding all methods to another timeline. + */ +public abstract class ForwardingTimeline extends Timeline { + + protected final Timeline timeline; + + public ForwardingTimeline(Timeline timeline) { + this.timeline = timeline; + } + + @Override + public int getWindowCount() { + return timeline.getWindowCount(); + } + + @Override + public int getNextWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + return timeline.getNextWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + } + + @Override + public int getPreviousWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + return timeline.getPreviousWindowIndex(windowIndex, repeatMode, shuffleModeEnabled); + } + + @Override + public int getLastWindowIndex(boolean shuffleModeEnabled) { + return timeline.getLastWindowIndex(shuffleModeEnabled); + } + + @Override + public int getFirstWindowIndex(boolean shuffleModeEnabled) { + return timeline.getFirstWindowIndex(shuffleModeEnabled); + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + return timeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + } + + @Override + public int getPeriodCount() { + return timeline.getPeriodCount(); + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + return timeline.getPeriod(periodIndex, period, setIds); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return timeline.getIndexOfPeriod(uid); + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + return timeline.getUidOfPeriod(periodIndex); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/IcyDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/IcyDataSource.java new file mode 100644 index 0000000000..b35525743a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/IcyDataSource.java @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Splits ICY stream metadata out from a stream. + * + * <p>Note: {@link #open(DataSpec)} and {@link #close()} are not supported. This implementation is + * intended to wrap upstream {@link DataSource} instances that are opened and closed directly. + */ +/* package */ final class IcyDataSource implements DataSource { + + public interface Listener { + + /** + * Called when ICY stream metadata has been split from the stream. + * + * @param metadata The stream metadata in binary form. + */ + void onIcyMetadata(ParsableByteArray metadata); + } + + private final DataSource upstream; + private final int metadataIntervalBytes; + private final Listener listener; + private final byte[] metadataLengthByteHolder; + private int bytesUntilMetadata; + + /** + * @param upstream The upstream {@link DataSource}. + * @param metadataIntervalBytes The interval between ICY stream metadata, in bytes. + * @param listener A listener to which stream metadata is delivered. + */ + public IcyDataSource(DataSource upstream, int metadataIntervalBytes, Listener listener) { + Assertions.checkArgument(metadataIntervalBytes > 0); + this.upstream = upstream; + this.metadataIntervalBytes = metadataIntervalBytes; + this.listener = listener; + metadataLengthByteHolder = new byte[1]; + bytesUntilMetadata = metadataIntervalBytes; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + throw new UnsupportedOperationException(); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + if (bytesUntilMetadata == 0) { + if (readMetadata()) { + bytesUntilMetadata = metadataIntervalBytes; + } else { + return C.RESULT_END_OF_INPUT; + } + } + int bytesRead = upstream.read(buffer, offset, Math.min(bytesUntilMetadata, readLength)); + if (bytesRead != C.RESULT_END_OF_INPUT) { + bytesUntilMetadata -= bytesRead; + } + return bytesRead; + } + + @Nullable + @Override + public Uri getUri() { + return upstream.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + throw new UnsupportedOperationException(); + } + + /** + * Reads an ICY stream metadata block, passing it to {@link #listener} unless the block is empty. + * + * @return True if the block was extracted, including if its length byte indicated a length of + * zero. False if the end of the stream was reached. + * @throws IOException If an error occurs reading from the wrapped {@link DataSource}. + */ + private boolean readMetadata() throws IOException { + int bytesRead = upstream.read(metadataLengthByteHolder, 0, 1); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return false; + } + int metadataLength = (metadataLengthByteHolder[0] & 0xFF) << 4; + if (metadataLength == 0) { + return true; + } + + int offset = 0; + int lengthRemaining = metadataLength; + byte[] metadata = new byte[metadataLength]; + while (lengthRemaining > 0) { + bytesRead = upstream.read(metadata, offset, lengthRemaining); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return false; + } + offset += bytesRead; + lengthRemaining -= bytesRead; + } + + // Discard trailing zero bytes. + while (metadataLength > 0 && metadata[metadataLength - 1] == 0) { + metadataLength--; + } + + if (metadataLength > 0) { + listener.onIcyMetadata(new ParsableByteArray(metadata, metadataLength)); + } + return true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/LoopingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/LoopingMediaSource.java new file mode 100644 index 0000000000..880bfd6a4f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/LoopingMediaSource.java @@ -0,0 +1,214 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ShuffleOrder.UnshuffledShuffleOrder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.HashMap; +import java.util.Map; + +/** + * Loops a {@link MediaSource} a specified number of times. + * + * <p>Note: To loop a {@link MediaSource} indefinitely, it is usually better to use {@link + * ExoPlayer#setRepeatMode(int)} instead of this class. + */ +public final class LoopingMediaSource extends CompositeMediaSource<Void> { + + private final MediaSource childSource; + private final int loopCount; + private final Map<MediaPeriodId, MediaPeriodId> childMediaPeriodIdToMediaPeriodId; + private final Map<MediaPeriod, MediaPeriodId> mediaPeriodToChildMediaPeriodId; + + /** + * Loops the provided source indefinitely. Note that it is usually better to use + * {@link ExoPlayer#setRepeatMode(int)}. + * + * @param childSource The {@link MediaSource} to loop. + */ + public LoopingMediaSource(MediaSource childSource) { + this(childSource, Integer.MAX_VALUE); + } + + /** + * Loops the provided source a specified number of times. + * + * @param childSource The {@link MediaSource} to loop. + * @param loopCount The desired number of loops. Must be strictly positive. + */ + public LoopingMediaSource(MediaSource childSource, int loopCount) { + Assertions.checkArgument(loopCount > 0); + this.childSource = childSource; + this.loopCount = loopCount; + childMediaPeriodIdToMediaPeriodId = new HashMap<>(); + mediaPeriodToChildMediaPeriodId = new HashMap<>(); + } + + @Override + @Nullable + public Object getTag() { + return childSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + prepareChildSource(/* id= */ null, childSource); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + if (loopCount == Integer.MAX_VALUE) { + return childSource.createPeriod(id, allocator, startPositionUs); + } + Object childPeriodUid = LoopingTimeline.getChildPeriodUidFromConcatenatedUid(id.periodUid); + MediaPeriodId childMediaPeriodId = id.copyWithPeriodUid(childPeriodUid); + childMediaPeriodIdToMediaPeriodId.put(childMediaPeriodId, id); + MediaPeriod mediaPeriod = + childSource.createPeriod(childMediaPeriodId, allocator, startPositionUs); + mediaPeriodToChildMediaPeriodId.put(mediaPeriod, childMediaPeriodId); + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + childSource.releasePeriod(mediaPeriod); + MediaPeriodId childMediaPeriodId = mediaPeriodToChildMediaPeriodId.remove(mediaPeriod); + if (childMediaPeriodId != null) { + childMediaPeriodIdToMediaPeriodId.remove(childMediaPeriodId); + } + } + + @Override + protected void onChildSourceInfoRefreshed(Void id, MediaSource mediaSource, Timeline timeline) { + Timeline loopingTimeline = + loopCount != Integer.MAX_VALUE + ? new LoopingTimeline(timeline, loopCount) + : new InfinitelyLoopingTimeline(timeline); + refreshSourceInfo(loopingTimeline); + } + + @Override + protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Void id, MediaPeriodId mediaPeriodId) { + return loopCount != Integer.MAX_VALUE + ? childMediaPeriodIdToMediaPeriodId.get(mediaPeriodId) + : mediaPeriodId; + } + + private static final class LoopingTimeline extends AbstractConcatenatedTimeline { + + private final Timeline childTimeline; + private final int childPeriodCount; + private final int childWindowCount; + private final int loopCount; + + public LoopingTimeline(Timeline childTimeline, int loopCount) { + super(/* isAtomic= */ false, new UnshuffledShuffleOrder(loopCount)); + this.childTimeline = childTimeline; + childPeriodCount = childTimeline.getPeriodCount(); + childWindowCount = childTimeline.getWindowCount(); + this.loopCount = loopCount; + if (childPeriodCount > 0) { + Assertions.checkState(loopCount <= Integer.MAX_VALUE / childPeriodCount, + "LoopingMediaSource contains too many periods"); + } + } + + @Override + public int getWindowCount() { + return childWindowCount * loopCount; + } + + @Override + public int getPeriodCount() { + return childPeriodCount * loopCount; + } + + @Override + protected int getChildIndexByPeriodIndex(int periodIndex) { + return periodIndex / childPeriodCount; + } + + @Override + protected int getChildIndexByWindowIndex(int windowIndex) { + return windowIndex / childWindowCount; + } + + @Override + protected int getChildIndexByChildUid(Object childUid) { + if (!(childUid instanceof Integer)) { + return C.INDEX_UNSET; + } + return (Integer) childUid; + } + + @Override + protected Timeline getTimelineByChildIndex(int childIndex) { + return childTimeline; + } + + @Override + protected int getFirstPeriodIndexByChildIndex(int childIndex) { + return childIndex * childPeriodCount; + } + + @Override + protected int getFirstWindowIndexByChildIndex(int childIndex) { + return childIndex * childWindowCount; + } + + @Override + protected Object getChildUidByChildIndex(int childIndex) { + return childIndex; + } + + } + + private static final class InfinitelyLoopingTimeline extends ForwardingTimeline { + + public InfinitelyLoopingTimeline(Timeline timeline) { + super(timeline); + } + + @Override + public int getNextWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + int childNextWindowIndex = timeline.getNextWindowIndex(windowIndex, repeatMode, + shuffleModeEnabled); + return childNextWindowIndex == C.INDEX_UNSET ? getFirstWindowIndex(shuffleModeEnabled) + : childNextWindowIndex; + } + + @Override + public int getPreviousWindowIndex(int windowIndex, @Player.RepeatMode int repeatMode, + boolean shuffleModeEnabled) { + int childPreviousWindowIndex = timeline.getPreviousWindowIndex(windowIndex, repeatMode, + shuffleModeEnabled); + return childPreviousWindowIndex == C.INDEX_UNSET ? getLastWindowIndex(shuffleModeEnabled) + : childPreviousWindowIndex; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaPeriod.java new file mode 100644 index 0000000000..4fe7b137b6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaPeriod.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import java.io.IOException; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Media period that wraps a media source and defers calling its {@link + * MediaSource#createPeriod(MediaPeriodId, Allocator, long)} method until {@link + * #createPeriod(MediaPeriodId)} has been called. This is useful if you need to return a media + * period immediately but the media source that should create it is not yet prepared. + */ +public final class MaskingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + /** Listener for preparation errors. */ + public interface PrepareErrorListener { + + /** + * Called the first time an error occurs while refreshing source info or preparing the period. + */ + void onPrepareError(MediaPeriodId mediaPeriodId, IOException exception); + } + + /** The {@link MediaSource} which will create the actual media period. */ + public final MediaSource mediaSource; + /** The {@link MediaPeriodId} used to create the masking media period. */ + public final MediaPeriodId id; + + private final Allocator allocator; + + @Nullable private MediaPeriod mediaPeriod; + @Nullable private Callback callback; + private long preparePositionUs; + @Nullable private PrepareErrorListener listener; + private boolean notifiedPrepareError; + private long preparePositionOverrideUs; + + /** + * Creates a new masking media period. + * + * @param mediaSource The media source to wrap. + * @param id The identifier used to create the masking media period. + * @param allocator The allocator used to create the media period. + * @param preparePositionUs The expected start position, in microseconds. + */ + public MaskingMediaPeriod( + MediaSource mediaSource, MediaPeriodId id, Allocator allocator, long preparePositionUs) { + this.id = id; + this.allocator = allocator; + this.mediaSource = mediaSource; + this.preparePositionUs = preparePositionUs; + preparePositionOverrideUs = C.TIME_UNSET; + } + + /** + * Sets a listener for preparation errors. + * + * @param listener An listener to be notified of media period preparation errors. If a listener is + * set, {@link #maybeThrowPrepareError()} will not throw but will instead pass the first + * preparation error (if any) to the listener. + */ + public void setPrepareErrorListener(PrepareErrorListener listener) { + this.listener = listener; + } + + /** Returns the position at which the masking media period was prepared, in microseconds. */ + public long getPreparePositionUs() { + return preparePositionUs; + } + + /** + * Overrides the default prepare position at which to prepare the media period. This value is only + * used if called before {@link #createPeriod(MediaPeriodId)}. + * + * @param preparePositionUs The default prepare position to use, in microseconds. + */ + public void overridePreparePositionUs(long preparePositionUs) { + preparePositionOverrideUs = preparePositionUs; + } + + /** + * Calls {@link MediaSource#createPeriod(MediaPeriodId, Allocator, long)} on the wrapped source + * then prepares it if {@link #prepare(Callback, long)} has been called. Call {@link + * #releasePeriod()} to release the period. + * + * @param id The identifier that should be used to create the media period from the media source. + */ + public void createPeriod(MediaPeriodId id) { + long preparePositionUs = getPreparePositionWithOverride(this.preparePositionUs); + mediaPeriod = mediaSource.createPeriod(id, allocator, preparePositionUs); + if (callback != null) { + mediaPeriod.prepare(this, preparePositionUs); + } + } + + /** + * Releases the period. + */ + public void releasePeriod() { + if (mediaPeriod != null) { + mediaSource.releasePeriod(mediaPeriod); + } + } + + @Override + public void prepare(Callback callback, long preparePositionUs) { + this.callback = callback; + if (mediaPeriod != null) { + mediaPeriod.prepare(this, getPreparePositionWithOverride(this.preparePositionUs)); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + try { + if (mediaPeriod != null) { + mediaPeriod.maybeThrowPrepareError(); + } else { + mediaSource.maybeThrowSourceInfoRefreshError(); + } + } catch (final IOException e) { + if (listener == null) { + throw e; + } + if (!notifiedPrepareError) { + notifiedPrepareError = true; + listener.onPrepareError(id, e); + } + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return castNonNull(mediaPeriod).getTrackGroups(); + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + if (preparePositionOverrideUs != C.TIME_UNSET && positionUs == preparePositionUs) { + positionUs = preparePositionOverrideUs; + preparePositionOverrideUs = C.TIME_UNSET; + } + return castNonNull(mediaPeriod) + .selectTracks(selections, mayRetainStreamFlags, streams, streamResetFlags, positionUs); + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + castNonNull(mediaPeriod).discardBuffer(positionUs, toKeyframe); + } + + @Override + public long readDiscontinuity() { + return castNonNull(mediaPeriod).readDiscontinuity(); + } + + @Override + public long getBufferedPositionUs() { + return castNonNull(mediaPeriod).getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + return castNonNull(mediaPeriod).seekToUs(positionUs); + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return castNonNull(mediaPeriod).getAdjustedSeekPositionUs(positionUs, seekParameters); + } + + @Override + public long getNextLoadPositionUs() { + return castNonNull(mediaPeriod).getNextLoadPositionUs(); + } + + @Override + public void reevaluateBuffer(long positionUs) { + castNonNull(mediaPeriod).reevaluateBuffer(positionUs); + } + + @Override + public boolean continueLoading(long positionUs) { + return mediaPeriod != null && mediaPeriod.continueLoading(positionUs); + } + + @Override + public boolean isLoading() { + return mediaPeriod != null && mediaPeriod.isLoading(); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod source) { + castNonNull(callback).onContinueLoadingRequested(this); + } + + // MediaPeriod.Callback implementation + + @Override + public void onPrepared(MediaPeriod mediaPeriod) { + castNonNull(callback).onPrepared(this); + } + + private long getPreparePositionWithOverride(long preparePositionUs) { + return preparePositionOverrideUs != C.TIME_UNSET + ? preparePositionOverrideUs + : preparePositionUs; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaSource.java new file mode 100644 index 0000000000..8c867a8c26 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MaskingMediaSource.java @@ -0,0 +1,353 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A {@link MediaSource} that masks the {@link Timeline} with a placeholder until the actual media + * structure is known. + */ +public final class MaskingMediaSource extends CompositeMediaSource<Void> { + + private final MediaSource mediaSource; + private final boolean useLazyPreparation; + private final Timeline.Window window; + private final Timeline.Period period; + + private MaskingTimeline timeline; + @Nullable private MaskingMediaPeriod unpreparedMaskingMediaPeriod; + @Nullable private EventDispatcher unpreparedMaskingMediaPeriodEventDispatcher; + private boolean hasStartedPreparing; + private boolean isPrepared; + + /** + * Creates the masking media source. + * + * @param mediaSource A {@link MediaSource}. + * @param useLazyPreparation Whether the {@code mediaSource} is prepared lazily. If false, all + * manifest loads and other initial preparation steps happen immediately. If true, these + * initial preparations are triggered only when the player starts buffering the media. + */ + public MaskingMediaSource(MediaSource mediaSource, boolean useLazyPreparation) { + this.mediaSource = mediaSource; + this.useLazyPreparation = useLazyPreparation; + window = new Timeline.Window(); + period = new Timeline.Period(); + timeline = MaskingTimeline.createWithDummyTimeline(mediaSource.getTag()); + } + + /** Returns the {@link Timeline}. */ + public Timeline getTimeline() { + return timeline; + } + + @Override + public void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + if (!useLazyPreparation) { + hasStartedPreparing = true; + prepareChildSource(/* id= */ null, mediaSource); + } + } + + @Nullable + @Override + public Object getTag() { + return mediaSource.getTag(); + } + + @Override + @SuppressWarnings("MissingSuperCall") + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. Source info refresh errors will be thrown when calling + // MaskingMediaPeriod.maybeThrowPrepareError. + } + + @Override + public MaskingMediaPeriod createPeriod( + MediaPeriodId id, Allocator allocator, long startPositionUs) { + MaskingMediaPeriod mediaPeriod = + new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); + if (isPrepared) { + MediaPeriodId idInSource = id.copyWithPeriodUid(getInternalPeriodUid(id.periodUid)); + mediaPeriod.createPeriod(idInSource); + } else { + // We should have at most one media period while source is unprepared because the duration is + // unset and we don't load beyond periods with unset duration. We need to figure out how to + // handle the prepare positions of multiple deferred media periods, should that ever change. + unpreparedMaskingMediaPeriod = mediaPeriod; + unpreparedMaskingMediaPeriodEventDispatcher = + createEventDispatcher(/* windowIndex= */ 0, id, /* mediaTimeOffsetMs= */ 0); + unpreparedMaskingMediaPeriodEventDispatcher.mediaPeriodCreated(); + if (!hasStartedPreparing) { + hasStartedPreparing = true; + prepareChildSource(/* id= */ null, mediaSource); + } + } + return mediaPeriod; + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((MaskingMediaPeriod) mediaPeriod).releasePeriod(); + if (mediaPeriod == unpreparedMaskingMediaPeriod) { + Assertions.checkNotNull(unpreparedMaskingMediaPeriodEventDispatcher).mediaPeriodReleased(); + unpreparedMaskingMediaPeriodEventDispatcher = null; + unpreparedMaskingMediaPeriod = null; + } + } + + @Override + public void releaseSourceInternal() { + isPrepared = false; + hasStartedPreparing = false; + super.releaseSourceInternal(); + } + + @Override + protected void onChildSourceInfoRefreshed( + Void id, MediaSource mediaSource, Timeline newTimeline) { + if (isPrepared) { + timeline = timeline.cloneWithUpdatedTimeline(newTimeline); + } else if (newTimeline.isEmpty()) { + timeline = + MaskingTimeline.createWithRealTimeline( + newTimeline, Window.SINGLE_WINDOW_UID, MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID); + } else { + // Determine first period and the start position. + // This will be: + // 1. The default window start position if no deferred period has been created yet. + // 2. The non-zero prepare position of the deferred period under the assumption that this is + // a non-zero initial seek position in the window. + // 3. The default window start position if the deferred period has a prepare position of zero + // under the assumption that the prepare position of zero was used because it's the + // default position of the DummyTimeline window. Note that this will override an + // intentional seek to zero for a window with a non-zero default position. This is + // unlikely to be a problem as a non-zero default position usually only occurs for live + // playbacks and seeking to zero in a live window would cause BehindLiveWindowExceptions + // anyway. + newTimeline.getWindow(/* windowIndex= */ 0, window); + long windowStartPositionUs = window.getDefaultPositionUs(); + if (unpreparedMaskingMediaPeriod != null) { + long periodPreparePositionUs = unpreparedMaskingMediaPeriod.getPreparePositionUs(); + if (periodPreparePositionUs != 0) { + windowStartPositionUs = periodPreparePositionUs; + } + } + Object windowUid = window.uid; + Pair<Object, Long> periodPosition = + newTimeline.getPeriodPosition( + window, period, /* windowIndex= */ 0, windowStartPositionUs); + Object periodUid = periodPosition.first; + long periodPositionUs = periodPosition.second; + timeline = MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid); + if (unpreparedMaskingMediaPeriod != null) { + MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod; + maskingPeriod.overridePreparePositionUs(periodPositionUs); + MediaPeriodId idInSource = + maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid)); + maskingPeriod.createPeriod(idInSource); + } + } + isPrepared = true; + refreshSourceInfo(this.timeline); + } + + @Nullable + @Override + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Void id, MediaPeriodId mediaPeriodId) { + return mediaPeriodId.copyWithPeriodUid(getExternalPeriodUid(mediaPeriodId.periodUid)); + } + + @Override + protected boolean shouldDispatchCreateOrReleaseEvent(MediaPeriodId mediaPeriodId) { + // Suppress create and release events for the period created while the source was still + // unprepared, as we send these events from this class. + return unpreparedMaskingMediaPeriod == null + || !mediaPeriodId.equals(unpreparedMaskingMediaPeriod.id); + } + + private Object getInternalPeriodUid(Object externalPeriodUid) { + return externalPeriodUid.equals(MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID) + ? timeline.replacedInternalPeriodUid + : externalPeriodUid; + } + + private Object getExternalPeriodUid(Object internalPeriodUid) { + return timeline.replacedInternalPeriodUid.equals(internalPeriodUid) + ? MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID + : internalPeriodUid; + } + + /** + * Timeline used as placeholder for an unprepared media source. After preparation, a + * MaskingTimeline is used to keep the originally assigned dummy period ID. + */ + private static final class MaskingTimeline extends ForwardingTimeline { + + public static final Object DUMMY_EXTERNAL_PERIOD_UID = new Object(); + + private final Object replacedInternalWindowUid; + private final Object replacedInternalPeriodUid; + + /** + * Returns an instance with a dummy timeline using the provided window tag. + * + * @param windowTag A window tag. + */ + public static MaskingTimeline createWithDummyTimeline(@Nullable Object windowTag) { + return new MaskingTimeline( + new DummyTimeline(windowTag), Window.SINGLE_WINDOW_UID, DUMMY_EXTERNAL_PERIOD_UID); + } + + /** + * Returns an instance with a real timeline, replacing the provided period ID with the already + * assigned dummy period ID. + * + * @param timeline The real timeline. + * @param firstWindowUid The window UID in the timeline which will be replaced by the already + * assigned {@link Window#SINGLE_WINDOW_UID}. + * @param firstPeriodUid The period UID in the timeline which will be replaced by the already + * assigned {@link #DUMMY_EXTERNAL_PERIOD_UID}. + */ + public static MaskingTimeline createWithRealTimeline( + Timeline timeline, Object firstWindowUid, Object firstPeriodUid) { + return new MaskingTimeline(timeline, firstWindowUid, firstPeriodUid); + } + + private MaskingTimeline( + Timeline timeline, Object replacedInternalWindowUid, Object replacedInternalPeriodUid) { + super(timeline); + this.replacedInternalWindowUid = replacedInternalWindowUid; + this.replacedInternalPeriodUid = replacedInternalPeriodUid; + } + + /** + * Returns a copy with an updated timeline. This keeps the existing period replacement. + * + * @param timeline The new timeline. + */ + public MaskingTimeline cloneWithUpdatedTimeline(Timeline timeline) { + return new MaskingTimeline(timeline, replacedInternalWindowUid, replacedInternalPeriodUid); + } + + /** Returns the wrapped timeline. */ + public Timeline getTimeline() { + return timeline; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + timeline.getWindow(windowIndex, window, defaultPositionProjectionUs); + if (Util.areEqual(window.uid, replacedInternalWindowUid)) { + window.uid = Window.SINGLE_WINDOW_UID; + } + return window; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + timeline.getPeriod(periodIndex, period, setIds); + if (Util.areEqual(period.uid, replacedInternalPeriodUid)) { + period.uid = DUMMY_EXTERNAL_PERIOD_UID; + } + return period; + } + + @Override + public int getIndexOfPeriod(Object uid) { + return timeline.getIndexOfPeriod( + DUMMY_EXTERNAL_PERIOD_UID.equals(uid) ? replacedInternalPeriodUid : uid); + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + Object uid = timeline.getUidOfPeriod(periodIndex); + return Util.areEqual(uid, replacedInternalPeriodUid) ? DUMMY_EXTERNAL_PERIOD_UID : uid; + } + } + + /** Dummy placeholder timeline with one dynamic window with a period of indeterminate duration. */ + private static final class DummyTimeline extends Timeline { + + @Nullable private final Object tag; + + public DummyTimeline(@Nullable Object tag) { + this.tag = tag; + } + + @Override + public int getWindowCount() { + return 1; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + return window.set( + Window.SINGLE_WINDOW_UID, + tag, + /* manifest= */ null, + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + /* isSeekable= */ false, + // Dynamic window to indicate pending timeline updates. + /* isDynamic= */ true, + /* isLive= */ false, + /* defaultPositionUs= */ 0, + /* durationUs= */ C.TIME_UNSET, + /* firstPeriodIndex= */ 0, + /* lastPeriodIndex= */ 0, + /* positionInFirstPeriodUs= */ 0); + } + + @Override + public int getPeriodCount() { + return 1; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + return period.set( + /* id= */ 0, + /* uid= */ MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID, + /* windowIndex= */ 0, + /* durationUs = */ C.TIME_UNSET, + /* positionInWindowUs= */ 0); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return uid == MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID ? 0 : C.INDEX_UNSET; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + return MaskingTimeline.DUMMY_EXTERNAL_PERIOD_UID; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaPeriod.java new file mode 100644 index 0000000000..3effcec904 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaPeriod.java @@ -0,0 +1,251 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaSourceCaller; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Loads media corresponding to a {@link Timeline.Period}, and allows that media to be read. All + * methods are called on the player's internal playback thread, as described in the + * {@link ExoPlayer} Javadoc. + */ +public interface MediaPeriod extends SequenceableLoader { + + /** + * A callback to be notified of {@link MediaPeriod} events. + */ + interface Callback extends SequenceableLoader.Callback<MediaPeriod> { + + /** + * Called when preparation completes. + * + * <p>Called on the playback thread. After invoking this method, the {@link MediaPeriod} can + * expect for {@link #selectTracks(TrackSelection[], boolean[], SampleStream[], boolean[], + * long)} to be called with the initial track selection. + * + * @param mediaPeriod The prepared {@link MediaPeriod}. + */ + void onPrepared(MediaPeriod mediaPeriod); + } + + /** + * Prepares this media period asynchronously. + * + * <p>{@code callback.onPrepared} is called when preparation completes. If preparation fails, + * {@link #maybeThrowPrepareError()} will throw an {@link IOException}. + * + * <p>If preparation succeeds and results in a source timeline change (e.g. the period duration + * becoming known), {@link MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} will be + * called before {@code callback.onPrepared}. + * + * @param callback Callback to receive updates from this period, including being notified when + * preparation completes. + * @param positionUs The expected starting position, in microseconds. + */ + void prepare(Callback callback, long positionUs); + + /** + * Throws an error that's preventing the period from becoming prepared. Does nothing if no such + * error exists. + * + * <p>This method is only called before the period has completed preparation. + * + * @throws IOException The underlying error. + */ + void maybeThrowPrepareError() throws IOException; + + /** + * Returns the {@link TrackGroup}s exposed by the period. + * + * <p>This method is only called after the period has been prepared. + * + * @return The {@link TrackGroup}s. + */ + TrackGroupArray getTrackGroups(); + + /** + * Returns a list of {@link StreamKey StreamKeys} which allow to filter the media in this period + * to load only the parts needed to play the provided {@link TrackSelection TrackSelections}. + * + * <p>This method is only called after the period has been prepared. + * + * @param trackSelections The {@link TrackSelection TrackSelections} describing the tracks for + * which stream keys are requested. + * @return The corresponding {@link StreamKey StreamKeys} for the selected tracks, or an empty + * list if filtering is not possible and the entire media needs to be loaded to play the + * selected tracks. + */ + default List<StreamKey> getStreamKeys(List<TrackSelection> trackSelections) { + return Collections.emptyList(); + } + + /** + * Performs a track selection. + * + * <p>The call receives track {@code selections} for each renderer, {@code mayRetainStreamFlags} + * indicating whether the existing {@link SampleStream} can be retained for each selection, and + * the existing {@code stream}s themselves. The call will update {@code streams} to reflect the + * provided selections, clearing, setting and replacing entries as required. If an existing sample + * stream is retained but with the requirement that the consuming renderer be reset, then the + * corresponding flag in {@code streamResetFlags} will be set to true. This flag will also be set + * if a new sample stream is created. + * + * <p>Note that previously passed {@link TrackSelection TrackSelections} are no longer valid, and + * any references to them must be updated to point to the new selections. + * + * <p>This method is only called after the period has been prepared. + * + * @param selections The renderer track selections. + * @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained + * for each track selection. A {@code true} value indicates that the selection is equivalent + * to the one that was previously passed, and that the caller does not require that the sample + * stream be recreated. If a retained sample stream holds any references to the track + * selection then they must be updated to point to the new selection. + * @param streams The existing sample streams, which will be updated to reflect the provided + * selections. + * @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that + * have been retained but with the requirement that the consuming renderer be reset. + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position. + * @return The actual position at which the tracks were enabled, in microseconds. + */ + long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs); + + /** + * Discards buffered media up to the specified position. + * + * <p>This method is only called after the period has been prepared. + * + * @param positionUs The position in microseconds. + * @param toKeyframe If true then for each track discards samples up to the keyframe before or at + * the specified position, rather than any sample before or at that position. + */ + void discardBuffer(long positionUs, boolean toKeyframe); + + /** + * Attempts to read a discontinuity. + * + * <p>After this method has returned a value other than {@link C#TIME_UNSET}, all {@link + * SampleStream}s provided by the period are guaranteed to start from a key frame. + * + * <p>This method is only called after the period has been prepared and before reading from any + * {@link SampleStream}s provided by the period. + * + * @return If a discontinuity was read then the playback position in microseconds after the + * discontinuity. Else {@link C#TIME_UNSET}. + */ + long readDiscontinuity(); + + /** + * Attempts to seek to the specified position in microseconds. + * + * <p>After this method has been called, all {@link SampleStream}s provided by the period are + * guaranteed to start from a key frame. + * + * <p>This method is only called when at least one track is selected. + * + * @param positionUs The seek position in microseconds. + * @return The actual position to which the period was seeked, in microseconds. + */ + long seekToUs(long positionUs); + + /** + * Returns the position to which a seek will be performed, given the specified seek position and + * {@link SeekParameters}. + * + * <p>This method is only called after the period has been prepared. + * + * @param positionUs The seek position in microseconds. + * @param seekParameters Parameters that control how the seek is performed. Implementations may + * apply seek parameters on a best effort basis. + * @return The actual position to which a seek will be performed, in microseconds. + */ + long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters); + + // SequenceableLoader interface. Overridden to provide more specific documentation. + + /** + * Returns an estimate of the position up to which data is buffered for the enabled tracks. + * + * <p>This method is only called when at least one track is selected. + * + * @return An estimate of the absolute position in microseconds up to which data is buffered, or + * {@link C#TIME_END_OF_SOURCE} if the track is fully buffered. + */ + @Override + long getBufferedPositionUs(); + + /** + * Returns the next load time, or {@link C#TIME_END_OF_SOURCE} if loading has finished. + * + * <p>This method is only called after the period has been prepared. It may be called when no + * tracks are selected. + */ + @Override + long getNextLoadPositionUs(); + + /** + * Attempts to continue loading. + * + * <p>This method may be called both during and after the period has been prepared. + * + * <p>A period may call {@link Callback#onContinueLoadingRequested(SequenceableLoader)} on the + * {@link Callback} passed to {@link #prepare(Callback, long)} to request that this method be + * called when the period is permitted to continue loading data. A period may do this both during + * and after preparation. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + * @return True if progress was made, meaning that {@link #getNextLoadPositionUs()} will return a + * different value than prior to the call. False otherwise. + */ + @Override + boolean continueLoading(long positionUs); + + /** Returns whether the media period is currently loading. */ + boolean isLoading(); + + /** + * Re-evaluates the buffer given the playback position. + * + * <p>This method is only called after the period has been prepared. + * + * <p>A period may choose to discard buffered media so that it can be re-buffered in a different + * quality. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + */ + @Override + void reevaluateBuffer(long positionUs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSource.java new file mode 100644 index 0000000000..7e757d5ade --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSource.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import java.io.IOException; + +/** + * Defines and provides media to be played by an {@link org.mozilla.thirdparty.com.google.android.exoplayer2ExoPlayer}. A + * MediaSource has two main responsibilities: + * + * <ul> + * <li>To provide the player with a {@link Timeline} defining the structure of its media, and to + * provide a new timeline whenever the structure of the media changes. The MediaSource + * provides these timelines by calling {@link MediaSourceCaller#onSourceInfoRefreshed} on the + * {@link MediaSourceCaller}s passed to {@link #prepareSource(MediaSourceCaller, + * TransferListener)}. + * <li>To provide {@link MediaPeriod} instances for the periods in its timeline. MediaPeriods are + * obtained by calling {@link #createPeriod(MediaPeriodId, Allocator, long)}, and provide a + * way for the player to load and read the media. + * </ul> + * + * All methods are called on the player's internal playback thread, as described in the {@link + * com.google.android.exoplayer2.ExoPlayer} Javadoc. They should not be called directly from + * application code. Instances can be re-used, but only for one {@link + * com.google.android.exoplayer2.ExoPlayer} instance simultaneously. + */ +public interface MediaSource { + + /** A caller of media sources, which will be notified of source events. */ + interface MediaSourceCaller { + + /** + * Called when the {@link Timeline} has been refreshed. + * + * <p>Called on the playback thread. + * + * @param source The {@link MediaSource} whose info has been refreshed. + * @param timeline The source's timeline. + */ + void onSourceInfoRefreshed(MediaSource source, Timeline timeline); + } + + /** Identifier for a {@link MediaPeriod}. */ + final class MediaPeriodId { + + /** The unique id of the timeline period. */ + public final Object periodUid; + + /** + * If the media period is in an ad group, the index of the ad group in the period. + * {@link C#INDEX_UNSET} otherwise. + */ + public final int adGroupIndex; + + /** + * If the media period is in an ad group, the index of the ad in its ad group in the period. + * {@link C#INDEX_UNSET} otherwise. + */ + public final int adIndexInAdGroup; + + /** + * The sequence number of the window in the buffered sequence of windows this media period is + * part of. {@link C#INDEX_UNSET} if the media period id is not part of a buffered sequence of + * windows. + */ + public final long windowSequenceNumber; + + /** + * The index of the next ad group to which the media period's content is clipped, or {@link + * C#INDEX_UNSET} if there is no following ad group or if this media period is an ad. + */ + public final int nextAdGroupIndex; + + /** + * Creates a media period identifier for a dummy period which is not part of a buffered sequence + * of windows. + * + * @param periodUid The unique id of the timeline period. + */ + public MediaPeriodId(Object periodUid) { + this(periodUid, /* windowSequenceNumber= */ C.INDEX_UNSET); + } + + /** + * Creates a media period identifier for the specified period in the timeline. + * + * @param periodUid The unique id of the timeline period. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this media period is part of. + */ + public MediaPeriodId(Object periodUid, long windowSequenceNumber) { + this( + periodUid, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET, + windowSequenceNumber, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + } + + /** + * Creates a media period identifier for the specified clipped period in the timeline. + * + * @param periodUid The unique id of the timeline period. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this media period is part of. + * @param nextAdGroupIndex The index of the next ad group to which the media period's content is + * clipped. + */ + public MediaPeriodId(Object periodUid, long windowSequenceNumber, int nextAdGroupIndex) { + this( + periodUid, + /* adGroupIndex= */ C.INDEX_UNSET, + /* adIndexInAdGroup= */ C.INDEX_UNSET, + windowSequenceNumber, + nextAdGroupIndex); + } + + /** + * Creates a media period identifier that identifies an ad within an ad group at the specified + * timeline period. + * + * @param periodUid The unique id of the timeline period that contains the ad group. + * @param adGroupIndex The index of the ad group. + * @param adIndexInAdGroup The index of the ad in the ad group. + * @param windowSequenceNumber The sequence number of the window in the buffered sequence of + * windows this media period is part of. + */ + public MediaPeriodId( + Object periodUid, int adGroupIndex, int adIndexInAdGroup, long windowSequenceNumber) { + this( + periodUid, + adGroupIndex, + adIndexInAdGroup, + windowSequenceNumber, + /* nextAdGroupIndex= */ C.INDEX_UNSET); + } + + private MediaPeriodId( + Object periodUid, + int adGroupIndex, + int adIndexInAdGroup, + long windowSequenceNumber, + int nextAdGroupIndex) { + this.periodUid = periodUid; + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + this.windowSequenceNumber = windowSequenceNumber; + this.nextAdGroupIndex = nextAdGroupIndex; + } + + /** Returns a copy of this period identifier but with {@code newPeriodUid} as its period uid. */ + public MediaPeriodId copyWithPeriodUid(Object newPeriodUid) { + return periodUid.equals(newPeriodUid) + ? this + : new MediaPeriodId( + newPeriodUid, adGroupIndex, adIndexInAdGroup, windowSequenceNumber, nextAdGroupIndex); + } + + /** + * Returns whether this period identifier identifies an ad in an ad group in a period. + */ + public boolean isAd() { + return adGroupIndex != C.INDEX_UNSET; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + MediaPeriodId periodId = (MediaPeriodId) obj; + return periodUid.equals(periodId.periodUid) + && adGroupIndex == periodId.adGroupIndex + && adIndexInAdGroup == periodId.adIndexInAdGroup + && windowSequenceNumber == periodId.windowSequenceNumber + && nextAdGroupIndex == periodId.nextAdGroupIndex; + } + + @Override + public int hashCode() { + int result = 17; + result = 31 * result + periodUid.hashCode(); + result = 31 * result + adGroupIndex; + result = 31 * result + adIndexInAdGroup; + result = 31 * result + (int) windowSequenceNumber; + result = 31 * result + nextAdGroupIndex; + return result; + } + } + + /** + * Adds a {@link MediaSourceEventListener} to the list of listeners which are notified of media + * source events. + * + * @param handler A handler on the which listener events will be posted. + * @param eventListener The listener to be added. + */ + void addEventListener(Handler handler, MediaSourceEventListener eventListener); + + /** + * Removes a {@link MediaSourceEventListener} from the list of listeners which are notified of + * media source events. + * + * @param eventListener The listener to be removed. + */ + void removeEventListener(MediaSourceEventListener eventListener); + + /** Returns the tag set on the media source, or null if none was set. */ + @Nullable + default Object getTag() { + return null; + } + + /** + * Registers a {@link MediaSourceCaller}. Starts source preparation if needed and enables the + * source for the creation of {@link MediaPeriod MediaPerods}. + * + * <p>Should not be called directly from application code. + * + * <p>{@link MediaSourceCaller#onSourceInfoRefreshed(MediaSource, Timeline)} will be called once + * the source has a {@link Timeline}. + * + * <p>For each call to this method, a call to {@link #releaseSource(MediaSourceCaller)} is needed + * to remove the caller and to release the source if no longer required. + * + * @param caller The {@link MediaSourceCaller} to be registered. + * @param mediaTransferListener The transfer listener which should be informed of any media data + * transfers. May be null if no listener is available. Note that this listener should be only + * informed of transfers related to the media loads and not of auxiliary loads for manifests + * and other data. + */ + void prepareSource(MediaSourceCaller caller, @Nullable TransferListener mediaTransferListener); + + /** + * Throws any pending error encountered while loading or refreshing source information. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called after {@link #prepareSource(MediaSourceCaller, TransferListener)}. + */ + void maybeThrowSourceInfoRefreshError() throws IOException; + + /** + * Enables the source for the creation of {@link MediaPeriod MediaPeriods}. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called after {@link #prepareSource(MediaSourceCaller, TransferListener)}. + * + * @param caller The {@link MediaSourceCaller} enabling the source. + */ + void enable(MediaSourceCaller caller); + + /** + * Returns a new {@link MediaPeriod} identified by {@code periodId}. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called if the source is enabled. + * + * @param id The identifier of the period. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. + * @return A new {@link MediaPeriod}. + */ + MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs); + + /** + * Releases the period. + * + * <p>Should not be called directly from application code. + * + * @param mediaPeriod The period to release. + */ + void releasePeriod(MediaPeriod mediaPeriod); + + /** + * Disables the source for the creation of {@link MediaPeriod MediaPeriods}. The implementation + * should not hold onto limited resources used for the creation of media periods. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called after all {@link MediaPeriod MediaPeriods} previously created by {@link + * #createPeriod(MediaPeriodId, Allocator, long)} have been released by {@link + * #releasePeriod(MediaPeriod)}. + * + * @param caller The {@link MediaSourceCaller} disabling the source. + */ + void disable(MediaSourceCaller caller); + + /** + * Unregisters a caller, and disables and releases the source if no longer required. + * + * <p>Should not be called directly from application code. + * + * <p>Must only be called if all created {@link MediaPeriod MediaPeriods} have been released by + * {@link #releasePeriod(MediaPeriod)}. + * + * @param caller The {@link MediaSourceCaller} to be unregistered. + */ + void releaseSource(MediaSourceCaller caller); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceEventListener.java new file mode 100644 index 0000000000..53c50d8a26 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceEventListener.java @@ -0,0 +1,740 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.CheckResult; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Interface for callbacks to be notified of {@link MediaSource} events. */ +public interface MediaSourceEventListener { + + /** Media source load event information. */ + final class LoadEventInfo { + + /** Defines the requested data. */ + public final DataSpec dataSpec; + /** + * The {@link Uri} from which data is being read. The uri will be identical to the one in {@link + * #dataSpec}.uri unless redirection has occurred. If redirection has occurred, this is the uri + * after redirection. + */ + public final Uri uri; + /** The response headers associated with the load, or an empty map if unavailable. */ + public final Map<String, List<String>> responseHeaders; + /** The value of {@link SystemClock#elapsedRealtime} at the time of the load event. */ + public final long elapsedRealtimeMs; + /** The duration of the load up to the event time. */ + public final long loadDurationMs; + /** The number of bytes that were loaded up to the event time. */ + public final long bytesLoaded; + + /** + * Creates load event info. + * + * @param dataSpec Defines the requested data. + * @param uri The {@link Uri} from which data is being read. The uri must be identical to the + * one in {@code dataSpec.uri} unless redirection has occurred. If redirection has occurred, + * this is the uri after redirection. + * @param responseHeaders The response headers associated with the load, or an empty map if + * unavailable. + * @param elapsedRealtimeMs The value of {@link SystemClock#elapsedRealtime} at the time of the + * load event. + * @param loadDurationMs The duration of the load up to the event time. + * @param bytesLoaded The number of bytes that were loaded up to the event time. For compressed + * network responses, this is the decompressed size. + */ + public LoadEventInfo( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + this.dataSpec = dataSpec; + this.uri = uri; + this.responseHeaders = responseHeaders; + this.elapsedRealtimeMs = elapsedRealtimeMs; + this.loadDurationMs = loadDurationMs; + this.bytesLoaded = bytesLoaded; + } + } + + /** Descriptor for data being loaded or selected by a media source. */ + final class MediaLoadData { + + /** One of the {@link C} {@code DATA_TYPE_*} constants defining the type of data. */ + public final int dataType; + /** + * One of the {@link C} {@code TRACK_TYPE_*} constants if the data corresponds to media of a + * specific type. {@link C#TRACK_TYPE_UNKNOWN} otherwise. + */ + public final int trackType; + /** + * The format of the track to which the data belongs. Null if the data does not belong to a + * specific track. + */ + @Nullable public final Format trackFormat; + /** + * One of the {@link C} {@code SELECTION_REASON_*} constants if the data belongs to a track. + * {@link C#SELECTION_REASON_UNKNOWN} otherwise. + */ + public final int trackSelectionReason; + /** + * Optional data associated with the selection of the track to which the data belongs. Null if + * the data does not belong to a track. + */ + @Nullable public final Object trackSelectionData; + /** + * The start time of the media, or {@link C#TIME_UNSET} if the data does not belong to a + * specific media period. + */ + public final long mediaStartTimeMs; + /** + * The end time of the media, or {@link C#TIME_UNSET} if the data does not belong to a specific + * media period or the end time is unknown. + */ + public final long mediaEndTimeMs; + + /** + * Creates media load data. + * + * @param dataType One of the {@link C} {@code DATA_TYPE_*} constants defining the type of data. + * @param trackType One of the {@link C} {@code TRACK_TYPE_*} constants if the data corresponds + * to media of a specific type. {@link C#TRACK_TYPE_UNKNOWN} otherwise. + * @param trackFormat The format of the track to which the data belongs. Null if the data does + * not belong to a track. + * @param trackSelectionReason One of the {@link C} {@code SELECTION_REASON_*} constants if the + * data belongs to a track. {@link C#SELECTION_REASON_UNKNOWN} otherwise. + * @param trackSelectionData Optional data associated with the selection of the track to which + * the data belongs. Null if the data does not belong to a track. + * @param mediaStartTimeMs The start time of the media, or {@link C#TIME_UNSET} if the data does + * not belong to a specific media period. + * @param mediaEndTimeMs The end time of the media, or {@link C#TIME_UNSET} if the data does not + * belong to a specific media period or the end time is unknown. + */ + public MediaLoadData( + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeMs, + long mediaEndTimeMs) { + this.dataType = dataType; + this.trackType = trackType; + this.trackFormat = trackFormat; + this.trackSelectionReason = trackSelectionReason; + this.trackSelectionData = trackSelectionData; + this.mediaStartTimeMs = mediaStartTimeMs; + this.mediaEndTimeMs = mediaEndTimeMs; + } + } + + /** + * Called when a media period is created by the media source. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} of the created media period. + */ + default void onMediaPeriodCreated(int windowIndex, MediaPeriodId mediaPeriodId) {} + + /** + * Called when a media period is released by the media source. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} of the released media period. + */ + default void onMediaPeriodReleased(int windowIndex, MediaPeriodId mediaPeriodId) {} + + /** + * Called when a load begins. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The value of {@link + * LoadEventInfo#uri} won't reflect potential redirection yet and {@link + * LoadEventInfo#responseHeaders} will be empty. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) {} + + /** + * Called when a load ends. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The values of {@link + * LoadEventInfo#elapsedRealtimeMs} and {@link LoadEventInfo#bytesLoaded} are relative to the + * corresponding {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} + * event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) {} + + /** + * Called when a load is canceled. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The values of {@link + * LoadEventInfo#elapsedRealtimeMs} and {@link LoadEventInfo#bytesLoaded} are relative to the + * corresponding {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} + * event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + */ + default void onLoadCanceled( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) {} + + /** + * Called when a load error occurs. + * + * <p>The error may or may not have resulted in the load being canceled, as indicated by the + * {@code wasCanceled} parameter. If the load was canceled, {@link #onLoadCanceled} will + * <em>not</em> be called in addition to this method. + * + * <p>This method being called does not indicate that playback has failed, or that it will fail. + * The player may be able to recover from the error and continue. Hence applications should + * <em>not</em> implement this method to display a user visible error or initiate an application + * level retry ({@link Player.EventListener#onPlayerError} is the appropriate place to implement + * such behavior). This method is called to provide the application with an opportunity to log the + * error if it wishes to do so. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} this load belongs to. Null if the load does not + * belong to a specific media period. + * @param loadEventInfo The {@link LoadEventInfo} corresponding to the event. The values of {@link + * LoadEventInfo#elapsedRealtimeMs} and {@link LoadEventInfo#bytesLoaded} are relative to the + * corresponding {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)} + * event. + * @param mediaLoadData The {@link MediaLoadData} defining the data being loaded. + * @param error The load error. + * @param wasCanceled Whether the load was canceled as a result of the error. + */ + default void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) {} + + /** + * Called when a media period is first being read from. + * + * @param windowIndex The window index in the timeline this media period belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} of the media period being read from. + */ + default void onReadingStarted(int windowIndex, MediaPeriodId mediaPeriodId) {} + + /** + * Called when data is removed from the back of a media buffer, typically so that it can be + * re-buffered in a different format. + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} the media belongs to. + * @param mediaLoadData The {@link MediaLoadData} defining the media being discarded. + */ + default void onUpstreamDiscarded( + int windowIndex, MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {} + + /** + * Called when a downstream format change occurs (i.e. when the format of the media being read + * from one or more {@link SampleStream}s provided by the source changes). + * + * @param windowIndex The window index in the timeline of the media source this load belongs to. + * @param mediaPeriodId The {@link MediaPeriodId} the media belongs to. + * @param mediaLoadData The {@link MediaLoadData} defining the newly selected downstream data. + */ + default void onDownstreamFormatChanged( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) {} + + /** Dispatches events to {@link MediaSourceEventListener}s. */ + final class EventDispatcher { + + /** The timeline window index reported with the events. */ + public final int windowIndex; + /** The {@link MediaPeriodId} reported with the events. */ + @Nullable public final MediaPeriodId mediaPeriodId; + + private final CopyOnWriteArrayList<ListenerAndHandler> listenerAndHandlers; + private final long mediaTimeOffsetMs; + + /** Creates an event dispatcher. */ + public EventDispatcher() { + this( + /* listenerAndHandlers= */ new CopyOnWriteArrayList<>(), + /* windowIndex= */ 0, + /* mediaPeriodId= */ null, + /* mediaTimeOffsetMs= */ 0); + } + + private EventDispatcher( + CopyOnWriteArrayList<ListenerAndHandler> listenerAndHandlers, + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + long mediaTimeOffsetMs) { + this.listenerAndHandlers = listenerAndHandlers; + this.windowIndex = windowIndex; + this.mediaPeriodId = mediaPeriodId; + this.mediaTimeOffsetMs = mediaTimeOffsetMs; + } + + /** + * Creates a view of the event dispatcher with pre-configured window index, media period id, and + * media time offset. + * + * @param windowIndex The timeline window index to be reported with the events. + * @param mediaPeriodId The {@link MediaPeriodId} to be reported with the events. + * @param mediaTimeOffsetMs The offset to be added to all media times, in milliseconds. + * @return A view of the event dispatcher with the pre-configured parameters. + */ + @CheckResult + public EventDispatcher withParameters( + int windowIndex, @Nullable MediaPeriodId mediaPeriodId, long mediaTimeOffsetMs) { + return new EventDispatcher( + listenerAndHandlers, windowIndex, mediaPeriodId, mediaTimeOffsetMs); + } + + /** + * Adds a listener to the event dispatcher. + * + * @param handler A handler on the which listener events will be posted. + * @param eventListener The listener to be added. + */ + public void addEventListener(Handler handler, MediaSourceEventListener eventListener) { + Assertions.checkArgument(handler != null && eventListener != null); + listenerAndHandlers.add(new ListenerAndHandler(handler, eventListener)); + } + + /** + * Removes a listener from the event dispatcher. + * + * @param eventListener The listener to be removed. + */ + public void removeEventListener(MediaSourceEventListener eventListener) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + if (listenerAndHandler.listener == eventListener) { + listenerAndHandlers.remove(listenerAndHandler); + } + } + } + + /** Dispatches {@link #onMediaPeriodCreated(int, MediaPeriodId)}. */ + public void mediaPeriodCreated() { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onMediaPeriodCreated(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onMediaPeriodReleased(int, MediaPeriodId)}. */ + public void mediaPeriodReleased() { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onMediaPeriodReleased(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted(DataSpec dataSpec, int dataType, long elapsedRealtimeMs) { + loadStarted( + dataSpec, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs); + } + + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted( + DataSpec dataSpec, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs) { + loadStarted( + new LoadEventInfo( + dataSpec, + dataSpec.uri, + /* responseHeaders= */ Collections.emptyMap(), + elapsedRealtimeMs, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onLoadStarted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadStarted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onLoadStarted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } + } + + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCompleted( + dataSpec, + uri, + responseHeaders, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded); + } + + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCompleted( + new LoadEventInfo( + dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onLoadCompleted(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCompleted(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadCompleted(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } + } + + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCanceled( + dataSpec, + uri, + responseHeaders, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded); + } + + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded) { + loadCanceled( + new LoadEventInfo( + dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onLoadCanceled(int, MediaPeriodId, LoadEventInfo, MediaLoadData)}. */ + public void loadCanceled(LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadCanceled(windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData)); + } + } + + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ + public void loadError( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded, + IOException error, + boolean wasCanceled) { + loadError( + dataSpec, + uri, + responseHeaders, + dataType, + C.TRACK_TYPE_UNKNOWN, + null, + C.SELECTION_REASON_UNKNOWN, + null, + C.TIME_UNSET, + C.TIME_UNSET, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded, + error, + wasCanceled); + } + + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ + public void loadError( + DataSpec dataSpec, + Uri uri, + Map<String, List<String>> responseHeaders, + int dataType, + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaStartTimeUs, + long mediaEndTimeUs, + long elapsedRealtimeMs, + long loadDurationMs, + long bytesLoaded, + IOException error, + boolean wasCanceled) { + loadError( + new LoadEventInfo( + dataSpec, uri, responseHeaders, elapsedRealtimeMs, loadDurationMs, bytesLoaded), + new MediaLoadData( + dataType, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs)), + error, + wasCanceled); + } + + /** + * Dispatches {@link #onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, IOException, + * boolean)}. + */ + public void loadError( + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> + listener.onLoadError( + windowIndex, mediaPeriodId, loadEventInfo, mediaLoadData, error, wasCanceled)); + } + } + + /** Dispatches {@link #onReadingStarted(int, MediaPeriodId)}. */ + public void readingStarted() { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onReadingStarted(windowIndex, mediaPeriodId)); + } + } + + /** Dispatches {@link #onUpstreamDiscarded(int, MediaPeriodId, MediaLoadData)}. */ + public void upstreamDiscarded(int trackType, long mediaStartTimeUs, long mediaEndTimeUs) { + upstreamDiscarded( + new MediaLoadData( + C.DATA_TYPE_MEDIA, + trackType, + /* trackFormat= */ null, + C.SELECTION_REASON_ADAPTIVE, + /* trackSelectionData= */ null, + adjustMediaTime(mediaStartTimeUs), + adjustMediaTime(mediaEndTimeUs))); + } + + /** Dispatches {@link #onUpstreamDiscarded(int, MediaPeriodId, MediaLoadData)}. */ + public void upstreamDiscarded(MediaLoadData mediaLoadData) { + MediaPeriodId mediaPeriodId = Assertions.checkNotNull(this.mediaPeriodId); + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onUpstreamDiscarded(windowIndex, mediaPeriodId, mediaLoadData)); + } + } + + /** Dispatches {@link #onDownstreamFormatChanged(int, MediaPeriodId, MediaLoadData)}. */ + public void downstreamFormatChanged( + int trackType, + @Nullable Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long mediaTimeUs) { + downstreamFormatChanged( + new MediaLoadData( + C.DATA_TYPE_MEDIA, + trackType, + trackFormat, + trackSelectionReason, + trackSelectionData, + adjustMediaTime(mediaTimeUs), + /* mediaEndTimeMs= */ C.TIME_UNSET)); + } + + /** Dispatches {@link #onDownstreamFormatChanged(int, MediaPeriodId, MediaLoadData)}. */ + public void downstreamFormatChanged(MediaLoadData mediaLoadData) { + for (ListenerAndHandler listenerAndHandler : listenerAndHandlers) { + final MediaSourceEventListener listener = listenerAndHandler.listener; + postOrRun( + listenerAndHandler.handler, + () -> listener.onDownstreamFormatChanged(windowIndex, mediaPeriodId, mediaLoadData)); + } + } + + private long adjustMediaTime(long mediaTimeUs) { + long mediaTimeMs = C.usToMs(mediaTimeUs); + return mediaTimeMs == C.TIME_UNSET ? C.TIME_UNSET : mediaTimeOffsetMs + mediaTimeMs; + } + + private void postOrRun(Handler handler, Runnable runnable) { + if (handler.getLooper() == Looper.myLooper()) { + runnable.run(); + } else { + handler.post(runnable); + } + } + + private static final class ListenerAndHandler { + + public final Handler handler; + public final MediaSourceEventListener listener; + + public ListenerAndHandler(Handler handler, MediaSourceEventListener listener) { + this.handler = handler; + this.listener = listener; + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceFactory.java new file mode 100644 index 0000000000..37c9dcee25 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MediaSourceFactory.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import java.util.List; + +/** Factory for creating {@link MediaSource}s from URIs. */ +public interface MediaSourceFactory { + + /** + * Sets a list of {@link StreamKey StreamKeys} by which the manifest is filtered. + * + * @param streamKeys A list of {@link StreamKey StreamKeys}. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + default MediaSourceFactory setStreamKeys(List<StreamKey> streamKeys) { + return this; + } + + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + MediaSourceFactory setDrmSessionManager(DrmSessionManager<?> drmSessionManager); + + /** + * Creates a new {@link MediaSource} with the specified {@code uri}. + * + * @param uri The URI to play. + * @return The new {@link MediaSource media source}. + */ + MediaSource createMediaSource(Uri uri); + + /** + * Returns the {@link C.ContentType content types} supported by media sources created by this + * factory. + */ + @C.ContentType + int[] getSupportedTypes(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaPeriod.java new file mode 100644 index 0000000000..f3315ec5cd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaPeriod.java @@ -0,0 +1,256 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Merges multiple {@link MediaPeriod}s. + */ +/* package */ final class MergingMediaPeriod implements MediaPeriod, MediaPeriod.Callback { + + public final MediaPeriod[] periods; + + private final IdentityHashMap<SampleStream, Integer> streamPeriodIndices; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private final ArrayList<MediaPeriod> childrenPendingPreparation; + + @Nullable private Callback callback; + @Nullable private TrackGroupArray trackGroups; + private MediaPeriod[] enabledPeriods; + private SequenceableLoader compositeSequenceableLoader; + + public MergingMediaPeriod(CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + MediaPeriod... periods) { + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.periods = periods; + childrenPendingPreparation = new ArrayList<>(); + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(); + streamPeriodIndices = new IdentityHashMap<>(); + enabledPeriods = new MediaPeriod[0]; + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + Collections.addAll(childrenPendingPreparation, periods); + for (MediaPeriod period : periods) { + period.prepare(this, positionUs); + } + } + + @Override + public void maybeThrowPrepareError() throws IOException { + for (MediaPeriod period : periods) { + period.maybeThrowPrepareError(); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return Assertions.checkNotNull(trackGroups); + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + // Map each selection and stream onto a child period index. + int[] streamChildIndices = new int[selections.length]; + int[] selectionChildIndices = new int[selections.length]; + for (int i = 0; i < selections.length; i++) { + streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET + : streamPeriodIndices.get(streams[i]); + selectionChildIndices[i] = C.INDEX_UNSET; + if (selections[i] != null) { + TrackGroup trackGroup = selections[i].getTrackGroup(); + for (int j = 0; j < periods.length; j++) { + if (periods[j].getTrackGroups().indexOf(trackGroup) != C.INDEX_UNSET) { + selectionChildIndices[i] = j; + break; + } + } + } + } + streamPeriodIndices.clear(); + // Select tracks for each child, copying the resulting streams back into a new streams array. + @NullableType SampleStream[] newStreams = new SampleStream[selections.length]; + @NullableType SampleStream[] childStreams = new SampleStream[selections.length]; + @NullableType TrackSelection[] childSelections = new TrackSelection[selections.length]; + ArrayList<MediaPeriod> enabledPeriodsList = new ArrayList<>(periods.length); + for (int i = 0; i < periods.length; i++) { + for (int j = 0; j < selections.length; j++) { + childStreams[j] = streamChildIndices[j] == i ? streams[j] : null; + childSelections[j] = selectionChildIndices[j] == i ? selections[j] : null; + } + long selectPositionUs = periods[i].selectTracks(childSelections, mayRetainStreamFlags, + childStreams, streamResetFlags, positionUs); + if (i == 0) { + positionUs = selectPositionUs; + } else if (selectPositionUs != positionUs) { + throw new IllegalStateException("Children enabled at different positions."); + } + boolean periodEnabled = false; + for (int j = 0; j < selections.length; j++) { + if (selectionChildIndices[j] == i) { + // Assert that the child provided a stream for the selection. + SampleStream childStream = Assertions.checkNotNull(childStreams[j]); + newStreams[j] = childStreams[j]; + periodEnabled = true; + streamPeriodIndices.put(childStream, i); + } else if (streamChildIndices[j] == i) { + // Assert that the child cleared any previous stream. + Assertions.checkState(childStreams[j] == null); + } + } + if (periodEnabled) { + enabledPeriodsList.add(periods[i]); + } + } + // Copy the new streams back into the streams array. + System.arraycopy(newStreams, 0, streams, 0, newStreams.length); + // Update the local state. + enabledPeriods = new MediaPeriod[enabledPeriodsList.size()]; + enabledPeriodsList.toArray(enabledPeriods); + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(enabledPeriods); + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + for (MediaPeriod period : enabledPeriods) { + period.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + + @Override + public boolean continueLoading(long positionUs) { + if (!childrenPendingPreparation.isEmpty()) { + // Preparation is still going on. + int childrenPendingPreparationSize = childrenPendingPreparation.size(); + for (int i = 0; i < childrenPendingPreparationSize; i++) { + childrenPendingPreparation.get(i).continueLoading(positionUs); + } + return false; + } else { + return compositeSequenceableLoader.continueLoading(positionUs); + } + } + + @Override + public boolean isLoading() { + return compositeSequenceableLoader.isLoading(); + } + + @Override + public long getNextLoadPositionUs() { + return compositeSequenceableLoader.getNextLoadPositionUs(); + } + + @Override + public long readDiscontinuity() { + long positionUs = periods[0].readDiscontinuity(); + // Periods other than the first one are not allowed to report discontinuities. + for (int i = 1; i < periods.length; i++) { + if (periods[i].readDiscontinuity() != C.TIME_UNSET) { + throw new IllegalStateException("Child reported discontinuity."); + } + } + // It must be possible to seek enabled periods to the new position, if there is one. + if (positionUs != C.TIME_UNSET) { + for (MediaPeriod enabledPeriod : enabledPeriods) { + if (enabledPeriod != periods[0] + && enabledPeriod.seekToUs(positionUs) != positionUs) { + throw new IllegalStateException("Unexpected child seekToUs result."); + } + } + } + return positionUs; + } + + @Override + public long getBufferedPositionUs() { + return compositeSequenceableLoader.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + positionUs = enabledPeriods[0].seekToUs(positionUs); + // Additional periods must seek to the same position. + for (int i = 1; i < enabledPeriods.length; i++) { + if (enabledPeriods[i].seekToUs(positionUs) != positionUs) { + throw new IllegalStateException("Unexpected child seekToUs result."); + } + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + MediaPeriod queryPeriod = enabledPeriods.length > 0 ? enabledPeriods[0] : periods[0]; + return queryPeriod.getAdjustedSeekPositionUs(positionUs, seekParameters); + } + + // MediaPeriod.Callback implementation + + @Override + public void onPrepared(MediaPeriod preparedPeriod) { + childrenPendingPreparation.remove(preparedPeriod); + if (!childrenPendingPreparation.isEmpty()) { + return; + } + int totalTrackGroupCount = 0; + for (MediaPeriod period : periods) { + totalTrackGroupCount += period.getTrackGroups().length; + } + TrackGroup[] trackGroupArray = new TrackGroup[totalTrackGroupCount]; + int trackGroupIndex = 0; + for (MediaPeriod period : periods) { + TrackGroupArray periodTrackGroups = period.getTrackGroups(); + int periodTrackGroupCount = periodTrackGroups.length; + for (int j = 0; j < periodTrackGroupCount; j++) { + trackGroupArray[trackGroupIndex++] = periodTrackGroups.get(j); + } + } + trackGroups = new TrackGroupArray(trackGroupArray); + Assertions.checkNotNull(callback).onPrepared(this); + } + + @Override + public void onContinueLoadingRequested(MediaPeriod ignored) { + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaSource.java new file mode 100644 index 0000000000..ac2ef3c7da --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/MergingMediaSource.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; + +/** + * Merges multiple {@link MediaSource}s. + * + * <p>The {@link Timeline}s of the sources being merged must have the same number of periods. + */ +public final class MergingMediaSource extends CompositeMediaSource<Integer> { + + /** + * Thrown when a {@link MergingMediaSource} cannot merge its sources. + */ + public static final class IllegalMergeException extends IOException { + + /** The reason the merge failed. One of {@link #REASON_PERIOD_COUNT_MISMATCH}. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({REASON_PERIOD_COUNT_MISMATCH}) + public @interface Reason {} + /** + * The sources have different period counts. + */ + public static final int REASON_PERIOD_COUNT_MISMATCH = 0; + + /** + * The reason the merge failed. + */ + @Reason public final int reason; + + /** + * @param reason The reason the merge failed. + */ + public IllegalMergeException(@Reason int reason) { + this.reason = reason; + } + + } + + private static final int PERIOD_COUNT_UNSET = -1; + + private final MediaSource[] mediaSources; + private final Timeline[] timelines; + private final ArrayList<MediaSource> pendingTimelineSources; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + + private int periodCount; + @Nullable private IllegalMergeException mergeError; + + /** + * @param mediaSources The {@link MediaSource}s to merge. + */ + public MergingMediaSource(MediaSource... mediaSources) { + this(new DefaultCompositeSequenceableLoaderFactory(), mediaSources); + } + + /** + * @param compositeSequenceableLoaderFactory A factory to create composite + * {@link SequenceableLoader}s for when this media source loads data from multiple streams + * (video, audio etc...). + * @param mediaSources The {@link MediaSource}s to merge. + */ + public MergingMediaSource(CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + MediaSource... mediaSources) { + this.mediaSources = mediaSources; + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + pendingTimelineSources = new ArrayList<>(Arrays.asList(mediaSources)); + periodCount = PERIOD_COUNT_UNSET; + timelines = new Timeline[mediaSources.length]; + } + + @Override + @Nullable + public Object getTag() { + return mediaSources.length > 0 ? mediaSources[0].getTag() : null; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + for (int i = 0; i < mediaSources.length; i++) { + prepareChildSource(i, mediaSources[i]); + } + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + if (mergeError != null) { + throw mergeError; + } + super.maybeThrowSourceInfoRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + MediaPeriod[] periods = new MediaPeriod[mediaSources.length]; + int periodIndex = timelines[0].getIndexOfPeriod(id.periodUid); + for (int i = 0; i < periods.length; i++) { + MediaPeriodId childMediaPeriodId = + id.copyWithPeriodUid(timelines[i].getUidOfPeriod(periodIndex)); + periods[i] = mediaSources[i].createPeriod(childMediaPeriodId, allocator, startPositionUs); + } + return new MergingMediaPeriod(compositeSequenceableLoaderFactory, periods); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MergingMediaPeriod mergingPeriod = (MergingMediaPeriod) mediaPeriod; + for (int i = 0; i < mediaSources.length; i++) { + mediaSources[i].releasePeriod(mergingPeriod.periods[i]); + } + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + Arrays.fill(timelines, null); + periodCount = PERIOD_COUNT_UNSET; + mergeError = null; + pendingTimelineSources.clear(); + Collections.addAll(pendingTimelineSources, mediaSources); + } + + @Override + protected void onChildSourceInfoRefreshed( + Integer id, MediaSource mediaSource, Timeline timeline) { + if (mergeError == null) { + mergeError = checkTimelineMerges(timeline); + } + if (mergeError != null) { + return; + } + pendingTimelineSources.remove(mediaSource); + timelines[id] = timeline; + if (pendingTimelineSources.isEmpty()) { + refreshSourceInfo(timelines[0]); + } + } + + @Override + @Nullable + protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + Integer id, MediaPeriodId mediaPeriodId) { + return id == 0 ? mediaPeriodId : null; + } + + @Nullable + private IllegalMergeException checkTimelineMerges(Timeline timeline) { + if (periodCount == PERIOD_COUNT_UNSET) { + periodCount = timeline.getPeriodCount(); + } else if (timeline.getPeriodCount() != periodCount) { + return new IllegalMergeException(IllegalMergeException.REASON_PERIOD_COUNT_MISMATCH); + } + return null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java new file mode 100644 index 0000000000..4c62a73edb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaPeriod.java @@ -0,0 +1,1162 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap.SeekPoints; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap.Unseekable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.icy.IcyHeaders; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.StatsDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ConditionVariable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** A {@link MediaPeriod} that extracts data using an {@link Extractor}. */ +/* package */ final class ProgressiveMediaPeriod + implements MediaPeriod, + ExtractorOutput, + Loader.Callback<ProgressiveMediaPeriod.ExtractingLoadable>, + Loader.ReleaseCallback, + UpstreamFormatChangedListener { + + /** + * Listener for information about the period. + */ + interface Listener { + + /** + * Called when the duration, the ability to seek within the period, or the categorization as + * live stream changes. + * + * @param durationUs The duration of the period, or {@link C#TIME_UNSET}. + * @param isSeekable Whether the period is seekable. + * @param isLive Whether the period is live. + */ + void onSourceInfoRefreshed(long durationUs, boolean isSeekable, boolean isLive); + } + + /** + * When the source's duration is unknown, it is calculated by adding this value to the largest + * sample timestamp seen when buffering completes. + */ + private static final long DEFAULT_LAST_SAMPLE_DURATION_US = 10000; + + private static final Map<String, String> ICY_METADATA_HEADERS = createIcyMetadataHeaders(); + + private static final Format ICY_FORMAT = + Format.createSampleFormat("icy", MimeTypes.APPLICATION_ICY, Format.OFFSET_SAMPLE_RELATIVE); + + private final Uri uri; + private final DataSource dataSource; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final EventDispatcher eventDispatcher; + private final Listener listener; + private final Allocator allocator; + @Nullable private final String customCacheKey; + private final long continueLoadingCheckIntervalBytes; + private final Loader loader; + private final ExtractorHolder extractorHolder; + private final ConditionVariable loadCondition; + private final Runnable maybeFinishPrepareRunnable; + private final Runnable onContinueLoadingRequestedRunnable; + private final Handler handler; + + @Nullable private Callback callback; + @Nullable private SeekMap seekMap; + @Nullable private IcyHeaders icyHeaders; + private SampleQueue[] sampleQueues; + private TrackId[] sampleQueueTrackIds; + private boolean sampleQueuesBuilt; + private boolean prepared; + + @Nullable private PreparedState preparedState; + private boolean haveAudioVideoTracks; + private int dataType; + + private boolean seenFirstTrackSelection; + private boolean notifyDiscontinuity; + private boolean notifiedReadingStarted; + private int enabledTrackCount; + private long durationUs; + private long length; + private boolean isLive; + + private long lastSeekPositionUs; + private long pendingResetPositionUs; + private boolean pendingDeferredRetry; + + private int extractedSamplesCountAtStartOfLoad; + private boolean loadingFinished; + private boolean released; + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSource The data source to read the media. + * @param extractors The extractors to use to read the data source. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + * @param listener A listener to notify when information about the period changes. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for cache + * indexing. May be null. + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between each + * invocation of {@link Callback#onContinueLoadingRequested(SequenceableLoader)}. + */ + // maybeFinishPrepare is not posted to the handler until initialization completes. + @SuppressWarnings({ + "nullness:argument.type.incompatible", + "nullness:methodref.receiver.bound.invalid" + }) + public ProgressiveMediaPeriod( + Uri uri, + DataSource dataSource, + Extractor[] extractors, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + Listener listener, + Allocator allocator, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes) { + this.uri = uri; + this.dataSource = dataSource; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.listener = listener; + this.allocator = allocator; + this.customCacheKey = customCacheKey; + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + loader = new Loader("Loader:ProgressiveMediaPeriod"); + extractorHolder = new ExtractorHolder(extractors); + loadCondition = new ConditionVariable(); + maybeFinishPrepareRunnable = this::maybeFinishPrepare; + onContinueLoadingRequestedRunnable = + () -> { + if (!released) { + Assertions.checkNotNull(callback) + .onContinueLoadingRequested(ProgressiveMediaPeriod.this); + } + }; + handler = new Handler(); + sampleQueueTrackIds = new TrackId[0]; + sampleQueues = new SampleQueue[0]; + pendingResetPositionUs = C.TIME_UNSET; + length = C.LENGTH_UNSET; + durationUs = C.TIME_UNSET; + dataType = C.DATA_TYPE_MEDIA; + eventDispatcher.mediaPeriodCreated(); + } + + public void release() { + if (prepared) { + // Discard as much as we can synchronously. We only do this if we're prepared, since otherwise + // sampleQueues may still be being modified by the loading thread. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.preRelease(); + } + } + loader.release(/* callback= */ this); + handler.removeCallbacksAndMessages(null); + callback = null; + released = true; + eventDispatcher.mediaPeriodReleased(); + } + + @Override + public void onLoaderReleased() { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.release(); + } + extractorHolder.release(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + loadCondition.open(); + startLoading(); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + maybeThrowError(); + if (loadingFinished && !prepared) { + throw new ParserException("Loading finished before preparation is complete."); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + return getPreparedState().tracks; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + PreparedState preparedState = getPreparedState(); + TrackGroupArray tracks = preparedState.tracks; + boolean[] trackEnabledStates = preparedState.trackEnabledStates; + int oldEnabledTrackCount = enabledTrackCount; + // Deselect old tracks. + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + int track = ((SampleStreamImpl) streams[i]).track; + Assertions.checkState(trackEnabledStates[track]); + enabledTrackCount--; + trackEnabledStates[track] = false; + streams[i] = null; + } + } + // We'll always need to seek if this is a first selection to a non-zero position, or if we're + // making a selection having previously disabled all tracks. + boolean seekRequired = seenFirstTrackSelection ? oldEnabledTrackCount == 0 : positionUs != 0; + // Select new tracks. + for (int i = 0; i < selections.length; i++) { + if (streams[i] == null && selections[i] != null) { + TrackSelection selection = selections[i]; + Assertions.checkState(selection.length() == 1); + Assertions.checkState(selection.getIndexInTrackGroup(0) == 0); + int track = tracks.indexOf(selection.getTrackGroup()); + Assertions.checkState(!trackEnabledStates[track]); + enabledTrackCount++; + trackEnabledStates[track] = true; + streams[i] = new SampleStreamImpl(track); + streamResetFlags[i] = true; + // If there's still a chance of avoiding a seek, try and seek within the sample queue. + if (!seekRequired) { + SampleQueue sampleQueue = sampleQueues[track]; + // A seek can be avoided if we're able to seek to the current playback position in the + // sample queue, or if we haven't read anything from the queue since the previous seek + // (this case is common for sparse tracks such as metadata tracks). In all other cases a + // seek is required. + seekRequired = + !sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true) + && sampleQueue.getReadIndex() != 0; + } + } + } + if (enabledTrackCount == 0) { + pendingDeferredRetry = false; + notifyDiscontinuity = false; + if (loader.isLoading()) { + // Discard as much as we can synchronously. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.discardToEnd(); + } + loader.cancelLoading(); + } else { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + } + } else if (seekRequired) { + positionUs = seekToUs(positionUs); + // We'll need to reset renderers consuming from all streams due to the seek. + for (int i = 0; i < streams.length; i++) { + if (streams[i] != null) { + streamResetFlags[i] = true; + } + } + } + seenFirstTrackSelection = true; + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (isPendingReset()) { + return; + } + boolean[] trackEnabledStates = getPreparedState().trackEnabledStates; + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + sampleQueues[i].discardTo(positionUs, toKeyframe, trackEnabledStates[i]); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + @Override + public boolean continueLoading(long playbackPositionUs) { + if (loadingFinished + || loader.hasFatalError() + || pendingDeferredRetry + || (prepared && enabledTrackCount == 0)) { + return false; + } + boolean continuedLoading = loadCondition.open(); + if (!loader.isLoading()) { + startLoading(); + continuedLoading = true; + } + return continuedLoading; + } + + @Override + public boolean isLoading() { + return loader.isLoading() && loadCondition.isOpen(); + } + + @Override + public long getNextLoadPositionUs() { + return enabledTrackCount == 0 ? C.TIME_END_OF_SOURCE : getBufferedPositionUs(); + } + + @Override + public long readDiscontinuity() { + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + if (notifyDiscontinuity + && (loadingFinished || getExtractedSamplesCount() > extractedSamplesCountAtStartOfLoad)) { + notifyDiscontinuity = false; + return lastSeekPositionUs; + } + return C.TIME_UNSET; + } + + @Override + public long getBufferedPositionUs() { + boolean[] trackIsAudioVideoFlags = getPreparedState().trackIsAudioVideoFlags; + if (loadingFinished) { + return C.TIME_END_OF_SOURCE; + } else if (isPendingReset()) { + return pendingResetPositionUs; + } + long largestQueuedTimestampUs = Long.MAX_VALUE; + if (haveAudioVideoTracks) { + // Ignore non-AV tracks, which may be sparse or poorly interleaved. + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + if (trackIsAudioVideoFlags[i] && !sampleQueues[i].isLastSampleQueued()) { + largestQueuedTimestampUs = Math.min(largestQueuedTimestampUs, + sampleQueues[i].getLargestQueuedTimestampUs()); + } + } + } + if (largestQueuedTimestampUs == Long.MAX_VALUE) { + largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + } + return largestQueuedTimestampUs == Long.MIN_VALUE ? lastSeekPositionUs + : largestQueuedTimestampUs; + } + + @Override + public long seekToUs(long positionUs) { + PreparedState preparedState = getPreparedState(); + SeekMap seekMap = preparedState.seekMap; + boolean[] trackIsAudioVideoFlags = preparedState.trackIsAudioVideoFlags; + // Treat all seeks into non-seekable media as being to t=0. + positionUs = seekMap.isSeekable() ? positionUs : 0; + + notifyDiscontinuity = false; + lastSeekPositionUs = positionUs; + if (isPendingReset()) { + // A reset is already pending. We only need to update its position. + pendingResetPositionUs = positionUs; + return positionUs; + } + + // If we're not playing a live stream, try and seek within the buffer. + if (dataType != C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE + && seekInsideBufferUs(trackIsAudioVideoFlags, positionUs)) { + return positionUs; + } + + // We can't seek inside the buffer, and so need to reset. + pendingDeferredRetry = false; + pendingResetPositionUs = positionUs; + loadingFinished = false; + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + loader.clearFatalError(); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + SeekMap seekMap = getPreparedState().seekMap; + if (!seekMap.isSeekable()) { + // Treat all seeks into non-seekable media as being to t=0. + return 0; + } + SeekPoints seekPoints = seekMap.getSeekPoints(positionUs); + return Util.resolveSeekPositionUs( + positionUs, seekParameters, seekPoints.first.timeUs, seekPoints.second.timeUs); + } + + // SampleStream methods. + + /* package */ boolean isReady(int track) { + return !suppressRead() && sampleQueues[track].isReady(loadingFinished); + } + + /* package */ void maybeThrowError(int sampleQueueIndex) throws IOException { + sampleQueues[sampleQueueIndex].maybeThrowError(); + maybeThrowError(); + } + + /* package */ void maybeThrowError() throws IOException { + loader.maybeThrowError(loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType)); + } + + /* package */ int readData( + int sampleQueueIndex, + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired) { + if (suppressRead()) { + return C.RESULT_NOTHING_READ; + } + maybeNotifyDownstreamFormat(sampleQueueIndex); + int result = + sampleQueues[sampleQueueIndex].read( + formatHolder, buffer, formatRequired, loadingFinished, lastSeekPositionUs); + if (result == C.RESULT_NOTHING_READ) { + maybeStartDeferredRetry(sampleQueueIndex); + } + return result; + } + + /* package */ int skipData(int track, long positionUs) { + if (suppressRead()) { + return 0; + } + maybeNotifyDownstreamFormat(track); + SampleQueue sampleQueue = sampleQueues[track]; + int skipCount; + if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { + skipCount = sampleQueue.advanceToEnd(); + } else { + skipCount = sampleQueue.advanceTo(positionUs); + } + if (skipCount == 0) { + maybeStartDeferredRetry(track); + } + return skipCount; + } + + private void maybeNotifyDownstreamFormat(int track) { + PreparedState preparedState = getPreparedState(); + boolean[] trackNotifiedDownstreamFormats = preparedState.trackNotifiedDownstreamFormats; + if (!trackNotifiedDownstreamFormats[track]) { + Format trackFormat = preparedState.tracks.get(track).getFormat(/* index= */ 0); + eventDispatcher.downstreamFormatChanged( + MimeTypes.getTrackType(trackFormat.sampleMimeType), + trackFormat, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + lastSeekPositionUs); + trackNotifiedDownstreamFormats[track] = true; + } + } + + private void maybeStartDeferredRetry(int track) { + boolean[] trackIsAudioVideoFlags = getPreparedState().trackIsAudioVideoFlags; + if (!pendingDeferredRetry + || !trackIsAudioVideoFlags[track] + || sampleQueues[track].isReady(/* loadingFinished= */ false)) { + return; + } + pendingResetPositionUs = 0; + pendingDeferredRetry = false; + notifyDiscontinuity = true; + lastSeekPositionUs = 0; + extractedSamplesCountAtStartOfLoad = 0; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + + private boolean suppressRead() { + return notifyDiscontinuity || isPendingReset(); + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(ExtractingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + if (durationUs == C.TIME_UNSET && seekMap != null) { + boolean isSeekable = seekMap.isSeekable(); + long largestQueuedTimestampUs = getLargestQueuedTimestampUs(); + durationUs = largestQueuedTimestampUs == Long.MIN_VALUE ? 0 + : largestQueuedTimestampUs + DEFAULT_LAST_SAMPLE_DURATION_US; + listener.onSourceInfoRefreshed(durationUs, isSeekable, isLive); + } + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead()); + copyLengthFromLoader(loadable); + loadingFinished = true; + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + + @Override + public void onLoadCanceled(ExtractingLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs, boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead()); + if (!released) { + copyLengthFromLoader(loadable); + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + if (enabledTrackCount > 0) { + Assertions.checkNotNull(callback).onContinueLoadingRequested(this); + } + } + } + + @Override + public LoadErrorAction onLoadError( + ExtractingLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + copyLengthFromLoader(loadable); + LoadErrorAction loadErrorAction; + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor(dataType, loadDurationMs, error, errorCount); + if (retryDelayMs == C.TIME_UNSET) { + loadErrorAction = Loader.DONT_RETRY_FATAL; + } else /* the load should be retried */ { + int extractedSamplesCount = getExtractedSamplesCount(); + boolean madeProgress = extractedSamplesCount > extractedSamplesCountAtStartOfLoad; + loadErrorAction = + configureRetry(loadable, extractedSamplesCount) + ? Loader.createRetryAction(/* resetErrorCount= */ madeProgress, retryDelayMs) + : Loader.DONT_RETRY; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead(), + error, + !loadErrorAction.isRetry()); + return loadErrorAction; + } + + // ExtractorOutput implementation. Called by the loading thread. + + @Override + public TrackOutput track(int id, int type) { + return prepareTrackOutput(new TrackId(id, /* isIcyTrack= */ false)); + } + + @Override + public void endTracks() { + sampleQueuesBuilt = true; + handler.post(maybeFinishPrepareRunnable); + } + + @Override + public void seekMap(SeekMap seekMap) { + this.seekMap = icyHeaders == null ? seekMap : new Unseekable(/* durationUs */ C.TIME_UNSET); + handler.post(maybeFinishPrepareRunnable); + } + + // Icy metadata. Called by the loading thread. + + /* package */ TrackOutput icyTrack() { + return prepareTrackOutput(new TrackId(0, /* isIcyTrack= */ true)); + } + + // UpstreamFormatChangedListener implementation. Called by the loading thread. + + @Override + public void onUpstreamFormatChanged(Format format) { + handler.post(maybeFinishPrepareRunnable); + } + + // Internal methods. + + private TrackOutput prepareTrackOutput(TrackId id) { + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + if (id.equals(sampleQueueTrackIds[i])) { + return sampleQueues[i]; + } + } + SampleQueue trackOutput = new SampleQueue(allocator, drmSessionManager); + trackOutput.setUpstreamFormatChangeListener(this); + @NullableType + TrackId[] sampleQueueTrackIds = Arrays.copyOf(this.sampleQueueTrackIds, trackCount + 1); + sampleQueueTrackIds[trackCount] = id; + this.sampleQueueTrackIds = Util.castNonNullTypeArray(sampleQueueTrackIds); + @NullableType SampleQueue[] sampleQueues = Arrays.copyOf(this.sampleQueues, trackCount + 1); + sampleQueues[trackCount] = trackOutput; + this.sampleQueues = Util.castNonNullTypeArray(sampleQueues); + return trackOutput; + } + + private void maybeFinishPrepare() { + SeekMap seekMap = this.seekMap; + if (released || prepared || !sampleQueuesBuilt || seekMap == null) { + return; + } + for (SampleQueue sampleQueue : sampleQueues) { + if (sampleQueue.getUpstreamFormat() == null) { + return; + } + } + loadCondition.close(); + int trackCount = sampleQueues.length; + TrackGroup[] trackArray = new TrackGroup[trackCount]; + boolean[] trackIsAudioVideoFlags = new boolean[trackCount]; + durationUs = seekMap.getDurationUs(); + for (int i = 0; i < trackCount; i++) { + Format trackFormat = sampleQueues[i].getUpstreamFormat(); + String mimeType = trackFormat.sampleMimeType; + boolean isAudio = MimeTypes.isAudio(mimeType); + boolean isAudioVideo = isAudio || MimeTypes.isVideo(mimeType); + trackIsAudioVideoFlags[i] = isAudioVideo; + haveAudioVideoTracks |= isAudioVideo; + IcyHeaders icyHeaders = this.icyHeaders; + if (icyHeaders != null) { + if (isAudio || sampleQueueTrackIds[i].isIcyTrack) { + Metadata metadata = trackFormat.metadata; + trackFormat = + trackFormat.copyWithMetadata( + metadata == null + ? new Metadata(icyHeaders) + : metadata.copyWithAppendedEntries(icyHeaders)); + } + if (isAudio + && trackFormat.bitrate == Format.NO_VALUE + && icyHeaders.bitrate != Format.NO_VALUE) { + trackFormat = trackFormat.copyWithBitrate(icyHeaders.bitrate); + } + } + trackArray[i] = new TrackGroup(trackFormat); + } + isLive = length == C.LENGTH_UNSET && seekMap.getDurationUs() == C.TIME_UNSET; + dataType = isLive ? C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE : C.DATA_TYPE_MEDIA; + preparedState = + new PreparedState(seekMap, new TrackGroupArray(trackArray), trackIsAudioVideoFlags); + prepared = true; + listener.onSourceInfoRefreshed(durationUs, seekMap.isSeekable(), isLive); + Assertions.checkNotNull(callback).onPrepared(this); + } + + private PreparedState getPreparedState() { + return Assertions.checkNotNull(preparedState); + } + + private void copyLengthFromLoader(ExtractingLoadable loadable) { + if (length == C.LENGTH_UNSET) { + length = loadable.length; + } + } + + private void startLoading() { + ExtractingLoadable loadable = + new ExtractingLoadable( + uri, dataSource, extractorHolder, /* extractorOutput= */ this, loadCondition); + if (prepared) { + SeekMap seekMap = getPreparedState().seekMap; + Assertions.checkState(isPendingReset()); + if (durationUs != C.TIME_UNSET && pendingResetPositionUs > durationUs) { + loadingFinished = true; + pendingResetPositionUs = C.TIME_UNSET; + return; + } + loadable.setLoadPosition( + seekMap.getSeekPoints(pendingResetPositionUs).first.position, pendingResetPositionUs); + pendingResetPositionUs = C.TIME_UNSET; + } + extractedSamplesCountAtStartOfLoad = getExtractedSamplesCount(); + long elapsedRealtimeMs = + loader.startLoading( + loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(dataType)); + eventDispatcher.loadStarted( + loadable.dataSpec, + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ loadable.seekTimeUs, + durationUs, + elapsedRealtimeMs); + } + + /** + * Called to configure a retry when a load error occurs. + * + * @param loadable The current loadable for which the error was encountered. + * @param currentExtractedSampleCount The current number of samples that have been extracted into + * the sample queues. + * @return Whether the loader should retry with the current loadable. False indicates a deferred + * retry. + */ + private boolean configureRetry(ExtractingLoadable loadable, int currentExtractedSampleCount) { + if (length != C.LENGTH_UNSET + || (seekMap != null && seekMap.getDurationUs() != C.TIME_UNSET)) { + // We're playing an on-demand stream. Resume the current loadable, which will + // request data starting from the point it left off. + extractedSamplesCountAtStartOfLoad = currentExtractedSampleCount; + return true; + } else if (prepared && !suppressRead()) { + // We're playing a stream of unknown length and duration. Assume it's live, and therefore that + // the data at the uri is a continuously shifting window of the latest available media. For + // this case there's no way to continue loading from where a previous load finished, so it's + // necessary to load from the start whenever commencing a new load. Deferring the retry until + // we run out of buffered data makes for a much better user experience. See: + // https://github.com/google/ExoPlayer/issues/1606. + // Note that the suppressRead() check means only a single deferred retry can occur without + // progress being made. Any subsequent failures without progress will go through the else + // block below. + pendingDeferredRetry = true; + return false; + } else { + // This is the same case as above, except in this case there's no value in deferring the retry + // because there's no buffered data to be read. This case also covers an on-demand stream with + // unknown length that has yet to be prepared. This case cannot be disambiguated from the live + // stream case, so we have no option but to load from the start. + notifyDiscontinuity = prepared; + lastSeekPositionUs = 0; + extractedSamplesCountAtStartOfLoad = 0; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(); + } + loadable.setLoadPosition(0, 0); + return true; + } + } + + /** + * Attempts to seek to the specified position within the sample queues. + * + * @param trackIsAudioVideoFlags Whether each track is audio/video. + * @param positionUs The seek position in microseconds. + * @return Whether the in-buffer seek was successful. + */ + private boolean seekInsideBufferUs(boolean[] trackIsAudioVideoFlags, long positionUs) { + int trackCount = sampleQueues.length; + for (int i = 0; i < trackCount; i++) { + SampleQueue sampleQueue = sampleQueues[i]; + boolean seekInsideQueue = sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false); + // If we have AV tracks then an in-buffer seek is successful if the seek into every AV queue + // is successful. We ignore whether seeks within non-AV queues are successful in this case, as + // they may be sparse or poorly interleaved. If we only have non-AV tracks then a seek is + // successful only if the seek into every queue succeeds. + if (!seekInsideQueue && (trackIsAudioVideoFlags[i] || !haveAudioVideoTracks)) { + return false; + } + } + return true; + } + + private int getExtractedSamplesCount() { + int extractedSamplesCount = 0; + for (SampleQueue sampleQueue : sampleQueues) { + extractedSamplesCount += sampleQueue.getWriteIndex(); + } + return extractedSamplesCount; + } + + private long getLargestQueuedTimestampUs() { + long largestQueuedTimestampUs = Long.MIN_VALUE; + for (SampleQueue sampleQueue : sampleQueues) { + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, + sampleQueue.getLargestQueuedTimestampUs()); + } + return largestQueuedTimestampUs; + } + + private boolean isPendingReset() { + return pendingResetPositionUs != C.TIME_UNSET; + } + + private final class SampleStreamImpl implements SampleStream { + + private final int track; + + public SampleStreamImpl(int track) { + this.track = track; + } + + @Override + public boolean isReady() { + return ProgressiveMediaPeriod.this.isReady(track); + } + + @Override + public void maybeThrowError() throws IOException { + ProgressiveMediaPeriod.this.maybeThrowError(track); + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + return ProgressiveMediaPeriod.this.readData(track, formatHolder, buffer, formatRequired); + } + + @Override + public int skipData(long positionUs) { + return ProgressiveMediaPeriod.this.skipData(track, positionUs); + } + + } + + /** Loads the media stream and extracts sample data from it. */ + /* package */ final class ExtractingLoadable implements Loadable, IcyDataSource.Listener { + + private final Uri uri; + private final StatsDataSource dataSource; + private final ExtractorHolder extractorHolder; + private final ExtractorOutput extractorOutput; + private final ConditionVariable loadCondition; + private final PositionHolder positionHolder; + + private volatile boolean loadCanceled; + + private boolean pendingExtractorSeek; + private long seekTimeUs; + private DataSpec dataSpec; + private long length; + @Nullable private TrackOutput icyTrackOutput; + private boolean seenIcyMetadata; + + @SuppressWarnings("method.invocation.invalid") + public ExtractingLoadable( + Uri uri, + DataSource dataSource, + ExtractorHolder extractorHolder, + ExtractorOutput extractorOutput, + ConditionVariable loadCondition) { + this.uri = uri; + this.dataSource = new StatsDataSource(dataSource); + this.extractorHolder = extractorHolder; + this.extractorOutput = extractorOutput; + this.loadCondition = loadCondition; + this.positionHolder = new PositionHolder(); + this.pendingExtractorSeek = true; + this.length = C.LENGTH_UNSET; + dataSpec = buildDataSpec(/* position= */ 0); + } + + // Loadable implementation. + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @Override + public void load() throws IOException, InterruptedException { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + ExtractorInput input = null; + try { + long position = positionHolder.position; + dataSpec = buildDataSpec(position); + length = dataSource.open(dataSpec); + if (length != C.LENGTH_UNSET) { + length += position; + } + Uri uri = Assertions.checkNotNull(dataSource.getUri()); + icyHeaders = IcyHeaders.parse(dataSource.getResponseHeaders()); + DataSource extractorDataSource = dataSource; + if (icyHeaders != null && icyHeaders.metadataInterval != C.LENGTH_UNSET) { + extractorDataSource = new IcyDataSource(dataSource, icyHeaders.metadataInterval, this); + icyTrackOutput = icyTrack(); + icyTrackOutput.format(ICY_FORMAT); + } + input = new DefaultExtractorInput(extractorDataSource, position, length); + Extractor extractor = extractorHolder.selectExtractor(input, extractorOutput, uri); + + // MP3 live streams commonly have seekable metadata, despite being unseekable. + if (icyHeaders != null && extractor instanceof Mp3Extractor) { + ((Mp3Extractor) extractor).disableSeeking(); + } + + if (pendingExtractorSeek) { + extractor.seek(position, seekTimeUs); + pendingExtractorSeek = false; + } + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + loadCondition.block(); + result = extractor.read(input, positionHolder); + if (input.getPosition() > position + continueLoadingCheckIntervalBytes) { + position = input.getPosition(); + loadCondition.close(); + handler.post(onContinueLoadingRequestedRunnable); + } + } + } finally { + if (result == Extractor.RESULT_SEEK) { + result = Extractor.RESULT_CONTINUE; + } else if (input != null) { + positionHolder.position = input.getPosition(); + } + Util.closeQuietly(dataSource); + } + } + } + + // IcyDataSource.Listener + + @Override + public void onIcyMetadata(ParsableByteArray metadata) { + // Always output the first ICY metadata at the start time. This helps minimize any delay + // between the start of playback and the first ICY metadata event. + long timeUs = + !seenIcyMetadata ? seekTimeUs : Math.max(getLargestQueuedTimestampUs(), seekTimeUs); + int length = metadata.bytesLeft(); + TrackOutput icyTrackOutput = Assertions.checkNotNull(this.icyTrackOutput); + icyTrackOutput.sampleData(metadata, length); + icyTrackOutput.sampleMetadata( + timeUs, C.BUFFER_FLAG_KEY_FRAME, length, /* offset= */ 0, /* encryptionData= */ null); + seenIcyMetadata = true; + } + + // Internal methods. + + private DataSpec buildDataSpec(long position) { + // Disable caching if the content length cannot be resolved, since this is indicative of a + // progressive live stream. + return new DataSpec( + uri, + position, + C.LENGTH_UNSET, + customCacheKey, + DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN | DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION, + ICY_METADATA_HEADERS); + } + + private void setLoadPosition(long position, long timeUs) { + positionHolder.position = position; + seekTimeUs = timeUs; + pendingExtractorSeek = true; + seenIcyMetadata = false; + } + } + + /** Stores a list of extractors and a selected extractor when the format has been detected. */ + private static final class ExtractorHolder { + + private final Extractor[] extractors; + + @Nullable private Extractor extractor; + + /** + * Creates a holder that will select an extractor and initialize it using the specified output. + * + * @param extractors One or more extractors to choose from. + */ + public ExtractorHolder(Extractor[] extractors) { + this.extractors = extractors; + } + + /** + * Returns an initialized extractor for reading {@code input}, and returns the same extractor on + * later calls. + * + * @param input The {@link ExtractorInput} from which data should be read. + * @param output The {@link ExtractorOutput} that will be used to initialize the selected + * extractor. + * @param uri The {@link Uri} of the data. + * @return An initialized extractor for reading {@code input}. + * @throws UnrecognizedInputFormatException Thrown if the input format could not be detected. + * @throws IOException Thrown if the input could not be read. + * @throws InterruptedException Thrown if the thread was interrupted. + */ + public Extractor selectExtractor(ExtractorInput input, ExtractorOutput output, Uri uri) + throws IOException, InterruptedException { + if (extractor != null) { + return extractor; + } + if (extractors.length == 1) { + this.extractor = extractors[0]; + } else { + for (Extractor extractor : extractors) { + try { + if (extractor.sniff(input)) { + this.extractor = extractor; + break; + } + } catch (EOFException e) { + // Do nothing. + } finally { + input.resetPeekPosition(); + } + } + if (extractor == null) { + throw new UnrecognizedInputFormatException( + "None of the available extractors (" + + Util.getCommaDelimitedSimpleClassNames(extractors) + + ") could read the stream.", + uri); + } + } + extractor.init(output); + return extractor; + } + + public void release() { + if (extractor != null) { + extractor.release(); + extractor = null; + } + } + } + + /** Stores state that is initialized when preparation completes. */ + private static final class PreparedState { + + public final SeekMap seekMap; + public final TrackGroupArray tracks; + public final boolean[] trackIsAudioVideoFlags; + public final boolean[] trackEnabledStates; + public final boolean[] trackNotifiedDownstreamFormats; + + public PreparedState( + SeekMap seekMap, TrackGroupArray tracks, boolean[] trackIsAudioVideoFlags) { + this.seekMap = seekMap; + this.tracks = tracks; + this.trackIsAudioVideoFlags = trackIsAudioVideoFlags; + this.trackEnabledStates = new boolean[tracks.length]; + this.trackNotifiedDownstreamFormats = new boolean[tracks.length]; + } + } + + /** Identifies a track. */ + private static final class TrackId { + + public final int id; + public final boolean isIcyTrack; + + public TrackId(int id, boolean isIcyTrack) { + this.id = id; + this.isIcyTrack = isIcyTrack; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackId other = (TrackId) obj; + return id == other.id && isIcyTrack == other.isIcyTrack; + } + + @Override + public int hashCode() { + return 31 * id + (isIcyTrack ? 1 : 0); + } + } + + private static Map<String, String> createIcyMetadataHeaders() { + Map<String, String> headers = new HashMap<>(); + headers.put( + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_NAME, + IcyHeaders.REQUEST_HEADER_ENABLE_METADATA_VALUE); + return Collections.unmodifiableMap(headers); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaSource.java new file mode 100644 index 0000000000..bed34a354b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ProgressiveMediaSource.java @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorsFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * Provides one period that loads data from a {@link Uri} and extracted using an {@link Extractor}. + * + * <p>If the possible input stream container formats are known, pass a factory that instantiates + * extractors for them to the constructor. Otherwise, pass a {@link DefaultExtractorsFactory} to use + * the default extractors. When reading a new stream, the first {@link Extractor} in the array of + * extractors created by the factory that returns {@code true} from {@link Extractor#sniff} will be + * used to extract samples from the input stream. + * + * <p>Note that the built-in extractor for FLV streams does not support seeking. + */ +public final class ProgressiveMediaSource extends BaseMediaSource + implements ProgressiveMediaPeriod.Listener { + + /** Factory for {@link ProgressiveMediaSource}s. */ + public static final class Factory implements MediaSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + private ExtractorsFactory extractorsFactory; + @Nullable private String customCacheKey; + @Nullable private Object tag; + private DrmSessionManager<?> drmSessionManager; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private int continueLoadingCheckIntervalBytes; + private boolean isCreateCalled; + + /** + * Creates a new factory for {@link ProgressiveMediaSource}s, using the extractors provided by + * {@link DefaultExtractorsFactory}. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this(dataSourceFactory, new DefaultExtractorsFactory()); + } + + /** + * Creates a new factory for {@link ProgressiveMediaSource}s. + * + * @param dataSourceFactory A factory for {@link DataSource}s to read the media. + * @param extractorsFactory A factory for extractors used to extract media from its container. + */ + public Factory(DataSource.Factory dataSourceFactory, ExtractorsFactory extractorsFactory) { + this.dataSourceFactory = dataSourceFactory; + this.extractorsFactory = extractorsFactory; + drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + continueLoadingCheckIntervalBytes = DEFAULT_LOADING_CHECK_INTERVAL_BYTES; + } + + /** + * Sets the factory for {@link Extractor}s to process the media stream. The default value is an + * instance of {@link DefaultExtractorsFactory}. + * + * @param extractorsFactory A factory for {@link Extractor}s to process the media stream. If the + * possible formats are known, pass a factory that instantiates extractors for those + * formats. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + * @deprecated Pass the {@link ExtractorsFactory} via {@link #Factory(DataSource.Factory, + * ExtractorsFactory)}. This is necessary so that proguard can treat the default extractors + * factory as unused. + */ + @Deprecated + public Factory setExtractorsFactory(ExtractorsFactory extractorsFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorsFactory = extractorsFactory; + return this; + } + + /** + * Sets the custom key that uniquely identifies the original stream. Used for cache indexing. + * The default value is {@code null}. + * + * @param customCacheKey A custom key that uniquely identifies the original stream. Used for + * cache indexing. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setCustomCacheKey(@Nullable String customCacheKey) { + Assertions.checkState(!isCreateCalled); + this.customCacheKey = customCacheKey; + return this; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * com.google.android.exoplayer2.Timeline} of the source as {@link + * com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the number of bytes that should be loaded between each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. The default value is + * {@link #DEFAULT_LOADING_CHECK_INTERVAL_BYTES}. + * + * @param continueLoadingCheckIntervalBytes The number of bytes that should be loaded between + * each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + * @return This factory, for convenience. + * @throws IllegalStateException If {@link #createMediaSource(Uri)} has already been called. + */ + public Factory setContinueLoadingCheckIntervalBytes(int continueLoadingCheckIntervalBytes) { + Assertions.checkState(!isCreateCalled); + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + return this; + } + + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + + /** + * Returns a new {@link ProgressiveMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @return The new {@link ProgressiveMediaSource}. + */ + @Override + public ProgressiveMediaSource createMediaSource(Uri uri) { + isCreateCalled = true; + return new ProgressiveMediaSource( + uri, + dataSourceFactory, + extractorsFactory, + drmSessionManager, + loadErrorHandlingPolicy, + customCacheKey, + continueLoadingCheckIntervalBytes, + tag); + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_OTHER}; + } + } + + /** + * The default number of bytes that should be loaded between each each invocation of {@link + * MediaPeriod.Callback#onContinueLoadingRequested(SequenceableLoader)}. + */ + public static final int DEFAULT_LOADING_CHECK_INTERVAL_BYTES = 1024 * 1024; + + private final Uri uri; + private final DataSource.Factory dataSourceFactory; + private final ExtractorsFactory extractorsFactory; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy; + @Nullable private final String customCacheKey; + private final int continueLoadingCheckIntervalBytes; + @Nullable private final Object tag; + + private long timelineDurationUs; + private boolean timelineIsSeekable; + private boolean timelineIsLive; + @Nullable private TransferListener transferListener; + + // TODO: Make private when ExtractorMediaSource is deleted. + /* package */ ProgressiveMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + ExtractorsFactory extractorsFactory, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadableLoadErrorHandlingPolicy, + @Nullable String customCacheKey, + int continueLoadingCheckIntervalBytes, + @Nullable Object tag) { + this.uri = uri; + this.dataSourceFactory = dataSourceFactory; + this.extractorsFactory = extractorsFactory; + this.drmSessionManager = drmSessionManager; + this.loadableLoadErrorHandlingPolicy = loadableLoadErrorHandlingPolicy; + this.customCacheKey = customCacheKey; + this.continueLoadingCheckIntervalBytes = continueLoadingCheckIntervalBytes; + this.timelineDurationUs = C.TIME_UNSET; + this.tag = tag; + } + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + transferListener = mediaTransferListener; + drmSessionManager.prepare(); + notifySourceInfoRefreshed(timelineDurationUs, timelineIsSeekable, timelineIsLive); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + DataSource dataSource = dataSourceFactory.createDataSource(); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return new ProgressiveMediaPeriod( + uri, + dataSource, + extractorsFactory.createExtractors(), + drmSessionManager, + loadableLoadErrorHandlingPolicy, + createEventDispatcher(id), + this, + allocator, + customCacheKey, + continueLoadingCheckIntervalBytes); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((ProgressiveMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + drmSessionManager.release(); + } + + // ProgressiveMediaPeriod.Listener implementation. + + @Override + public void onSourceInfoRefreshed(long durationUs, boolean isSeekable, boolean isLive) { + // If we already have the duration from a previous source info refresh, use it. + durationUs = durationUs == C.TIME_UNSET ? timelineDurationUs : durationUs; + if (timelineDurationUs == durationUs + && timelineIsSeekable == isSeekable + && timelineIsLive == isLive) { + // Suppress no-op source info changes. + return; + } + notifySourceInfoRefreshed(durationUs, isSeekable, isLive); + } + + // Internal methods. + + private void notifySourceInfoRefreshed(long durationUs, boolean isSeekable, boolean isLive) { + timelineDurationUs = durationUs; + timelineIsSeekable = isSeekable; + timelineIsLive = isLive; + // TODO: Split up isDynamic into multiple fields to indicate which values may change. Then + // indicate that the duration may change until it's known. See [internal: b/69703223]. + refreshSourceInfo( + new SinglePeriodTimeline( + timelineDurationUs, + timelineIsSeekable, + /* isDynamic= */ false, + /* isLive= */ timelineIsLive, + /* manifest= */ null, + tag)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleDataQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleDataQueue.java new file mode 100644 index 0000000000..81933a468d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleDataQueue.java @@ -0,0 +1,472 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.CryptoInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput.CryptoData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.SampleExtrasHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocation; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** A queue of media sample data. */ +/* package */ class SampleDataQueue { + + private static final int INITIAL_SCRATCH_SIZE = 32; + + private final Allocator allocator; + private final int allocationLength; + private final ParsableByteArray scratch; + + // References into the linked list of allocations. + private AllocationNode firstAllocationNode; + private AllocationNode readAllocationNode; + private AllocationNode writeAllocationNode; + + // Accessed only by the loading thread (or the consuming thread when there is no loading thread). + private long totalBytesWritten; + + public SampleDataQueue(Allocator allocator) { + this.allocator = allocator; + allocationLength = allocator.getIndividualAllocationLength(); + scratch = new ParsableByteArray(INITIAL_SCRATCH_SIZE); + firstAllocationNode = new AllocationNode(/* startPosition= */ 0, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** Clears all sample data. */ + public void reset() { + clearAllocationNodes(firstAllocationNode); + firstAllocationNode = new AllocationNode(0, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + totalBytesWritten = 0; + allocator.trim(); + } + + /** + * Discards sample data bytes from the write side of the queue. + * + * @param totalBytesWritten The reduced total number of bytes written after the samples have been + * discarded, or 0 if the queue is now empty. + */ + public void discardUpstreamSampleBytes(long totalBytesWritten) { + this.totalBytesWritten = totalBytesWritten; + if (this.totalBytesWritten == 0 + || this.totalBytesWritten == firstAllocationNode.startPosition) { + clearAllocationNodes(firstAllocationNode); + firstAllocationNode = new AllocationNode(this.totalBytesWritten, allocationLength); + readAllocationNode = firstAllocationNode; + writeAllocationNode = firstAllocationNode; + } else { + // Find the last node containing at least 1 byte of data that we need to keep. + AllocationNode lastNodeToKeep = firstAllocationNode; + while (this.totalBytesWritten > lastNodeToKeep.endPosition) { + lastNodeToKeep = lastNodeToKeep.next; + } + // Discard all subsequent nodes. + AllocationNode firstNodeToDiscard = lastNodeToKeep.next; + clearAllocationNodes(firstNodeToDiscard); + // Reset the successor of the last node to be an uninitialized node. + lastNodeToKeep.next = new AllocationNode(lastNodeToKeep.endPosition, allocationLength); + // Update writeAllocationNode and readAllocationNode as necessary. + writeAllocationNode = + this.totalBytesWritten == lastNodeToKeep.endPosition + ? lastNodeToKeep.next + : lastNodeToKeep; + if (readAllocationNode == firstNodeToDiscard) { + readAllocationNode = lastNodeToKeep.next; + } + } + } + + // Called by the consuming thread. + + /** Rewinds the read position to the first sample in the queue. */ + public void rewind() { + readAllocationNode = firstAllocationNode; + } + + /** + * Reads data from the rolling buffer to populate a decoder input buffer. + * + * @param buffer The buffer to populate. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + public void readToBuffer(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { + // Read encryption data if the sample is encrypted. + if (buffer.isEncrypted()) { + readEncryptionData(buffer, extrasHolder); + } + // Read sample data, extracting supplemental data into a separate buffer if needed. + if (buffer.hasSupplementalData()) { + // If there is supplemental data, the sample data is prefixed by its size. + scratch.reset(4); + readData(extrasHolder.offset, scratch.data, 4); + int sampleSize = scratch.readUnsignedIntToInt(); + extrasHolder.offset += 4; + extrasHolder.size -= 4; + + // Write the sample data. + buffer.ensureSpaceForWrite(sampleSize); + readData(extrasHolder.offset, buffer.data, sampleSize); + extrasHolder.offset += sampleSize; + extrasHolder.size -= sampleSize; + + // Write the remaining data as supplemental data. + buffer.resetSupplementalData(extrasHolder.size); + readData(extrasHolder.offset, buffer.supplementalData, extrasHolder.size); + } else { + // Write the sample data. + buffer.ensureSpaceForWrite(extrasHolder.size); + readData(extrasHolder.offset, buffer.data, extrasHolder.size); + } + } + + /** + * Advances the read position to the specified absolute position. + * + * @param absolutePosition The new absolute read position. May be {@link C#POSITION_UNSET}, in + * which case calling this method is a no-op. + */ + public void discardDownstreamTo(long absolutePosition) { + if (absolutePosition == C.POSITION_UNSET) { + return; + } + while (absolutePosition >= firstAllocationNode.endPosition) { + // Advance firstAllocationNode to the specified absolute position. Also clear nodes that are + // advanced past, and return their underlying allocations to the allocator. + allocator.release(firstAllocationNode.allocation); + firstAllocationNode = firstAllocationNode.clear(); + } + if (readAllocationNode.startPosition < firstAllocationNode.startPosition) { + // We discarded the node referenced by readAllocationNode. We need to advance it to the first + // remaining node. + readAllocationNode = firstAllocationNode; + } + } + + // Called by the loading thread. + + public long getTotalBytesWritten() { + return totalBytesWritten; + } + + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + length = preAppend(length); + int bytesAppended = + input.read( + writeAllocationNode.allocation.data, + writeAllocationNode.translateOffset(totalBytesWritten), + length); + if (bytesAppended == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } + throw new EOFException(); + } + postAppend(bytesAppended); + return bytesAppended; + } + + public void sampleData(ParsableByteArray buffer, int length) { + while (length > 0) { + int bytesAppended = preAppend(length); + buffer.readBytes( + writeAllocationNode.allocation.data, + writeAllocationNode.translateOffset(totalBytesWritten), + bytesAppended); + length -= bytesAppended; + postAppend(bytesAppended); + } + } + + // Private methods. + + /** + * Reads encryption data for the current sample. + * + * <p>The encryption data is written into {@link DecoderInputBuffer#cryptoInfo}, and {@link + * SampleExtrasHolder#size} is adjusted to subtract the number of bytes that were read. The same + * value is added to {@link SampleExtrasHolder#offset}. + * + * @param buffer The buffer into which the encryption data should be written. + * @param extrasHolder The extras holder whose offset should be read and subsequently adjusted. + */ + private void readEncryptionData(DecoderInputBuffer buffer, SampleExtrasHolder extrasHolder) { + long offset = extrasHolder.offset; + + // Read the signal byte. + scratch.reset(1); + readData(offset, scratch.data, 1); + offset++; + byte signalByte = scratch.data[0]; + boolean subsampleEncryption = (signalByte & 0x80) != 0; + int ivSize = signalByte & 0x7F; + + // Read the initialization vector. + CryptoInfo cryptoInfo = buffer.cryptoInfo; + if (cryptoInfo.iv == null) { + cryptoInfo.iv = new byte[16]; + } else { + // Zero out cryptoInfo.iv so that if ivSize < 16, the remaining bytes are correctly set to 0. + Arrays.fill(cryptoInfo.iv, (byte) 0); + } + readData(offset, cryptoInfo.iv, ivSize); + offset += ivSize; + + // Read the subsample count, if present. + int subsampleCount; + if (subsampleEncryption) { + scratch.reset(2); + readData(offset, scratch.data, 2); + offset += 2; + subsampleCount = scratch.readUnsignedShort(); + } else { + subsampleCount = 1; + } + + // Write the clear and encrypted subsample sizes. + @Nullable int[] clearDataSizes = cryptoInfo.numBytesOfClearData; + if (clearDataSizes == null || clearDataSizes.length < subsampleCount) { + clearDataSizes = new int[subsampleCount]; + } + @Nullable int[] encryptedDataSizes = cryptoInfo.numBytesOfEncryptedData; + if (encryptedDataSizes == null || encryptedDataSizes.length < subsampleCount) { + encryptedDataSizes = new int[subsampleCount]; + } + if (subsampleEncryption) { + int subsampleDataLength = 6 * subsampleCount; + scratch.reset(subsampleDataLength); + readData(offset, scratch.data, subsampleDataLength); + offset += subsampleDataLength; + scratch.setPosition(0); + for (int i = 0; i < subsampleCount; i++) { + clearDataSizes[i] = scratch.readUnsignedShort(); + encryptedDataSizes[i] = scratch.readUnsignedIntToInt(); + } + } else { + clearDataSizes[0] = 0; + encryptedDataSizes[0] = extrasHolder.size - (int) (offset - extrasHolder.offset); + } + + // Populate the cryptoInfo. + CryptoData cryptoData = extrasHolder.cryptoData; + cryptoInfo.set( + subsampleCount, + clearDataSizes, + encryptedDataSizes, + cryptoData.encryptionKey, + cryptoInfo.iv, + cryptoData.cryptoMode, + cryptoData.encryptedBlocks, + cryptoData.clearBlocks); + + // Adjust the offset and size to take into account the bytes read. + int bytesRead = (int) (offset - extrasHolder.offset); + extrasHolder.offset += bytesRead; + extrasHolder.size -= bytesRead; + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The buffer into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, ByteBuffer target, int length) { + advanceReadTo(absolutePosition); + int remaining = length; + while (remaining > 0) { + int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + Allocation allocation = readAllocationNode.allocation; + target.put(allocation.data, readAllocationNode.translateOffset(absolutePosition), toCopy); + remaining -= toCopy; + absolutePosition += toCopy; + if (absolutePosition == readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + } + + /** + * Reads data from the front of the rolling buffer. + * + * @param absolutePosition The absolute position from which data should be read. + * @param target The array into which data should be written. + * @param length The number of bytes to read. + */ + private void readData(long absolutePosition, byte[] target, int length) { + advanceReadTo(absolutePosition); + int remaining = length; + while (remaining > 0) { + int toCopy = Math.min(remaining, (int) (readAllocationNode.endPosition - absolutePosition)); + Allocation allocation = readAllocationNode.allocation; + System.arraycopy( + allocation.data, + readAllocationNode.translateOffset(absolutePosition), + target, + length - remaining, + toCopy); + remaining -= toCopy; + absolutePosition += toCopy; + if (absolutePosition == readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + } + + /** + * Advances the read position to the specified absolute position. + * + * @param absolutePosition The position to which {@link #readAllocationNode} should be advanced. + */ + private void advanceReadTo(long absolutePosition) { + while (absolutePosition >= readAllocationNode.endPosition) { + readAllocationNode = readAllocationNode.next; + } + } + + /** + * Clears allocation nodes starting from {@code fromNode}. + * + * @param fromNode The node from which to clear. + */ + private void clearAllocationNodes(AllocationNode fromNode) { + if (!fromNode.wasInitialized) { + return; + } + // Bulk release allocations for performance (it's significantly faster when using + // DefaultAllocator because the allocator's lock only needs to be acquired and released once) + // [Internal: See b/29542039]. + int allocationCount = + (writeAllocationNode.wasInitialized ? 1 : 0) + + ((int) (writeAllocationNode.startPosition - fromNode.startPosition) + / allocationLength); + Allocation[] allocationsToRelease = new Allocation[allocationCount]; + AllocationNode currentNode = fromNode; + for (int i = 0; i < allocationsToRelease.length; i++) { + allocationsToRelease[i] = currentNode.allocation; + currentNode = currentNode.clear(); + } + allocator.release(allocationsToRelease); + } + + /** + * Called before writing sample data to {@link #writeAllocationNode}. May cause {@link + * #writeAllocationNode} to be initialized. + * + * @param length The number of bytes that the caller wishes to write. + * @return The number of bytes that the caller is permitted to write, which may be less than + * {@code length}. + */ + private int preAppend(int length) { + if (!writeAllocationNode.wasInitialized) { + writeAllocationNode.initialize( + allocator.allocate(), + new AllocationNode(writeAllocationNode.endPosition, allocationLength)); + } + return Math.min(length, (int) (writeAllocationNode.endPosition - totalBytesWritten)); + } + + /** + * Called after writing sample data. May cause {@link #writeAllocationNode} to be advanced. + * + * @param length The number of bytes that were written. + */ + private void postAppend(int length) { + totalBytesWritten += length; + if (totalBytesWritten == writeAllocationNode.endPosition) { + writeAllocationNode = writeAllocationNode.next; + } + } + + /** A node in a linked list of {@link Allocation}s held by the output. */ + private static final class AllocationNode { + + /** The absolute position of the start of the data (inclusive). */ + public final long startPosition; + /** The absolute position of the end of the data (exclusive). */ + public final long endPosition; + /** Whether the node has been initialized. Remains true after {@link #clear()}. */ + public boolean wasInitialized; + /** The {@link Allocation}, or {@code null} if the node is not initialized. */ + @Nullable public Allocation allocation; + /** + * The next {@link AllocationNode} in the list, or {@code null} if the node has not been + * initialized. Remains set after {@link #clear()}. + */ + @Nullable public AllocationNode next; + + /** + * @param startPosition See {@link #startPosition}. + * @param allocationLength The length of the {@link Allocation} with which this node will be + * initialized. + */ + public AllocationNode(long startPosition, int allocationLength) { + this.startPosition = startPosition; + this.endPosition = startPosition + allocationLength; + } + + /** + * Initializes the node. + * + * @param allocation The node's {@link Allocation}. + * @param next The next {@link AllocationNode}. + */ + public void initialize(Allocation allocation, AllocationNode next) { + this.allocation = allocation; + this.next = next; + wasInitialized = true; + } + + /** + * Gets the offset into the {@link #allocation}'s {@link Allocation#data} that corresponds to + * the specified absolute position. + * + * @param absolutePosition The absolute position. + * @return The corresponding offset into the allocation's data. + */ + public int translateOffset(long absolutePosition) { + return (int) (absolutePosition - startPosition) + allocation.offset; + } + + /** + * Clears {@link #allocation} and {@link #next}. + * + * @return The cleared next {@link AllocationNode}. + */ + public AllocationNode clear() { + allocation = null; + AllocationNode temp = next; + next = null; + return temp; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleQueue.java new file mode 100644 index 0000000000..639cccee00 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleQueue.java @@ -0,0 +1,926 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Looper; +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** A queue of media samples. */ +public class SampleQueue implements TrackOutput { + + /** A listener for changes to the upstream format. */ + public interface UpstreamFormatChangedListener { + + /** + * Called on the loading thread when an upstream format change occurs. + * + * @param format The new upstream format. + */ + void onUpstreamFormatChanged(Format format); + } + + @VisibleForTesting /* package */ static final int SAMPLE_CAPACITY_INCREMENT = 1000; + + private final SampleDataQueue sampleDataQueue; + private final SampleExtrasHolder extrasHolder; + private final DrmSessionManager<?> drmSessionManager; + private UpstreamFormatChangedListener upstreamFormatChangeListener; + + @Nullable private Format downstreamFormat; + @Nullable private DrmSession<?> currentDrmSession; + + private int capacity; + private int[] sourceIds; + private long[] offsets; + private int[] sizes; + private int[] flags; + private long[] timesUs; + private CryptoData[] cryptoDatas; + private Format[] formats; + + private int length; + private int absoluteFirstIndex; + private int relativeFirstIndex; + private int readPosition; + + private long largestDiscardedTimestampUs; + private long largestQueuedTimestampUs; + private boolean isLastSampleQueued; + private boolean upstreamKeyframeRequired; + private boolean upstreamFormatRequired; + private Format upstreamFormat; + private Format upstreamCommittedFormat; + private int upstreamSourceId; + + private boolean pendingUpstreamFormatAdjustment; + private Format unadjustedUpstreamFormat; + private long sampleOffsetUs; + private boolean pendingSplice; + + /** + * Creates a sample queue. + * + * @param allocator An {@link Allocator} from which allocations for sample data can be obtained. + * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} + * from. The created instance does not take ownership of this {@link DrmSessionManager}. + */ + public SampleQueue(Allocator allocator, DrmSessionManager<?> drmSessionManager) { + sampleDataQueue = new SampleDataQueue(allocator); + this.drmSessionManager = drmSessionManager; + extrasHolder = new SampleExtrasHolder(); + capacity = SAMPLE_CAPACITY_INCREMENT; + sourceIds = new int[capacity]; + offsets = new long[capacity]; + timesUs = new long[capacity]; + flags = new int[capacity]; + sizes = new int[capacity]; + cryptoDatas = new CryptoData[capacity]; + formats = new Format[capacity]; + largestDiscardedTimestampUs = Long.MIN_VALUE; + largestQueuedTimestampUs = Long.MIN_VALUE; + upstreamFormatRequired = true; + upstreamKeyframeRequired = true; + } + + // Called by the consuming thread when there is no loading thread. + + /** Calls {@link #reset(boolean) reset(true)} and releases any resources owned by the queue. */ + @CallSuper + public void release() { + reset(/* resetUpstreamFormat= */ true); + releaseDrmSessionReferences(); + } + + /** Convenience method for {@code reset(false)}. */ + public final void reset() { + reset(/* resetUpstreamFormat= */ false); + } + + /** + * Clears all samples from the queue. + * + * @param resetUpstreamFormat Whether the upstream format should be cleared. If set to false, + * samples queued after the reset (and before a subsequent call to {@link #format(Format)}) + * are assumed to have the current upstream format. If set to true, {@link #format(Format)} + * must be called after the reset before any more samples can be queued. + */ + @CallSuper + public void reset(boolean resetUpstreamFormat) { + sampleDataQueue.reset(); + length = 0; + absoluteFirstIndex = 0; + relativeFirstIndex = 0; + readPosition = 0; + upstreamKeyframeRequired = true; + largestDiscardedTimestampUs = Long.MIN_VALUE; + largestQueuedTimestampUs = Long.MIN_VALUE; + isLastSampleQueued = false; + upstreamCommittedFormat = null; + if (resetUpstreamFormat) { + unadjustedUpstreamFormat = null; + upstreamFormat = null; + upstreamFormatRequired = true; + } + } + + /** + * Sets a source identifier for subsequent samples. + * + * @param sourceId The source identifier. + */ + public final void sourceId(int sourceId) { + upstreamSourceId = sourceId; + } + + /** Indicates samples that are subsequently queued should be spliced into those already queued. */ + public final void splice() { + pendingSplice = true; + } + + /** Returns the current absolute write index. */ + public final int getWriteIndex() { + return absoluteFirstIndex + length; + } + + /** + * Discards samples from the write side of the queue. + * + * @param discardFromIndex The absolute index of the first sample to be discarded. Must be in the + * range [{@link #getReadIndex()}, {@link #getWriteIndex()}]. + */ + public final void discardUpstreamSamples(int discardFromIndex) { + sampleDataQueue.discardUpstreamSampleBytes(discardUpstreamSampleMetadata(discardFromIndex)); + } + + // Called by the consuming thread. + + /** Calls {@link #discardToEnd()} and releases any resources owned by the queue. */ + @CallSuper + public void preRelease() { + discardToEnd(); + releaseDrmSessionReferences(); + } + + /** + * Throws an error that's preventing data from being read. Does nothing if no such error exists. + * + * @throws IOException The underlying error. + */ + @CallSuper + public void maybeThrowError() throws IOException { + // TODO: Avoid throwing if the DRM error is not preventing a read operation. + if (currentDrmSession != null && currentDrmSession.getState() == DrmSession.STATE_ERROR) { + throw Assertions.checkNotNull(currentDrmSession.getError()); + } + } + + /** Returns the current absolute start index. */ + public final int getFirstIndex() { + return absoluteFirstIndex; + } + + /** Returns the current absolute read index. */ + public final int getReadIndex() { + return absoluteFirstIndex + readPosition; + } + + /** + * Peeks the source id of the next sample to be read, or the current upstream source id if the + * queue is empty or if the read position is at the end of the queue. + * + * @return The source id. + */ + public final synchronized int peekSourceId() { + int relativeReadIndex = getRelativeIndex(readPosition); + return hasNextSample() ? sourceIds[relativeReadIndex] : upstreamSourceId; + } + + /** Returns the upstream {@link Format} in which samples are being queued. */ + public final synchronized Format getUpstreamFormat() { + return upstreamFormatRequired ? null : upstreamFormat; + } + + /** + * Returns the largest sample timestamp that has been queued since the last {@link #reset}. + * + * <p>Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. + * + * @return The largest sample timestamp that has been queued, or {@link Long#MIN_VALUE} if no + * samples have been queued. + */ + public final synchronized long getLargestQueuedTimestampUs() { + return largestQueuedTimestampUs; + } + + /** + * Returns whether the last sample of the stream has knowingly been queued. A return value of + * {@code false} means that the last sample had not been queued or that it's unknown whether the + * last sample has been queued. + * + * <p>Samples that were discarded by calling {@link #discardUpstreamSamples(int)} are not + * considered as having been queued. Samples that were dequeued from the front of the queue are + * considered as having been queued. + */ + public final synchronized boolean isLastSampleQueued() { + return isLastSampleQueued; + } + + /** Returns the timestamp of the first sample, or {@link Long#MIN_VALUE} if the queue is empty. */ + public final synchronized long getFirstTimestampUs() { + return length == 0 ? Long.MIN_VALUE : timesUs[relativeFirstIndex]; + } + + /** + * Returns whether there is data available for reading. + * + * <p>Note: If the stream has ended then a buffer with the end of stream flag can always be read + * from {@link #read}. Hence an ended stream is always ready. + * + * @param loadingFinished Whether no more samples will be written to the sample queue. When true, + * this method returns true if the sample queue is empty, because an empty sample queue means + * the end of stream has been reached. When false, this method returns false if the sample + * queue is empty. + */ + @SuppressWarnings("ReferenceEquality") // See comments in setUpstreamFormat + @CallSuper + public synchronized boolean isReady(boolean loadingFinished) { + if (!hasNextSample()) { + return loadingFinished + || isLastSampleQueued + || (upstreamFormat != null && upstreamFormat != downstreamFormat); + } + int relativeReadIndex = getRelativeIndex(readPosition); + if (formats[relativeReadIndex] != downstreamFormat) { + // A format can be read. + return true; + } + return mayReadSample(relativeReadIndex); + } + + /** + * Attempts to read from the queue. + * + * <p>{@link Format Formats} read from this method may be associated to a {@link DrmSession} + * through {@link FormatHolder#drmSession}, which is populated in two scenarios: + * + * <ul> + * <li>The {@link Format} has a non-null {@link Format#drmInitData}. + * <li>The {@link DrmSessionManager} provides placeholder sessions for this queue's track type. + * See {@link DrmSessionManager#acquirePlaceholderSession(Looper, int)}. + * </ul> + * + * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. + * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the + * end of the stream. If the end of the stream has been reached, the {@link + * C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. If a {@link + * DecoderInputBuffer#isFlagsOnly() flags-only} buffer is passed, only the buffer flags may be + * populated by this method and the read position of the queue will not change. + * @param formatRequired Whether the caller requires that the format of the stream be read even if + * it's not changing. A sample will never be read if set to true, however it is still possible + * for the end of stream or nothing to be read. + * @param loadingFinished True if an empty queue should be considered the end of the stream. + * @param decodeOnlyUntilUs If a buffer is read, the {@link C#BUFFER_FLAG_DECODE_ONLY} flag will + * be set if the buffer's timestamp is less than this value. + * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or + * {@link C#RESULT_BUFFER_READ}. + */ + @CallSuper + public int read( + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired, + boolean loadingFinished, + long decodeOnlyUntilUs) { + int result = + readSampleMetadata( + formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilUs, extrasHolder); + if (result == C.RESULT_BUFFER_READ && !buffer.isEndOfStream() && !buffer.isFlagsOnly()) { + sampleDataQueue.readToBuffer(buffer, extrasHolder); + } + return result; + } + + /** + * Attempts to seek the read position to the specified sample index. + * + * @param sampleIndex The sample index. + * @return Whether the seek was successful. + */ + public final synchronized boolean seekTo(int sampleIndex) { + rewind(); + if (sampleIndex < absoluteFirstIndex || sampleIndex > absoluteFirstIndex + length) { + return false; + } + readPosition = sampleIndex - absoluteFirstIndex; + return true; + } + + /** + * Attempts to seek the read position to the keyframe before or at the specified time. + * + * @param timeUs The time to seek to. + * @param allowTimeBeyondBuffer Whether the operation can succeed if {@code timeUs} is beyond the + * end of the queue, by seeking to the last sample (or keyframe). + * @return Whether the seek was successful. + */ + public final synchronized boolean seekTo(long timeUs, boolean allowTimeBeyondBuffer) { + rewind(); + int relativeReadIndex = getRelativeIndex(readPosition); + if (!hasNextSample() + || timeUs < timesUs[relativeReadIndex] + || (timeUs > largestQueuedTimestampUs && !allowTimeBeyondBuffer)) { + return false; + } + int offset = + findSampleBefore(relativeReadIndex, length - readPosition, timeUs, /* keyframe= */ true); + if (offset == -1) { + return false; + } + readPosition += offset; + return true; + } + + /** + * Advances the read position to the keyframe before or at the specified time. + * + * @param timeUs The time to advance to. + * @return The number of samples that were skipped, which may be equal to 0. + */ + public final synchronized int advanceTo(long timeUs) { + int relativeReadIndex = getRelativeIndex(readPosition); + if (!hasNextSample() || timeUs < timesUs[relativeReadIndex]) { + return 0; + } + int offset = + findSampleBefore(relativeReadIndex, length - readPosition, timeUs, /* keyframe= */ true); + if (offset == -1) { + return 0; + } + readPosition += offset; + return offset; + } + + /** + * Advances the read position to the end of the queue. + * + * @return The number of samples that were skipped. + */ + public final synchronized int advanceToEnd() { + int skipCount = length - readPosition; + readPosition = length; + return skipCount; + } + + /** + * Discards up to but not including the sample immediately before or at the specified time. + * + * @param timeUs The time to discard up to. + * @param toKeyframe If true then discards samples up to the keyframe before or at the specified + * time, rather than any sample before or at that time. + * @param stopAtReadPosition If true then samples are only discarded if they're before the read + * position. If false then samples at and beyond the read position may be discarded, in which + * case the read position is advanced to the first remaining sample. + */ + public final void discardTo(long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { + sampleDataQueue.discardDownstreamTo( + discardSampleMetadataTo(timeUs, toKeyframe, stopAtReadPosition)); + } + + /** Discards up to but not including the read position. */ + public final void discardToRead() { + sampleDataQueue.discardDownstreamTo(discardSampleMetadataToRead()); + } + + /** Discards all samples in the queue and advances the read position. */ + public final void discardToEnd() { + sampleDataQueue.discardDownstreamTo(discardSampleMetadataToEnd()); + } + + // Called by the loading thread. + + /** + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that + * are subsequently queued. + * + * @param sampleOffsetUs The timestamp offset in microseconds. + */ + public final void setSampleOffsetUs(long sampleOffsetUs) { + if (this.sampleOffsetUs != sampleOffsetUs) { + this.sampleOffsetUs = sampleOffsetUs; + invalidateUpstreamFormatAdjustment(); + } + } + + /** + * Sets a listener to be notified of changes to the upstream format. + * + * @param listener The listener. + */ + public final void setUpstreamFormatChangeListener(UpstreamFormatChangedListener listener) { + upstreamFormatChangeListener = listener; + } + + // TrackOutput implementation. Called by the loading thread. + + @Override + public final void format(Format unadjustedUpstreamFormat) { + Format adjustedUpstreamFormat = getAdjustedUpstreamFormat(unadjustedUpstreamFormat); + pendingUpstreamFormatAdjustment = false; + this.unadjustedUpstreamFormat = unadjustedUpstreamFormat; + boolean upstreamFormatChanged = setUpstreamFormat(adjustedUpstreamFormat); + if (upstreamFormatChangeListener != null && upstreamFormatChanged) { + upstreamFormatChangeListener.onUpstreamFormatChanged(adjustedUpstreamFormat); + } + } + + @Override + public final int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + return sampleDataQueue.sampleData(input, length, allowEndOfInput); + } + + @Override + public final void sampleData(ParsableByteArray buffer, int length) { + sampleDataQueue.sampleData(buffer, length); + } + + @Override + public final void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData cryptoData) { + if (pendingUpstreamFormatAdjustment) { + format(unadjustedUpstreamFormat); + } + timeUs += sampleOffsetUs; + if (pendingSplice) { + if ((flags & C.BUFFER_FLAG_KEY_FRAME) == 0 || !attemptSplice(timeUs)) { + return; + } + pendingSplice = false; + } + long absoluteOffset = sampleDataQueue.getTotalBytesWritten() - size - offset; + commitSample(timeUs, flags, absoluteOffset, size, cryptoData); + } + + /** + * Invalidates the last upstream format adjustment. {@link #getAdjustedUpstreamFormat(Format)} + * will be called to adjust the upstream {@link Format} again before the next sample is queued. + */ + protected final void invalidateUpstreamFormatAdjustment() { + pendingUpstreamFormatAdjustment = true; + } + + /** + * Adjusts the upstream {@link Format} (i.e., the {@link Format} that was most recently passed to + * {@link #format(Format)}). + * + * <p>The default implementation incorporates the sample offset passed to {@link + * #setSampleOffsetUs(long)} into {@link Format#subsampleOffsetUs}. + * + * @param format The {@link Format} to adjust. + * @return The adjusted {@link Format}. + */ + @CallSuper + protected Format getAdjustedUpstreamFormat(Format format) { + if (sampleOffsetUs != 0 && format.subsampleOffsetUs != Format.OFFSET_SAMPLE_RELATIVE) { + format = format.copyWithSubsampleOffsetUs(format.subsampleOffsetUs + sampleOffsetUs); + } + return format; + } + + // Internal methods. + + /** Rewinds the read position to the first sample in the queue. */ + private synchronized void rewind() { + readPosition = 0; + sampleDataQueue.rewind(); + } + + @SuppressWarnings("ReferenceEquality") // See comments in setUpstreamFormat + private synchronized int readSampleMetadata( + FormatHolder formatHolder, + DecoderInputBuffer buffer, + boolean formatRequired, + boolean loadingFinished, + long decodeOnlyUntilUs, + SampleExtrasHolder extrasHolder) { + buffer.waitingForKeys = false; + // This is a temporary fix for https://github.com/google/ExoPlayer/issues/6155. + // TODO: Remove it and replace it with a fix that discards samples when writing to the queue. + boolean hasNextSample; + int relativeReadIndex = C.INDEX_UNSET; + while ((hasNextSample = hasNextSample())) { + relativeReadIndex = getRelativeIndex(readPosition); + long timeUs = timesUs[relativeReadIndex]; + if (timeUs < decodeOnlyUntilUs + && MimeTypes.allSamplesAreSyncSamples(formats[relativeReadIndex].sampleMimeType)) { + readPosition++; + } else { + break; + } + } + + if (!hasNextSample) { + if (loadingFinished || isLastSampleQueued) { + buffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } else if (upstreamFormat != null && (formatRequired || upstreamFormat != downstreamFormat)) { + onFormatResult(Assertions.checkNotNull(upstreamFormat), formatHolder); + return C.RESULT_FORMAT_READ; + } else { + return C.RESULT_NOTHING_READ; + } + } + + if (formatRequired || formats[relativeReadIndex] != downstreamFormat) { + onFormatResult(formats[relativeReadIndex], formatHolder); + return C.RESULT_FORMAT_READ; + } + + if (!mayReadSample(relativeReadIndex)) { + buffer.waitingForKeys = true; + return C.RESULT_NOTHING_READ; + } + + buffer.setFlags(flags[relativeReadIndex]); + buffer.timeUs = timesUs[relativeReadIndex]; + if (buffer.timeUs < decodeOnlyUntilUs) { + buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY); + } + if (buffer.isFlagsOnly()) { + return C.RESULT_BUFFER_READ; + } + extrasHolder.size = sizes[relativeReadIndex]; + extrasHolder.offset = offsets[relativeReadIndex]; + extrasHolder.cryptoData = cryptoDatas[relativeReadIndex]; + + readPosition++; + return C.RESULT_BUFFER_READ; + } + + private synchronized boolean setUpstreamFormat(Format format) { + if (format == null) { + upstreamFormatRequired = true; + return false; + } + upstreamFormatRequired = false; + if (Util.areEqual(format, upstreamFormat)) { + // The format is unchanged. If format and upstreamFormat are different objects, we keep the + // current upstreamFormat so we can detect format changes on the read side using cheap + // referential quality. + return false; + } else if (Util.areEqual(format, upstreamCommittedFormat)) { + // The format has changed back to the format of the last committed sample. If they are + // different objects, we revert back to using upstreamCommittedFormat as the upstreamFormat + // so we can detect format changes on the read side using cheap referential equality. + upstreamFormat = upstreamCommittedFormat; + return true; + } else { + upstreamFormat = format; + return true; + } + } + + private synchronized long discardSampleMetadataTo( + long timeUs, boolean toKeyframe, boolean stopAtReadPosition) { + if (length == 0 || timeUs < timesUs[relativeFirstIndex]) { + return C.POSITION_UNSET; + } + int searchLength = stopAtReadPosition && readPosition != length ? readPosition + 1 : length; + int discardCount = findSampleBefore(relativeFirstIndex, searchLength, timeUs, toKeyframe); + if (discardCount == -1) { + return C.POSITION_UNSET; + } + return discardSamples(discardCount); + } + + public synchronized long discardSampleMetadataToRead() { + if (readPosition == 0) { + return C.POSITION_UNSET; + } + return discardSamples(readPosition); + } + + private synchronized long discardSampleMetadataToEnd() { + if (length == 0) { + return C.POSITION_UNSET; + } + return discardSamples(length); + } + + private void releaseDrmSessionReferences() { + if (currentDrmSession != null) { + currentDrmSession.release(); + currentDrmSession = null; + // Clear downstream format to avoid violating the assumption that downstreamFormat.drmInitData + // != null implies currentSession != null + downstreamFormat = null; + } + } + + private synchronized void commitSample( + long timeUs, @C.BufferFlags int sampleFlags, long offset, int size, CryptoData cryptoData) { + if (upstreamKeyframeRequired) { + if ((sampleFlags & C.BUFFER_FLAG_KEY_FRAME) == 0) { + return; + } + upstreamKeyframeRequired = false; + } + Assertions.checkState(!upstreamFormatRequired); + + isLastSampleQueued = (sampleFlags & C.BUFFER_FLAG_LAST_SAMPLE) != 0; + largestQueuedTimestampUs = Math.max(largestQueuedTimestampUs, timeUs); + + int relativeEndIndex = getRelativeIndex(length); + timesUs[relativeEndIndex] = timeUs; + offsets[relativeEndIndex] = offset; + sizes[relativeEndIndex] = size; + flags[relativeEndIndex] = sampleFlags; + cryptoDatas[relativeEndIndex] = cryptoData; + formats[relativeEndIndex] = upstreamFormat; + sourceIds[relativeEndIndex] = upstreamSourceId; + upstreamCommittedFormat = upstreamFormat; + + length++; + if (length == capacity) { + // Increase the capacity. + int newCapacity = capacity + SAMPLE_CAPACITY_INCREMENT; + int[] newSourceIds = new int[newCapacity]; + long[] newOffsets = new long[newCapacity]; + long[] newTimesUs = new long[newCapacity]; + int[] newFlags = new int[newCapacity]; + int[] newSizes = new int[newCapacity]; + CryptoData[] newCryptoDatas = new CryptoData[newCapacity]; + Format[] newFormats = new Format[newCapacity]; + int beforeWrap = capacity - relativeFirstIndex; + System.arraycopy(offsets, relativeFirstIndex, newOffsets, 0, beforeWrap); + System.arraycopy(timesUs, relativeFirstIndex, newTimesUs, 0, beforeWrap); + System.arraycopy(flags, relativeFirstIndex, newFlags, 0, beforeWrap); + System.arraycopy(sizes, relativeFirstIndex, newSizes, 0, beforeWrap); + System.arraycopy(cryptoDatas, relativeFirstIndex, newCryptoDatas, 0, beforeWrap); + System.arraycopy(formats, relativeFirstIndex, newFormats, 0, beforeWrap); + System.arraycopy(sourceIds, relativeFirstIndex, newSourceIds, 0, beforeWrap); + int afterWrap = relativeFirstIndex; + System.arraycopy(offsets, 0, newOffsets, beforeWrap, afterWrap); + System.arraycopy(timesUs, 0, newTimesUs, beforeWrap, afterWrap); + System.arraycopy(flags, 0, newFlags, beforeWrap, afterWrap); + System.arraycopy(sizes, 0, newSizes, beforeWrap, afterWrap); + System.arraycopy(cryptoDatas, 0, newCryptoDatas, beforeWrap, afterWrap); + System.arraycopy(formats, 0, newFormats, beforeWrap, afterWrap); + System.arraycopy(sourceIds, 0, newSourceIds, beforeWrap, afterWrap); + offsets = newOffsets; + timesUs = newTimesUs; + flags = newFlags; + sizes = newSizes; + cryptoDatas = newCryptoDatas; + formats = newFormats; + sourceIds = newSourceIds; + relativeFirstIndex = 0; + capacity = newCapacity; + } + } + + /** + * Attempts to discard samples from the end of the queue to allow samples starting from the + * specified timestamp to be spliced in. Samples will not be discarded prior to the read position. + * + * @param timeUs The timestamp at which the splice occurs. + * @return Whether the splice was successful. + */ + private synchronized boolean attemptSplice(long timeUs) { + if (length == 0) { + return timeUs > largestDiscardedTimestampUs; + } + long largestReadTimestampUs = + Math.max(largestDiscardedTimestampUs, getLargestTimestamp(readPosition)); + if (largestReadTimestampUs >= timeUs) { + return false; + } + int retainCount = length; + int relativeSampleIndex = getRelativeIndex(length - 1); + while (retainCount > readPosition && timesUs[relativeSampleIndex] >= timeUs) { + retainCount--; + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + discardUpstreamSampleMetadata(absoluteFirstIndex + retainCount); + return true; + } + + private long discardUpstreamSampleMetadata(int discardFromIndex) { + int discardCount = getWriteIndex() - discardFromIndex; + Assertions.checkArgument(0 <= discardCount && discardCount <= (length - readPosition)); + length -= discardCount; + largestQueuedTimestampUs = Math.max(largestDiscardedTimestampUs, getLargestTimestamp(length)); + isLastSampleQueued = discardCount == 0 && isLastSampleQueued; + if (length != 0) { + int relativeLastWriteIndex = getRelativeIndex(length - 1); + return offsets[relativeLastWriteIndex] + sizes[relativeLastWriteIndex]; + } + return 0; + } + + private boolean hasNextSample() { + return readPosition != length; + } + + /** + * Sets the downstream format, performs DRM resource management, and populates the {@code + * outputFormatHolder}. + * + * @param newFormat The new downstream format. + * @param outputFormatHolder The output {@link FormatHolder}. + */ + private void onFormatResult(Format newFormat, FormatHolder outputFormatHolder) { + outputFormatHolder.format = newFormat; + boolean isFirstFormat = downstreamFormat == null; + DrmInitData oldDrmInitData = isFirstFormat ? null : downstreamFormat.drmInitData; + downstreamFormat = newFormat; + if (drmSessionManager == DrmSessionManager.DUMMY) { + // Avoid attempting to acquire a session using the dummy DRM session manager. It's likely that + // the media source creation has not yet been migrated and the renderer can acquire the + // session for the read DRM init data. + // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. + return; + } + DrmInitData newDrmInitData = newFormat.drmInitData; + outputFormatHolder.includesDrmSession = true; + outputFormatHolder.drmSession = currentDrmSession; + if (!isFirstFormat && Util.areEqual(oldDrmInitData, newDrmInitData)) { + // Nothing to do. + return; + } + // Ensure we acquire the new session before releasing the previous one in case the same session + // is being used for both DrmInitData. + DrmSession<?> previousSession = currentDrmSession; + Looper playbackLooper = Assertions.checkNotNull(Looper.myLooper()); + currentDrmSession = + newDrmInitData != null + ? drmSessionManager.acquireSession(playbackLooper, newDrmInitData) + : drmSessionManager.acquirePlaceholderSession( + playbackLooper, MimeTypes.getTrackType(newFormat.sampleMimeType)); + outputFormatHolder.drmSession = currentDrmSession; + + if (previousSession != null) { + previousSession.release(); + } + } + + /** + * Returns whether it's possible to read the next sample. + * + * @param relativeReadIndex The relative read index of the next sample. + * @return Whether it's possible to read the next sample. + */ + private boolean mayReadSample(int relativeReadIndex) { + if (drmSessionManager == DrmSessionManager.DUMMY) { + // TODO: Remove once renderers are migrated [Internal ref: b/122519809]. + // For protected content it's likely that the DrmSessionManager is still being injected into + // the renderers. We assume that the renderers will be able to acquire a DrmSession if needed. + return true; + } + return currentDrmSession == null + || currentDrmSession.getState() == DrmSession.STATE_OPENED_WITH_KEYS + || ((flags[relativeReadIndex] & C.BUFFER_FLAG_ENCRYPTED) == 0 + && currentDrmSession.playClearSamplesWithoutKeys()); + } + + /** + * Finds the sample in the specified range that's before or at the specified time. If {@code + * keyframe} is {@code true} then the sample is additionally required to be a keyframe. + * + * @param relativeStartIndex The relative index from which to start searching. + * @param length The length of the range being searched. + * @param timeUs The specified time. + * @param keyframe Whether only keyframes should be considered. + * @return The offset from {@code relativeFirstIndex} to the found sample, or -1 if no matching + * sample was found. + */ + private int findSampleBefore(int relativeStartIndex, int length, long timeUs, boolean keyframe) { + // This could be optimized to use a binary search, however in practice callers to this method + // normally pass times near to the start of the search region. Hence it's unclear whether + // switching to a binary search would yield any real benefit. + int sampleCountToTarget = -1; + int searchIndex = relativeStartIndex; + for (int i = 0; i < length && timesUs[searchIndex] <= timeUs; i++) { + if (!keyframe || (flags[searchIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + // We've found a suitable sample. + sampleCountToTarget = i; + } + searchIndex++; + if (searchIndex == capacity) { + searchIndex = 0; + } + } + return sampleCountToTarget; + } + + /** + * Discards the specified number of samples. + * + * @param discardCount The number of samples to discard. + * @return The corresponding offset up to which data should be discarded. + */ + private long discardSamples(int discardCount) { + largestDiscardedTimestampUs = + Math.max(largestDiscardedTimestampUs, getLargestTimestamp(discardCount)); + length -= discardCount; + absoluteFirstIndex += discardCount; + relativeFirstIndex += discardCount; + if (relativeFirstIndex >= capacity) { + relativeFirstIndex -= capacity; + } + readPosition -= discardCount; + if (readPosition < 0) { + readPosition = 0; + } + if (length == 0) { + int relativeLastDiscardIndex = (relativeFirstIndex == 0 ? capacity : relativeFirstIndex) - 1; + return offsets[relativeLastDiscardIndex] + sizes[relativeLastDiscardIndex]; + } else { + return offsets[relativeFirstIndex]; + } + } + + /** + * Finds the largest timestamp of any sample from the start of the queue up to the specified + * length, assuming that the timestamps prior to a keyframe are always less than the timestamp of + * the keyframe itself, and of subsequent frames. + * + * @param length The length of the range being searched. + * @return The largest timestamp, or {@link Long#MIN_VALUE} if {@code length == 0}. + */ + private long getLargestTimestamp(int length) { + if (length == 0) { + return Long.MIN_VALUE; + } + long largestTimestampUs = Long.MIN_VALUE; + int relativeSampleIndex = getRelativeIndex(length - 1); + for (int i = 0; i < length; i++) { + largestTimestampUs = Math.max(largestTimestampUs, timesUs[relativeSampleIndex]); + if ((flags[relativeSampleIndex] & C.BUFFER_FLAG_KEY_FRAME) != 0) { + break; + } + relativeSampleIndex--; + if (relativeSampleIndex == -1) { + relativeSampleIndex = capacity - 1; + } + } + return largestTimestampUs; + } + + /** + * Returns the relative index for a given offset from the start of the queue. + * + * @param offset The offset, which must be in the range [0, length]. + */ + private int getRelativeIndex(int offset) { + int relativeIndex = relativeFirstIndex + offset; + return relativeIndex < capacity ? relativeIndex : relativeIndex - capacity; + } + + /** A holder for sample metadata not held by {@link DecoderInputBuffer}. */ + /* package */ static final class SampleExtrasHolder { + + public int size; + public long offset; + public CryptoData cryptoData; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleStream.java new file mode 100644 index 0000000000..54a7d0f895 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SampleStream.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import java.io.IOException; + +/** + * A stream of media samples (and associated format information). + */ +public interface SampleStream { + + /** + * Returns whether data is available to be read. + * <p> + * Note: If the stream has ended then a buffer with the end of stream flag can always be read from + * {@link #readData(FormatHolder, DecoderInputBuffer, boolean)}. Hence an ended stream is always + * ready. + * + * @return Whether data is available to be read. + */ + boolean isReady(); + + /** + * Throws an error that's preventing data from being read. Does nothing if no such error exists. + * + * @throws IOException The underlying error. + */ + void maybeThrowError() throws IOException; + + /** + * Attempts to read from the stream. + * + * <p>If the stream has ended then {@link C#BUFFER_FLAG_END_OF_STREAM} flag is set on {@code + * buffer} and {@link C#RESULT_BUFFER_READ} is returned. Else if no data is available then {@link + * C#RESULT_NOTHING_READ} is returned. Else if the format of the media is changing or if {@code + * formatRequired} is set then {@code formatHolder} is populated and {@link C#RESULT_FORMAT_READ} + * is returned. Else {@code buffer} is populated and {@link C#RESULT_BUFFER_READ} is returned. + * + * @param formatHolder A {@link FormatHolder} to populate in the case of reading a format. + * @param buffer A {@link DecoderInputBuffer} to populate in the case of reading a sample or the + * end of the stream. If the end of the stream has been reached, the {@link + * C#BUFFER_FLAG_END_OF_STREAM} flag will be set on the buffer. If a {@link + * DecoderInputBuffer#isFlagsOnly() flags-only} buffer is passed, then no {@link + * DecoderInputBuffer#data} will be read and the read position of the stream will not change, + * but the flags of the buffer will be populated. + * @param formatRequired Whether the caller requires that the format of the stream be read even if + * it's not changing. A sample will never be read if set to true, however it is still possible + * for the end of stream or nothing to be read. + * @return The result, which can be {@link C#RESULT_NOTHING_READ}, {@link C#RESULT_FORMAT_READ} or + * {@link C#RESULT_BUFFER_READ}. + */ + int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired); + + /** + * Attempts to skip to the keyframe before the specified position, or to the end of the stream if + * {@code positionUs} is beyond it. + * + * @param positionUs The specified time. + * @return The number of samples that were skipped. + */ + int skipData(long positionUs); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SequenceableLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SequenceableLoader.java new file mode 100644 index 0000000000..09cb8b663b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SequenceableLoader.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +// TODO: Clarify the requirements for implementing this interface [Internal ref: b/36250203]. +/** + * A loader that can proceed in approximate synchronization with other loaders. + */ +public interface SequenceableLoader { + + /** + * A callback to be notified of {@link SequenceableLoader} events. + */ + interface Callback<T extends SequenceableLoader> { + + /** + * Called by the loader to indicate that it wishes for its {@link #continueLoading(long)} method + * to be called when it can continue to load data. Called on the playback thread. + */ + void onContinueLoadingRequested(T source); + + } + + /** + * Returns an estimate of the position up to which data is buffered. + * + * @return An estimate of the absolute position in microseconds up to which data is buffered, or + * {@link C#TIME_END_OF_SOURCE} if the data is fully buffered. + */ + long getBufferedPositionUs(); + + /** + * Returns the next load time, or {@link C#TIME_END_OF_SOURCE} if loading has finished. + */ + long getNextLoadPositionUs(); + + /** + * Attempts to continue loading. + * + * @param positionUs The current playback position in microseconds. If playback of the period to + * which this loader belongs has not yet started, the value will be the starting position + * in the period minus the duration of any media in previous periods still to be played. + * @return True if progress was made, meaning that {@link #getNextLoadPositionUs()} will return + * a different value than prior to the call. False otherwise. + */ + boolean continueLoading(long positionUs); + + /** Returns whether the loader is currently loading. */ + boolean isLoading(); + + /** + * Re-evaluates the buffer given the playback position. + * + * <p>Re-evaluation may discard buffered media so that it can be re-buffered in a different + * quality. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + */ + void reevaluateBuffer(long positionUs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ShuffleOrder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ShuffleOrder.java new file mode 100644 index 0000000000..f137054145 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ShuffleOrder.java @@ -0,0 +1,283 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.Arrays; +import java.util.Random; + +/** + * Shuffled order of indices. + * + * <p>The shuffle order must be immutable to ensure thread safety. + */ +public interface ShuffleOrder { + + /** + * The default {@link ShuffleOrder} implementation for random shuffle order. + */ + class DefaultShuffleOrder implements ShuffleOrder { + + private final Random random; + private final int[] shuffled; + private final int[] indexInShuffled; + + /** + * Creates an instance with a specified length. + * + * @param length The length of the shuffle order. + */ + public DefaultShuffleOrder(int length) { + this(length, new Random()); + } + + /** + * Creates an instance with a specified length and the specified random seed. Shuffle orders of + * the same length initialized with the same random seed are guaranteed to be equal. + * + * @param length The length of the shuffle order. + * @param randomSeed A random seed. + */ + public DefaultShuffleOrder(int length, long randomSeed) { + this(length, new Random(randomSeed)); + } + + /** + * Creates an instance with a specified shuffle order and the specified random seed. The random + * seed is used for {@link #cloneAndInsert(int, int)} invocations. + * + * @param shuffledIndices The shuffled indices to use as order. + * @param randomSeed A random seed. + */ + public DefaultShuffleOrder(int[] shuffledIndices, long randomSeed) { + this(Arrays.copyOf(shuffledIndices, shuffledIndices.length), new Random(randomSeed)); + } + + private DefaultShuffleOrder(int length, Random random) { + this(createShuffledList(length, random), random); + } + + private DefaultShuffleOrder(int[] shuffled, Random random) { + this.shuffled = shuffled; + this.random = random; + this.indexInShuffled = new int[shuffled.length]; + for (int i = 0; i < shuffled.length; i++) { + indexInShuffled[shuffled[i]] = i; + } + } + + @Override + public int getLength() { + return shuffled.length; + } + + @Override + public int getNextIndex(int index) { + int shuffledIndex = indexInShuffled[index]; + return ++shuffledIndex < shuffled.length ? shuffled[shuffledIndex] : C.INDEX_UNSET; + } + + @Override + public int getPreviousIndex(int index) { + int shuffledIndex = indexInShuffled[index]; + return --shuffledIndex >= 0 ? shuffled[shuffledIndex] : C.INDEX_UNSET; + } + + @Override + public int getLastIndex() { + return shuffled.length > 0 ? shuffled[shuffled.length - 1] : C.INDEX_UNSET; + } + + @Override + public int getFirstIndex() { + return shuffled.length > 0 ? shuffled[0] : C.INDEX_UNSET; + } + + @Override + public ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount) { + int[] insertionPoints = new int[insertionCount]; + int[] insertionValues = new int[insertionCount]; + for (int i = 0; i < insertionCount; i++) { + insertionPoints[i] = random.nextInt(shuffled.length + 1); + int swapIndex = random.nextInt(i + 1); + insertionValues[i] = insertionValues[swapIndex]; + insertionValues[swapIndex] = i + insertionIndex; + } + Arrays.sort(insertionPoints); + int[] newShuffled = new int[shuffled.length + insertionCount]; + int indexInOldShuffled = 0; + int indexInInsertionList = 0; + for (int i = 0; i < shuffled.length + insertionCount; i++) { + if (indexInInsertionList < insertionCount + && indexInOldShuffled == insertionPoints[indexInInsertionList]) { + newShuffled[i] = insertionValues[indexInInsertionList++]; + } else { + newShuffled[i] = shuffled[indexInOldShuffled++]; + if (newShuffled[i] >= insertionIndex) { + newShuffled[i] += insertionCount; + } + } + } + return new DefaultShuffleOrder(newShuffled, new Random(random.nextLong())); + } + + @Override + public ShuffleOrder cloneAndRemove(int indexFrom, int indexToExclusive) { + int numberOfElementsToRemove = indexToExclusive - indexFrom; + int[] newShuffled = new int[shuffled.length - numberOfElementsToRemove]; + int foundElementsCount = 0; + for (int i = 0; i < shuffled.length; i++) { + if (shuffled[i] >= indexFrom && shuffled[i] < indexToExclusive) { + foundElementsCount++; + } else { + newShuffled[i - foundElementsCount] = + shuffled[i] >= indexFrom ? shuffled[i] - numberOfElementsToRemove : shuffled[i]; + } + } + return new DefaultShuffleOrder(newShuffled, new Random(random.nextLong())); + } + + @Override + public ShuffleOrder cloneAndClear() { + return new DefaultShuffleOrder(/* length= */ 0, new Random(random.nextLong())); + } + + private static int[] createShuffledList(int length, Random random) { + int[] shuffled = new int[length]; + for (int i = 0; i < length; i++) { + int swapIndex = random.nextInt(i + 1); + shuffled[i] = shuffled[swapIndex]; + shuffled[swapIndex] = i; + } + return shuffled; + } + + } + + /** + * A {@link ShuffleOrder} implementation which does not shuffle. + */ + final class UnshuffledShuffleOrder implements ShuffleOrder { + + private final int length; + + /** + * Creates an instance with a specified length. + * + * @param length The length of the shuffle order. + */ + public UnshuffledShuffleOrder(int length) { + this.length = length; + } + + @Override + public int getLength() { + return length; + } + + @Override + public int getNextIndex(int index) { + return ++index < length ? index : C.INDEX_UNSET; + } + + @Override + public int getPreviousIndex(int index) { + return --index >= 0 ? index : C.INDEX_UNSET; + } + + @Override + public int getLastIndex() { + return length > 0 ? length - 1 : C.INDEX_UNSET; + } + + @Override + public int getFirstIndex() { + return length > 0 ? 0 : C.INDEX_UNSET; + } + + @Override + public ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount) { + return new UnshuffledShuffleOrder(length + insertionCount); + } + + @Override + public ShuffleOrder cloneAndRemove(int indexFrom, int indexToExclusive) { + return new UnshuffledShuffleOrder(length - indexToExclusive + indexFrom); + } + + @Override + public ShuffleOrder cloneAndClear() { + return new UnshuffledShuffleOrder(/* length= */ 0); + } + } + + /** + * Returns length of shuffle order. + */ + int getLength(); + + /** + * Returns the next index in the shuffle order. + * + * @param index An index. + * @return The index after {@code index}, or {@link C#INDEX_UNSET} if {@code index} is the last + * element. + */ + int getNextIndex(int index); + + /** + * Returns the previous index in the shuffle order. + * + * @param index An index. + * @return The index before {@code index}, or {@link C#INDEX_UNSET} if {@code index} is the first + * element. + */ + int getPreviousIndex(int index); + + /** + * Returns the last index in the shuffle order, or {@link C#INDEX_UNSET} if the shuffle order is + * empty. + */ + int getLastIndex(); + + /** + * Returns the first index in the shuffle order, or {@link C#INDEX_UNSET} if the shuffle order is + * empty. + */ + int getFirstIndex(); + + /** + * Returns a copy of the shuffle order with newly inserted elements. + * + * @param insertionIndex The index in the unshuffled order at which elements are inserted. + * @param insertionCount The number of elements inserted at {@code insertionIndex}. + * @return A copy of this {@link ShuffleOrder} with newly inserted elements. + */ + ShuffleOrder cloneAndInsert(int insertionIndex, int insertionCount); + + /** + * Returns a copy of the shuffle order with a range of elements removed. + * + * @param indexFrom The starting index in the unshuffled order of the range to remove. + * @param indexToExclusive The smallest index (must be greater or equal to {@code indexFrom}) that + * will not be removed. + * @return A copy of this {@link ShuffleOrder} without the elements in the removed range. + */ + ShuffleOrder cloneAndRemove(int indexFrom, int indexToExclusive); + + /** Returns a copy of the shuffle order with all elements removed. */ + ShuffleOrder cloneAndClear(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SilenceMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SilenceMediaSource.java new file mode 100644 index 0000000000..096cc66622 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SilenceMediaSource.java @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Media source with a single period consisting of silent raw audio of a given duration. */ +public final class SilenceMediaSource extends BaseMediaSource { + + private static final int SAMPLE_RATE_HZ = 44100; + @C.PcmEncoding private static final int ENCODING = C.ENCODING_PCM_16BIT; + private static final int CHANNEL_COUNT = 2; + private static final Format FORMAT = + Format.createAudioSampleFormat( + /* id=*/ null, + MimeTypes.AUDIO_RAW, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* maxInputSize= */ Format.NO_VALUE, + CHANNEL_COUNT, + SAMPLE_RATE_HZ, + ENCODING, + /* initializationData= */ null, + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null); + private static final byte[] SILENCE_SAMPLE = + new byte[Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT) * 1024]; + + private final long durationUs; + + /** + * Creates a new media source providing silent audio of the given duration. + * + * @param durationUs The duration of silent audio to output, in microseconds. + */ + public SilenceMediaSource(long durationUs) { + Assertions.checkArgument(durationUs >= 0); + this.durationUs = durationUs; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + refreshSourceInfo( + new SinglePeriodTimeline( + durationUs, /* isSeekable= */ true, /* isDynamic= */ false, /* isLive= */ false)); + } + + @Override + public void maybeThrowSourceInfoRefreshError() {} + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return new SilenceMediaPeriod(durationUs); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) {} + + @Override + protected void releaseSourceInternal() {} + + private static final class SilenceMediaPeriod implements MediaPeriod { + + private static final TrackGroupArray TRACKS = new TrackGroupArray(new TrackGroup(FORMAT)); + + private final long durationUs; + private final ArrayList<SampleStream> sampleStreams; + + public SilenceMediaPeriod(long durationUs) { + this.durationUs = durationUs; + sampleStreams = new ArrayList<>(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + callback.onPrepared(/* mediaPeriod= */ this); + } + + @Override + public void maybeThrowPrepareError() {} + + @Override + public TrackGroupArray getTrackGroups() { + return TRACKS; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + positionUs = constrainSeekPosition(positionUs); + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + sampleStreams.remove(streams[i]); + streams[i] = null; + } + if (streams[i] == null && selections[i] != null) { + SilenceSampleStream stream = new SilenceSampleStream(durationUs); + stream.seekTo(positionUs); + sampleStreams.add(stream); + streams[i] = stream; + streamResetFlags[i] = true; + } + } + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) {} + + @Override + public long readDiscontinuity() { + return C.TIME_UNSET; + } + + @Override + public long seekToUs(long positionUs) { + positionUs = constrainSeekPosition(positionUs); + for (int i = 0; i < sampleStreams.size(); i++) { + ((SilenceSampleStream) sampleStreams.get(i)).seekTo(positionUs); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return constrainSeekPosition(positionUs); + } + + @Override + public long getBufferedPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public long getNextLoadPositionUs() { + return C.TIME_END_OF_SOURCE; + } + + @Override + public boolean continueLoading(long positionUs) { + return false; + } + + @Override + public boolean isLoading() { + return false; + } + + @Override + public void reevaluateBuffer(long positionUs) {} + + private long constrainSeekPosition(long positionUs) { + return Util.constrainValue(positionUs, 0, durationUs); + } + } + + private static final class SilenceSampleStream implements SampleStream { + + private final long durationBytes; + + private boolean sentFormat; + private long positionBytes; + + public SilenceSampleStream(long durationUs) { + durationBytes = getAudioByteCount(durationUs); + seekTo(0); + } + + public void seekTo(long positionUs) { + positionBytes = Util.constrainValue(getAudioByteCount(positionUs), 0, durationBytes); + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void maybeThrowError() {} + + @Override + public int readData( + FormatHolder formatHolder, DecoderInputBuffer buffer, boolean formatRequired) { + if (!sentFormat || formatRequired) { + formatHolder.format = FORMAT; + sentFormat = true; + return C.RESULT_FORMAT_READ; + } + + long bytesRemaining = durationBytes - positionBytes; + if (bytesRemaining == 0) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + + int bytesToWrite = (int) Math.min(SILENCE_SAMPLE.length, bytesRemaining); + buffer.ensureSpaceForWrite(bytesToWrite); + buffer.data.put(SILENCE_SAMPLE, /* offset= */ 0, bytesToWrite); + buffer.timeUs = getAudioPositionUs(positionBytes); + buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); + positionBytes += bytesToWrite; + return C.RESULT_BUFFER_READ; + } + + @Override + public int skipData(long positionUs) { + long oldPositionBytes = positionBytes; + seekTo(positionUs); + return (int) ((positionBytes - oldPositionBytes) / SILENCE_SAMPLE.length); + } + } + + private static long getAudioByteCount(long durationUs) { + long audioSampleCount = durationUs * SAMPLE_RATE_HZ / C.MICROS_PER_SECOND; + return Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT) * audioSampleCount; + } + + private static long getAudioPositionUs(long bytes) { + long audioSampleCount = bytes / Util.getPcmFrameSize(ENCODING, CHANNEL_COUNT); + return audioSampleCount * C.MICROS_PER_SECOND / SAMPLE_RATE_HZ; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SinglePeriodTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SinglePeriodTimeline.java new file mode 100644 index 0000000000..72d805dfa3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SinglePeriodTimeline.java @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * A {@link Timeline} consisting of a single period and static window. + */ +public final class SinglePeriodTimeline extends Timeline { + + private static final Object UID = new Object(); + + private final long presentationStartTimeMs; + private final long windowStartTimeMs; + private final long periodDurationUs; + private final long windowDurationUs; + private final long windowPositionInPeriodUs; + private final long windowDefaultStartPositionUs; + private final boolean isSeekable; + private final boolean isDynamic; + private final boolean isLive; + @Nullable private final Object tag; + @Nullable private final Object manifest; + + /** + * Creates a timeline containing a single period and a window that spans it. + * + * @param durationUs The duration of the period, in microseconds. + * @param isSeekable Whether seeking is supported within the period. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + */ + public SinglePeriodTimeline( + long durationUs, boolean isSeekable, boolean isDynamic, boolean isLive) { + this(durationUs, isSeekable, isDynamic, isLive, /* manifest= */ null, /* tag= */ null); + } + + /** + * Creates a timeline containing a single period and a window that spans it. + * + * @param durationUs The duration of the period, in microseconds. + * @param isSeekable Whether seeking is supported within the period. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be {@code null}. + * @param tag A tag used for {@link Window#tag}. + */ + public SinglePeriodTimeline( + long durationUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + durationUs, + durationUs, + /* windowPositionInPeriodUs= */ 0, + /* windowDefaultStartPositionUs= */ 0, + isSeekable, + isDynamic, + isLive, + manifest, + tag); + } + + /** + * Creates a timeline with one period, and a window of known duration starting at a specified + * position in the period. + * + * @param periodDurationUs The duration of the period in microseconds. + * @param windowDurationUs The duration of the window in microseconds. + * @param windowPositionInPeriodUs The position of the start of the window in the period, in + * microseconds. + * @param windowDefaultStartPositionUs The default position relative to the start of the window at + * which to begin playback, in microseconds. + * @param isSeekable Whether seeking is supported within the window. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be (@code null}. + * @param tag A tag used for {@link Timeline.Window#tag}. + */ + public SinglePeriodTimeline( + long periodDurationUs, + long windowDurationUs, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this( + /* presentationStartTimeMs= */ C.TIME_UNSET, + /* windowStartTimeMs= */ C.TIME_UNSET, + periodDurationUs, + windowDurationUs, + windowPositionInPeriodUs, + windowDefaultStartPositionUs, + isSeekable, + isDynamic, + isLive, + manifest, + tag); + } + + /** + * Creates a timeline with one period, and a window of known duration starting at a specified + * position in the period. + * + * @param presentationStartTimeMs The start time of the presentation in milliseconds since the + * epoch. + * @param windowStartTimeMs The window's start time in milliseconds since the epoch. + * @param periodDurationUs The duration of the period in microseconds. + * @param windowDurationUs The duration of the window in microseconds. + * @param windowPositionInPeriodUs The position of the start of the window in the period, in + * microseconds. + * @param windowDefaultStartPositionUs The default position relative to the start of the window at + * which to begin playback, in microseconds. + * @param isSeekable Whether seeking is supported within the window. + * @param isDynamic Whether the window may change when the timeline is updated. + * @param isLive Whether the window is live. + * @param manifest The manifest. May be {@code null}. + * @param tag A tag used for {@link Timeline.Window#tag}. + */ + public SinglePeriodTimeline( + long presentationStartTimeMs, + long windowStartTimeMs, + long periodDurationUs, + long windowDurationUs, + long windowPositionInPeriodUs, + long windowDefaultStartPositionUs, + boolean isSeekable, + boolean isDynamic, + boolean isLive, + @Nullable Object manifest, + @Nullable Object tag) { + this.presentationStartTimeMs = presentationStartTimeMs; + this.windowStartTimeMs = windowStartTimeMs; + this.periodDurationUs = periodDurationUs; + this.windowDurationUs = windowDurationUs; + this.windowPositionInPeriodUs = windowPositionInPeriodUs; + this.windowDefaultStartPositionUs = windowDefaultStartPositionUs; + this.isSeekable = isSeekable; + this.isDynamic = isDynamic; + this.isLive = isLive; + this.manifest = manifest; + this.tag = tag; + } + + @Override + public int getWindowCount() { + return 1; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + Assertions.checkIndex(windowIndex, 0, 1); + long windowDefaultStartPositionUs = this.windowDefaultStartPositionUs; + if (isDynamic && defaultPositionProjectionUs != 0) { + if (windowDurationUs == C.TIME_UNSET) { + // Don't allow projection into a window that has an unknown duration. + windowDefaultStartPositionUs = C.TIME_UNSET; + } else { + windowDefaultStartPositionUs += defaultPositionProjectionUs; + if (windowDefaultStartPositionUs > windowDurationUs) { + // The projection takes us beyond the end of the window. + windowDefaultStartPositionUs = C.TIME_UNSET; + } + } + } + return window.set( + Window.SINGLE_WINDOW_UID, + tag, + manifest, + presentationStartTimeMs, + windowStartTimeMs, + isSeekable, + isDynamic, + isLive, + windowDefaultStartPositionUs, + windowDurationUs, + 0, + 0, + windowPositionInPeriodUs); + } + + @Override + public int getPeriodCount() { + return 1; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + Assertions.checkIndex(periodIndex, 0, 1); + Object uid = setIds ? UID : null; + return period.set(/* id= */ null, uid, 0, periodDurationUs, -windowPositionInPeriodUs); + } + + @Override + public int getIndexOfPeriod(Object uid) { + return UID.equals(uid) ? 0 : C.INDEX_UNSET; + } + + @Override + public Object getUidOfPeriod(int periodIndex) { + Assertions.checkIndex(periodIndex, 0, 1); + return UID; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java new file mode 100644 index 0000000000..6c7d92dac9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.StatsDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link MediaPeriod} with a single sample. + */ +/* package */ final class SingleSampleMediaPeriod implements MediaPeriod, + Loader.Callback<SingleSampleMediaPeriod.SourceLoadable> { + + /** + * The initial size of the allocation used to hold the sample data. + */ + private static final int INITIAL_SAMPLE_SIZE = 1024; + + private final DataSpec dataSpec; + private final DataSource.Factory dataSourceFactory; + @Nullable private final TransferListener transferListener; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final EventDispatcher eventDispatcher; + private final TrackGroupArray tracks; + private final ArrayList<SampleStreamImpl> sampleStreams; + private final long durationUs; + + // Package private to avoid thunk methods. + /* package */ final Loader loader; + /* package */ final Format format; + /* package */ final boolean treatLoadErrorsAsEndOfStream; + + /* package */ boolean notifiedReadingStarted; + /* package */ boolean loadingFinished; + /* package */ byte @MonotonicNonNull [] sampleData; + /* package */ int sampleSize; + + public SingleSampleMediaPeriod( + DataSpec dataSpec, + DataSource.Factory dataSourceFactory, + @Nullable TransferListener transferListener, + Format format, + long durationUs, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + boolean treatLoadErrorsAsEndOfStream) { + this.dataSpec = dataSpec; + this.dataSourceFactory = dataSourceFactory; + this.transferListener = transferListener; + this.format = format; + this.durationUs = durationUs; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; + tracks = new TrackGroupArray(new TrackGroup(format)); + sampleStreams = new ArrayList<>(); + loader = new Loader("Loader:SingleSampleMediaPeriod"); + eventDispatcher.mediaPeriodCreated(); + } + + public void release() { + loader.release(); + eventDispatcher.mediaPeriodReleased(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + callback.onPrepared(this); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + // Do nothing. + } + + @Override + public TrackGroupArray getTrackGroups() { + return tracks; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + for (int i = 0; i < selections.length; i++) { + if (streams[i] != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + sampleStreams.remove(streams[i]); + streams[i] = null; + } + if (streams[i] == null && selections[i] != null) { + SampleStreamImpl stream = new SampleStreamImpl(); + sampleStreams.add(stream); + streams[i] = stream; + streamResetFlags[i] = true; + } + } + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + // Do nothing. + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + @Override + public boolean continueLoading(long positionUs) { + if (loadingFinished || loader.isLoading() || loader.hasFatalError()) { + return false; + } + DataSource dataSource = dataSourceFactory.createDataSource(); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + long elapsedRealtimeMs = + loader.startLoading( + new SourceLoadable(dataSpec, dataSource), + /* callback= */ this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA)); + eventDispatcher.loadStarted( + dataSpec, + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs); + return true; + } + + @Override + public boolean isLoading() { + return loader.isLoading(); + } + + @Override + public long readDiscontinuity() { + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + return C.TIME_UNSET; + } + + @Override + public long getNextLoadPositionUs() { + return loadingFinished || loader.isLoading() ? C.TIME_END_OF_SOURCE : 0; + } + + @Override + public long getBufferedPositionUs() { + return loadingFinished ? C.TIME_END_OF_SOURCE : 0; + } + + @Override + public long seekToUs(long positionUs) { + for (int i = 0; i < sampleStreams.size(); i++) { + sampleStreams.get(i).reset(); + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return positionUs; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(SourceLoadable loadable, long elapsedRealtimeMs, + long loadDurationMs) { + sampleSize = (int) loadable.dataSource.getBytesRead(); + sampleData = Assertions.checkNotNull(loadable.sampleData); + loadingFinished = true; + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + sampleSize); + } + + @Override + public void onLoadCanceled(SourceLoadable loadable, long elapsedRealtimeMs, long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + /* trackFormat= */ null, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead()); + } + + @Override + public LoadErrorAction onLoadError( + SourceLoadable loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long retryDelay = + loadErrorHandlingPolicy.getRetryDelayMsFor( + C.DATA_TYPE_MEDIA, loadDurationMs, error, errorCount); + boolean errorCanBePropagated = + retryDelay == C.TIME_UNSET + || errorCount + >= loadErrorHandlingPolicy.getMinimumLoadableRetryCount(C.DATA_TYPE_MEDIA); + + LoadErrorAction action; + if (treatLoadErrorsAsEndOfStream && errorCanBePropagated) { + loadingFinished = true; + action = Loader.DONT_RETRY; + } else { + action = + retryDelay != C.TIME_UNSET + ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelay) + : Loader.DONT_RETRY_FATAL; + } + eventDispatcher.loadError( + loadable.dataSpec, + loadable.dataSource.getLastOpenedUri(), + loadable.dataSource.getLastResponseHeaders(), + C.DATA_TYPE_MEDIA, + C.TRACK_TYPE_UNKNOWN, + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaStartTimeUs= */ 0, + durationUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.dataSource.getBytesRead(), + error, + /* wasCanceled= */ !action.isRetry()); + return action; + } + + private final class SampleStreamImpl implements SampleStream { + + private static final int STREAM_STATE_SEND_FORMAT = 0; + private static final int STREAM_STATE_SEND_SAMPLE = 1; + private static final int STREAM_STATE_END_OF_STREAM = 2; + + private int streamState; + private boolean notifiedDownstreamFormat; + + public void reset() { + if (streamState == STREAM_STATE_END_OF_STREAM) { + streamState = STREAM_STATE_SEND_SAMPLE; + } + } + + @Override + public boolean isReady() { + return loadingFinished; + } + + @Override + public void maybeThrowError() throws IOException { + if (!treatLoadErrorsAsEndOfStream) { + loader.maybeThrowError(); + } + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean requireFormat) { + maybeNotifyDownstreamFormat(); + if (streamState == STREAM_STATE_END_OF_STREAM) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } else if (requireFormat || streamState == STREAM_STATE_SEND_FORMAT) { + formatHolder.format = format; + streamState = STREAM_STATE_SEND_SAMPLE; + return C.RESULT_FORMAT_READ; + } else if (loadingFinished) { + if (sampleData != null) { + buffer.addFlag(C.BUFFER_FLAG_KEY_FRAME); + buffer.timeUs = 0; + if (buffer.isFlagsOnly()) { + return C.RESULT_BUFFER_READ; + } + buffer.ensureSpaceForWrite(sampleSize); + buffer.data.put(sampleData, 0, sampleSize); + } else { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + } + streamState = STREAM_STATE_END_OF_STREAM; + return C.RESULT_BUFFER_READ; + } + return C.RESULT_NOTHING_READ; + } + + @Override + public int skipData(long positionUs) { + maybeNotifyDownstreamFormat(); + if (positionUs > 0 && streamState != STREAM_STATE_END_OF_STREAM) { + streamState = STREAM_STATE_END_OF_STREAM; + return 1; + } + return 0; + } + + private void maybeNotifyDownstreamFormat() { + if (!notifiedDownstreamFormat) { + eventDispatcher.downstreamFormatChanged( + MimeTypes.getTrackType(format.sampleMimeType), + format, + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + /* mediaTimeUs= */ 0); + notifiedDownstreamFormat = true; + } + } + } + + /* package */ static final class SourceLoadable implements Loadable { + + public final DataSpec dataSpec; + + private final StatsDataSource dataSource; + + @Nullable private byte[] sampleData; + + // the constructor does not initialize fields: sampleData + @SuppressWarnings("nullness:initialization.fields.uninitialized") + public SourceLoadable(DataSpec dataSpec, DataSource dataSource) { + this.dataSpec = dataSpec; + this.dataSource = new StatsDataSource(dataSource); + } + + @Override + public void cancelLoad() { + // Never happens. + } + + @Override + public void load() throws IOException, InterruptedException { + // We always load from the beginning, so reset bytesRead to 0. + dataSource.resetBytesRead(); + try { + // Create and open the input. + dataSource.open(dataSpec); + // Load the sample data. + int result = 0; + while (result != C.RESULT_END_OF_INPUT) { + int sampleSize = (int) dataSource.getBytesRead(); + if (sampleData == null) { + sampleData = new byte[INITIAL_SAMPLE_SIZE]; + } else if (sampleSize == sampleData.length) { + sampleData = Arrays.copyOf(sampleData, sampleData.length * 2); + } + result = dataSource.read(sampleData, sampleSize, sampleData.length - sampleSize); + } + } finally { + Util.closeQuietly(dataSource); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaSource.java new file mode 100644 index 0000000000..01f35ef775 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/SingleSampleMediaSource.java @@ -0,0 +1,371 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * Loads data at a given {@link Uri} as a single sample belonging to a single {@link MediaPeriod}. + */ +public final class SingleSampleMediaSource extends BaseMediaSource { + + /** + * Listener of {@link SingleSampleMediaSource} events. + * + * @deprecated Use {@link MediaSourceEventListener}. + */ + @Deprecated + public interface EventListener { + + /** + * Called when an error occurs loading media data. + * + * @param sourceId The id of the reporting {@link SingleSampleMediaSource}. + * @param e The cause of the failure. + */ + void onLoadError(int sourceId, IOException e); + + } + + /** Factory for {@link SingleSampleMediaSource}. */ + public static final class Factory { + + private final DataSource.Factory dataSourceFactory; + + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private boolean treatLoadErrorsAsEndOfStream; + private boolean isCreateCalled; + @Nullable private Object tag; + + /** + * Creates a factory for {@link SingleSampleMediaSource}s. + * + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = Assertions.checkNotNull(dataSourceFactory); + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + } + + /** + * Sets a tag for the media source which will be published in the {@link Timeline} of the source + * as {@link Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the minimum number of times to retry if a loading error occurs. See {@link + * #setLoadErrorHandlingPolicy} for the default value. + * + * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with + * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int) + * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)} + * + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. + */ + @Deprecated + public Factory setMinLoadableRetryCount(int minLoadableRetryCount) { + return setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)); + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets whether load errors will be treated as end-of-stream signal (load errors will not be + * propagated). The default value is false. + * + * @param treatLoadErrorsAsEndOfStream If true, load errors will not be propagated by sample + * streams, treating them as ended instead. If false, load errors will be propagated + * normally by {@link SampleStream#maybeThrowError()}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTreatLoadErrorsAsEndOfStream(boolean treatLoadErrorsAsEndOfStream) { + Assertions.checkState(!isCreateCalled); + this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; + return this; + } + + /** + * Returns a new {@link SingleSampleMediaSource} using the current parameters. + * + * @param uri The {@link Uri}. + * @param format The {@link Format} of the media stream. + * @param durationUs The duration of the media stream in microseconds. + * @return The new {@link SingleSampleMediaSource}. + */ + public SingleSampleMediaSource createMediaSource(Uri uri, Format format, long durationUs) { + isCreateCalled = true; + return new SingleSampleMediaSource( + uri, + dataSourceFactory, + format, + durationUs, + loadErrorHandlingPolicy, + treatLoadErrorsAsEndOfStream, + tag); + } + + /** + * @deprecated Use {@link #createMediaSource(Uri, Format, long)} and {@link + * #addEventListener(Handler, MediaSourceEventListener)} instead. + */ + @Deprecated + public SingleSampleMediaSource createMediaSource( + Uri uri, + Format format, + long durationUs, + @Nullable Handler eventHandler, + @Nullable MediaSourceEventListener eventListener) { + SingleSampleMediaSource mediaSource = createMediaSource(uri, format, durationUs); + if (eventHandler != null && eventListener != null) { + mediaSource.addEventListener(eventHandler, eventListener); + } + return mediaSource; + } + + } + + private final DataSpec dataSpec; + private final DataSource.Factory dataSourceFactory; + private final Format format; + private final long durationUs; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final boolean treatLoadErrorsAsEndOfStream; + private final Timeline timeline; + @Nullable private final Object tag; + + @Nullable private TransferListener transferListener; + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + * @param format The {@link Format} associated with the output track. + * @param durationUs The duration of the media stream in microseconds. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + @SuppressWarnings("deprecation") + public SingleSampleMediaSource( + Uri uri, DataSource.Factory dataSourceFactory, Format format, long durationUs) { + this( + uri, + dataSourceFactory, + format, + durationUs, + DefaultLoadErrorHandlingPolicy.DEFAULT_MIN_LOADABLE_RETRY_COUNT); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + * @param format The {@link Format} associated with the output track. + * @param durationUs The duration of the media stream in microseconds. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + public SingleSampleMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + Format format, + long durationUs, + int minLoadableRetryCount) { + this( + uri, + dataSourceFactory, + format, + durationUs, + new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), + /* treatLoadErrorsAsEndOfStream= */ false, + /* tag= */ null); + } + + /** + * @param uri The {@link Uri} of the media stream. + * @param dataSourceFactory The factory from which the {@link DataSource} to read the media will + * be obtained. + * @param format The {@link Format} associated with the output track. + * @param durationUs The duration of the media stream in microseconds. + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @param eventHandler A handler for events. May be null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param eventSourceId An identifier that gets passed to {@code eventListener} methods. + * @param treatLoadErrorsAsEndOfStream If true, load errors will not be propagated by sample + * streams, treating them as ended instead. If false, load errors will be propagated normally + * by {@link SampleStream#maybeThrowError()}. + * @deprecated Use {@link Factory} instead. + */ + @Deprecated + @SuppressWarnings("deprecation") + public SingleSampleMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + Format format, + long durationUs, + int minLoadableRetryCount, + Handler eventHandler, + EventListener eventListener, + int eventSourceId, + boolean treatLoadErrorsAsEndOfStream) { + this( + uri, + dataSourceFactory, + format, + durationUs, + new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount), + treatLoadErrorsAsEndOfStream, + /* tag= */ null); + if (eventHandler != null && eventListener != null) { + addEventListener(eventHandler, new EventListenerWrapper(eventListener, eventSourceId)); + } + } + + private SingleSampleMediaSource( + Uri uri, + DataSource.Factory dataSourceFactory, + Format format, + long durationUs, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + boolean treatLoadErrorsAsEndOfStream, + @Nullable Object tag) { + this.dataSourceFactory = dataSourceFactory; + this.format = format; + this.durationUs = durationUs; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.treatLoadErrorsAsEndOfStream = treatLoadErrorsAsEndOfStream; + this.tag = tag; + dataSpec = new DataSpec(uri, DataSpec.FLAG_ALLOW_GZIP); + timeline = + new SinglePeriodTimeline( + durationUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + /* manifest= */ null, + tag); + } + + // MediaSource implementation. + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + transferListener = mediaTransferListener; + refreshSourceInfo(timeline); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + // Do nothing. + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + return new SingleSampleMediaPeriod( + dataSpec, + dataSourceFactory, + transferListener, + format, + durationUs, + loadErrorHandlingPolicy, + createEventDispatcher(id), + treatLoadErrorsAsEndOfStream); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((SingleSampleMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + // Do nothing. + } + + /** + * Wraps a deprecated {@link EventListener}, invoking its callback from the equivalent callback in + * {@link MediaSourceEventListener}. + */ + @Deprecated + @SuppressWarnings("deprecation") + private static final class EventListenerWrapper implements MediaSourceEventListener { + + private final EventListener eventListener; + private final int eventSourceId; + + public EventListenerWrapper(EventListener eventListener, int eventSourceId) { + this.eventListener = Assertions.checkNotNull(eventListener); + this.eventSourceId = eventSourceId; + } + + @Override + public void onLoadError( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + eventListener.onLoadError(eventSourceId, error); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroup.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroup.java new file mode 100644 index 0000000000..566238dbdb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroup.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Arrays; + +// TODO: Add an allowMultipleStreams boolean to indicate where the one stream per group restriction +// does not apply. +/** + * Defines a group of tracks exposed by a {@link MediaPeriod}. + * + * <p>A {@link MediaPeriod} is only able to provide one {@link SampleStream} corresponding to a + * group at any given time, however this {@link SampleStream} may adapt between multiple tracks + * within the group. + */ +public final class TrackGroup implements Parcelable { + + /** + * The number of tracks in the group. + */ + public final int length; + + private final Format[] formats; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * @param formats The track formats. Must not be null, contain null elements or be of length 0. + */ + public TrackGroup(Format... formats) { + Assertions.checkState(formats.length > 0); + this.formats = formats; + this.length = formats.length; + } + + /* package */ TrackGroup(Parcel in) { + length = in.readInt(); + formats = new Format[length]; + for (int i = 0; i < length; i++) { + formats[i] = in.readParcelable(Format.class.getClassLoader()); + } + } + + /** + * Returns the format of the track at a given index. + * + * @param index The index of the track. + * @return The track's format. + */ + public Format getFormat(int index) { + return formats[index]; + } + + /** + * Returns the index of the track with the given format in the group. The format is located by + * identity so, for example, {@code group.indexOf(group.getFormat(index)) == index} even if + * multiple tracks have formats that contain the same values. + * + * @param format The format. + * @return The index of the track, or {@link C#INDEX_UNSET} if no such track exists. + */ + @SuppressWarnings("ReferenceEquality") + public int indexOf(Format format) { + for (int i = 0; i < formats.length; i++) { + if (format == formats[i]) { + return i; + } + } + return C.INDEX_UNSET; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 17; + result = 31 * result + Arrays.hashCode(formats); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackGroup other = (TrackGroup) obj; + return length == other.length && Arrays.equals(formats, other.formats); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(length); + for (int i = 0; i < length; i++) { + dest.writeParcelable(formats[i], 0); + } + } + + public static final Parcelable.Creator<TrackGroup> CREATOR = + new Parcelable.Creator<TrackGroup>() { + + @Override + public TrackGroup createFromParcel(Parcel in) { + return new TrackGroup(in); + } + + @Override + public TrackGroup[] newArray(int size) { + return new TrackGroup[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroupArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroupArray.java new file mode 100644 index 0000000000..103a45080e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/TrackGroupArray.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.Arrays; + +/** An array of {@link TrackGroup}s exposed by a {@link MediaPeriod}. */ +public final class TrackGroupArray implements Parcelable { + + /** + * The empty array. + */ + public static final TrackGroupArray EMPTY = new TrackGroupArray(); + + /** + * The number of groups in the array. Greater than or equal to zero. + */ + public final int length; + + private final TrackGroup[] trackGroups; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * @param trackGroups The groups. Must not be null or contain null elements, but may be empty. + */ + public TrackGroupArray(TrackGroup... trackGroups) { + this.trackGroups = trackGroups; + this.length = trackGroups.length; + } + + /* package */ TrackGroupArray(Parcel in) { + length = in.readInt(); + trackGroups = new TrackGroup[length]; + for (int i = 0; i < length; i++) { + trackGroups[i] = in.readParcelable(TrackGroup.class.getClassLoader()); + } + } + + /** + * Returns the group at a given index. + * + * @param index The index of the group. + * @return The group. + */ + public TrackGroup get(int index) { + return trackGroups[index]; + } + + /** + * Returns the index of a group within the array. + * + * @param group The group. + * @return The index of the group, or {@link C#INDEX_UNSET} if no such group exists. + */ + @SuppressWarnings("ReferenceEquality") + public int indexOf(TrackGroup group) { + for (int i = 0; i < length; i++) { + // Suppressed reference equality warning because this is looking for the index of a specific + // TrackGroup object, not the index of a potential equal TrackGroup. + if (trackGroups[i] == group) { + return i; + } + } + return C.INDEX_UNSET; + } + + /** + * Returns whether this track group array is empty. + */ + public boolean isEmpty() { + return length == 0; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + hashCode = Arrays.hashCode(trackGroups); + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackGroupArray other = (TrackGroupArray) obj; + return length == other.length && Arrays.equals(trackGroups, other.trackGroups); + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(length); + for (int i = 0; i < length; i++) { + dest.writeParcelable(trackGroups[i], 0); + } + } + + public static final Parcelable.Creator<TrackGroupArray> CREATOR = + new Parcelable.Creator<TrackGroupArray>() { + + @Override + public TrackGroupArray createFromParcel(Parcel in) { + return new TrackGroupArray(in); + } + + @Override + public TrackGroupArray[] newArray(int size) { + return new TrackGroupArray[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/UnrecognizedInputFormatException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/UnrecognizedInputFormatException.java new file mode 100644 index 0000000000..ccb9d350fc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/UnrecognizedInputFormatException.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source; + +import android.net.Uri; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; + +/** + * Thrown if the input format was not recognized. + */ +public class UnrecognizedInputFormatException extends ParserException { + + /** + * The {@link Uri} from which the unrecognized data was read. + */ + public final Uri uri; + + /** + * @param message The detail message for the exception. + * @param uri The {@link Uri} from which the unrecognized data was read. + */ + public UnrecognizedInputFormatException(String message, Uri uri) { + super(message); + this.uri = uri; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdPlaybackState.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdPlaybackState.java new file mode 100644 index 0000000000..83b5b1bc40 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdPlaybackState.java @@ -0,0 +1,486 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import android.net.Uri; +import androidx.annotation.CheckResult; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Represents ad group times relative to the start of the media and information on the state and + * URIs of ads within each ad group. + * + * <p>Instances are immutable. Call the {@code with*} methods to get new instances that have the + * required changes. + */ +public final class AdPlaybackState { + + /** + * Represents a group of ads, with information about their states. + * + * <p>Instances are immutable. Call the {@code with*} methods to get new instances that have the + * required changes. + */ + public static final class AdGroup { + + /** The number of ads in the ad group, or {@link C#LENGTH_UNSET} if unknown. */ + public final int count; + /** The URI of each ad in the ad group. */ + public final @NullableType Uri[] uris; + /** The state of each ad in the ad group. */ + @AdState public final int[] states; + /** The durations of each ad in the ad group, in microseconds. */ + public final long[] durationsUs; + + /** Creates a new ad group with an unspecified number of ads. */ + public AdGroup() { + this( + /* count= */ C.LENGTH_UNSET, + /* states= */ new int[0], + /* uris= */ new Uri[0], + /* durationsUs= */ new long[0]); + } + + private AdGroup( + int count, @AdState int[] states, @NullableType Uri[] uris, long[] durationsUs) { + Assertions.checkArgument(states.length == uris.length); + this.count = count; + this.states = states; + this.uris = uris; + this.durationsUs = durationsUs; + } + + /** + * Returns the index of the first ad in the ad group that should be played, or {@link #count} if + * no ads should be played. + */ + public int getFirstAdIndexToPlay() { + return getNextAdIndexToPlay(-1); + } + + /** + * Returns the index of the next ad in the ad group that should be played after playing {@code + * lastPlayedAdIndex}, or {@link #count} if no later ads should be played. + */ + public int getNextAdIndexToPlay(int lastPlayedAdIndex) { + int nextAdIndexToPlay = lastPlayedAdIndex + 1; + while (nextAdIndexToPlay < states.length) { + if (states[nextAdIndexToPlay] == AD_STATE_UNAVAILABLE + || states[nextAdIndexToPlay] == AD_STATE_AVAILABLE) { + break; + } + nextAdIndexToPlay++; + } + return nextAdIndexToPlay; + } + + /** Returns whether the ad group has at least one ad that still needs to be played. */ + public boolean hasUnplayedAds() { + return count == C.LENGTH_UNSET || getFirstAdIndexToPlay() < count; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdGroup adGroup = (AdGroup) o; + return count == adGroup.count + && Arrays.equals(uris, adGroup.uris) + && Arrays.equals(states, adGroup.states) + && Arrays.equals(durationsUs, adGroup.durationsUs); + } + + @Override + public int hashCode() { + int result = count; + result = 31 * result + Arrays.hashCode(uris); + result = 31 * result + Arrays.hashCode(states); + result = 31 * result + Arrays.hashCode(durationsUs); + return result; + } + + /** + * Returns a new instance with the ad count set to {@code count}. This method may only be called + * if this instance's ad count has not yet been specified. + */ + @CheckResult + public AdGroup withAdCount(int count) { + Assertions.checkArgument(this.count == C.LENGTH_UNSET && states.length <= count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, count); + long[] durationsUs = copyDurationsUsWithSpaceForAdCount(this.durationsUs, count); + @NullableType Uri[] uris = Arrays.copyOf(this.uris, count); + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns a new instance with the specified {@code uri} set for the specified ad, and the ad + * marked as {@link #AD_STATE_AVAILABLE}. The specified ad must currently be in {@link + * #AD_STATE_UNAVAILABLE}, which is the default state. + * + * <p>This instance's ad count may be unknown, in which case {@code index} must be less than the + * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + */ + @CheckResult + public AdGroup withAdUri(Uri uri, int index) { + Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); + Assertions.checkArgument(states[index] == AD_STATE_UNAVAILABLE); + long[] durationsUs = + this.durationsUs.length == states.length + ? this.durationsUs + : copyDurationsUsWithSpaceForAdCount(this.durationsUs, states.length); + @NullableType Uri[] uris = Arrays.copyOf(this.uris, states.length); + uris[index] = uri; + states[index] = AD_STATE_AVAILABLE; + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns a new instance with the specified ad set to the specified {@code state}. The ad + * specified must currently either be in {@link #AD_STATE_UNAVAILABLE} or {@link + * #AD_STATE_AVAILABLE}. + * + * <p>This instance's ad count may be unknown, in which case {@code index} must be less than the + * ad count specified later. Otherwise, {@code index} must be less than the current ad count. + */ + @CheckResult + public AdGroup withAdState(@AdState int state, int index) { + Assertions.checkArgument(count == C.LENGTH_UNSET || index < count); + @AdState int[] states = copyStatesWithSpaceForAdCount(this.states, index + 1); + Assertions.checkArgument( + states[index] == AD_STATE_UNAVAILABLE + || states[index] == AD_STATE_AVAILABLE + || states[index] == state); + long[] durationsUs = + this.durationsUs.length == states.length + ? this.durationsUs + : copyDurationsUsWithSpaceForAdCount(this.durationsUs, states.length); + @NullableType + Uri[] uris = + this.uris.length == states.length ? this.uris : Arrays.copyOf(this.uris, states.length); + states[index] = state; + return new AdGroup(count, states, uris, durationsUs); + } + + /** Returns a new instance with the specified ad durations, in microseconds. */ + @CheckResult + public AdGroup withAdDurationsUs(long[] durationsUs) { + Assertions.checkArgument(count == C.LENGTH_UNSET || durationsUs.length <= this.uris.length); + if (durationsUs.length < this.uris.length) { + durationsUs = copyDurationsUsWithSpaceForAdCount(durationsUs, uris.length); + } + return new AdGroup(count, states, uris, durationsUs); + } + + /** + * Returns an instance with all unavailable and available ads marked as skipped. If the ad count + * hasn't been set, it will be set to zero. + */ + @CheckResult + public AdGroup withAllAdsSkipped() { + if (count == C.LENGTH_UNSET) { + return new AdGroup( + /* count= */ 0, + /* states= */ new int[0], + /* uris= */ new Uri[0], + /* durationsUs= */ new long[0]); + } + int count = this.states.length; + @AdState int[] states = Arrays.copyOf(this.states, count); + for (int i = 0; i < count; i++) { + if (states[i] == AD_STATE_AVAILABLE || states[i] == AD_STATE_UNAVAILABLE) { + states[i] = AD_STATE_SKIPPED; + } + } + return new AdGroup(count, states, uris, durationsUs); + } + + @CheckResult + private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) { + int oldStateCount = states.length; + int newStateCount = Math.max(count, oldStateCount); + states = Arrays.copyOf(states, newStateCount); + Arrays.fill(states, oldStateCount, newStateCount, AD_STATE_UNAVAILABLE); + return states; + } + + @CheckResult + private static long[] copyDurationsUsWithSpaceForAdCount(long[] durationsUs, int count) { + int oldDurationsUsCount = durationsUs.length; + int newDurationsUsCount = Math.max(count, oldDurationsUsCount); + durationsUs = Arrays.copyOf(durationsUs, newDurationsUsCount); + Arrays.fill(durationsUs, oldDurationsUsCount, newDurationsUsCount, C.TIME_UNSET); + return durationsUs; + } + } + + /** + * Represents the state of an ad in an ad group. One of {@link #AD_STATE_UNAVAILABLE}, {@link + * #AD_STATE_AVAILABLE}, {@link #AD_STATE_SKIPPED}, {@link #AD_STATE_PLAYED} or {@link + * #AD_STATE_ERROR}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + AD_STATE_UNAVAILABLE, + AD_STATE_AVAILABLE, + AD_STATE_SKIPPED, + AD_STATE_PLAYED, + AD_STATE_ERROR, + }) + public @interface AdState {} + /** State for an ad that does not yet have a URL. */ + public static final int AD_STATE_UNAVAILABLE = 0; + /** State for an ad that has a URL but has not yet been played. */ + public static final int AD_STATE_AVAILABLE = 1; + /** State for an ad that was skipped. */ + public static final int AD_STATE_SKIPPED = 2; + /** State for an ad that was played in full. */ + public static final int AD_STATE_PLAYED = 3; + /** State for an ad that could not be loaded. */ + public static final int AD_STATE_ERROR = 4; + + /** Ad playback state with no ads. */ + public static final AdPlaybackState NONE = new AdPlaybackState(); + + /** The number of ad groups. */ + public final int adGroupCount; + /** + * The times of ad groups, in microseconds. A final element with the value {@link + * C#TIME_END_OF_SOURCE} indicates a postroll ad. + */ + public final long[] adGroupTimesUs; + /** The ad groups. */ + public final AdGroup[] adGroups; + /** The position offset in the first unplayed ad at which to begin playback, in microseconds. */ + public final long adResumePositionUs; + /** The content duration in microseconds, if known. {@link C#TIME_UNSET} otherwise. */ + public final long contentDurationUs; + + /** + * Creates a new ad playback state with the specified ad group times. + * + * @param adGroupTimesUs The times of ad groups in microseconds. A final element with the value + * {@link C#TIME_END_OF_SOURCE} indicates that there is a postroll ad. + */ + public AdPlaybackState(long... adGroupTimesUs) { + int count = adGroupTimesUs.length; + adGroupCount = count; + this.adGroupTimesUs = Arrays.copyOf(adGroupTimesUs, count); + this.adGroups = new AdGroup[count]; + for (int i = 0; i < count; i++) { + adGroups[i] = new AdGroup(); + } + adResumePositionUs = 0; + contentDurationUs = C.TIME_UNSET; + } + + private AdPlaybackState( + long[] adGroupTimesUs, AdGroup[] adGroups, long adResumePositionUs, long contentDurationUs) { + adGroupCount = adGroups.length; + this.adGroupTimesUs = adGroupTimesUs; + this.adGroups = adGroups; + this.adResumePositionUs = adResumePositionUs; + this.contentDurationUs = contentDurationUs; + } + + /** + * Returns the index of the ad group at or before {@code positionUs}, if that ad group is + * unplayed. Returns {@link C#INDEX_UNSET} if the ad group at or before {@code positionUs} has no + * ads remaining to be played, or if there is no such ad group. + * + * @param positionUs The position at or before which to find an ad group, in microseconds, or + * {@link C#TIME_END_OF_SOURCE} for the end of the stream (in which case the index of any + * unplayed postroll ad group will be returned). + * @return The index of the ad group, or {@link C#INDEX_UNSET}. + */ + public int getAdGroupIndexForPositionUs(long positionUs) { + // Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE. + // In practice we expect there to be few ad groups so the search shouldn't be expensive. + int index = adGroupTimesUs.length - 1; + while (index >= 0 && isPositionBeforeAdGroup(positionUs, index)) { + index--; + } + return index >= 0 && adGroups[index].hasUnplayedAds() ? index : C.INDEX_UNSET; + } + + /** + * Returns the index of the next ad group after {@code positionUs} that has ads remaining to be + * played. Returns {@link C#INDEX_UNSET} if there is no such ad group. + * + * @param positionUs The position after which to find an ad group, in microseconds, or {@link + * C#TIME_END_OF_SOURCE} for the end of the stream (in which case there can be no ad group + * after the position). + * @param periodDurationUs The duration of the containing period in microseconds, or {@link + * C#TIME_UNSET} if not known. + * @return The index of the ad group, or {@link C#INDEX_UNSET}. + */ + public int getAdGroupIndexAfterPositionUs(long positionUs, long periodDurationUs) { + if (positionUs == C.TIME_END_OF_SOURCE + || (periodDurationUs != C.TIME_UNSET && positionUs >= periodDurationUs)) { + return C.INDEX_UNSET; + } + // Use a linear search as the array elements may not be increasing due to TIME_END_OF_SOURCE. + // In practice we expect there to be few ad groups so the search shouldn't be expensive. + int index = 0; + while (index < adGroupTimesUs.length + && adGroupTimesUs[index] != C.TIME_END_OF_SOURCE + && (positionUs >= adGroupTimesUs[index] || !adGroups[index].hasUnplayedAds())) { + index++; + } + return index < adGroupTimesUs.length ? index : C.INDEX_UNSET; + } + + /** + * Returns an instance with the number of ads in {@code adGroupIndex} resolved to {@code adCount}. + * The ad count must be greater than zero. + */ + @CheckResult + public AdPlaybackState withAdCount(int adGroupIndex, int adCount) { + Assertions.checkArgument(adCount > 0); + if (adGroups[adGroupIndex].count == adCount) { + return this; + } + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = this.adGroups[adGroupIndex].withAdCount(adCount); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad URI. */ + @CheckResult + public AdPlaybackState withAdUri(int adGroupIndex, int adIndexInAdGroup, Uri uri) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdUri(uri, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as played. */ + @CheckResult + public AdPlaybackState withPlayedAd(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_PLAYED, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as skipped. */ + @CheckResult + public AdPlaybackState withSkippedAd(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_SKIPPED, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad marked as having a load error. */ + @CheckResult + public AdPlaybackState withAdLoadError(int adGroupIndex, int adIndexInAdGroup) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdState(AD_STATE_ERROR, adIndexInAdGroup); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** + * Returns an instance with all ads in the specified ad group skipped (except for those already + * marked as played or in the error state). + */ + @CheckResult + public AdPlaybackState withSkippedAdGroup(int adGroupIndex) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAllAdsSkipped(); + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad durations, in microseconds. */ + @CheckResult + public AdPlaybackState withAdDurationsUs(long[][] adDurationUs) { + AdGroup[] adGroups = Util.nullSafeArrayCopy(this.adGroups, this.adGroups.length); + for (int adGroupIndex = 0; adGroupIndex < adGroupCount; adGroupIndex++) { + adGroups[adGroupIndex] = adGroups[adGroupIndex].withAdDurationsUs(adDurationUs[adGroupIndex]); + } + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + + /** Returns an instance with the specified ad resume position, in microseconds. */ + @CheckResult + public AdPlaybackState withAdResumePositionUs(long adResumePositionUs) { + if (this.adResumePositionUs == adResumePositionUs) { + return this; + } else { + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + } + + /** Returns an instance with the specified content duration, in microseconds. */ + @CheckResult + public AdPlaybackState withContentDurationUs(long contentDurationUs) { + if (this.contentDurationUs == contentDurationUs) { + return this; + } else { + return new AdPlaybackState(adGroupTimesUs, adGroups, adResumePositionUs, contentDurationUs); + } + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AdPlaybackState that = (AdPlaybackState) o; + return adGroupCount == that.adGroupCount + && adResumePositionUs == that.adResumePositionUs + && contentDurationUs == that.contentDurationUs + && Arrays.equals(adGroupTimesUs, that.adGroupTimesUs) + && Arrays.equals(adGroups, that.adGroups); + } + + @Override + public int hashCode() { + int result = adGroupCount; + result = 31 * result + (int) adResumePositionUs; + result = 31 * result + (int) contentDurationUs; + result = 31 * result + Arrays.hashCode(adGroupTimesUs); + result = 31 * result + Arrays.hashCode(adGroups); + return result; + } + + private boolean isPositionBeforeAdGroup(long positionUs, int adGroupIndex) { + if (positionUs == C.TIME_END_OF_SOURCE) { + // The end of the content is at (but not before) any postroll ad, and after any other ads. + return false; + } + long adGroupPositionUs = adGroupTimesUs[adGroupIndex]; + if (adGroupPositionUs == C.TIME_END_OF_SOURCE) { + return contentDurationUs == C.TIME_UNSET || positionUs < contentDurationUs; + } else { + return positionUs < adGroupPositionUs; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsLoader.java new file mode 100644 index 0000000000..12ffb8ec0d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsLoader.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads.AdsMediaSource.AdLoadException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; + +/** + * Interface for loaders of ads, which can be used with {@link AdsMediaSource}. + * + * <p>Ads loaders notify the {@link AdsMediaSource} about events via {@link EventListener}. In + * particular, implementations must call {@link EventListener#onAdPlaybackState(AdPlaybackState)} + * with a new copy of the current {@link AdPlaybackState} whenever further information about ads + * becomes known (for example, when an ad media URI is available, or an ad has played to the end). + * + * <p>{@link #start(EventListener, AdViewProvider)} will be called when the ads media source first + * initializes, at which point the loader can request ads. If the player enters the background, + * {@link #stop()} will be called. Loaders should maintain any ad playback state in preparation for + * a later call to {@link #start(EventListener, AdViewProvider)}. If an ad is playing when the + * player is detached, update the ad playback state with the current playback position using {@link + * AdPlaybackState#withAdResumePositionUs(long)}. + * + * <p>If {@link EventListener#onAdPlaybackState(AdPlaybackState)} has been called, the + * implementation of {@link #start(EventListener, AdViewProvider)} should invoke the same listener + * to provide the existing playback state to the new player. + */ +public interface AdsLoader { + + /** Listener for ads loader events. All methods are called on the main thread. */ + interface EventListener { + + /** + * Called when the ad playback state has been updated. + * + * @param adPlaybackState The new ad playback state. + */ + default void onAdPlaybackState(AdPlaybackState adPlaybackState) {} + + /** + * Called when there was an error loading ads. + * + * @param error The error. + * @param dataSpec The data spec associated with the load error. + */ + default void onAdLoadError(AdLoadException error, DataSpec dataSpec) {} + + /** Called when the user clicks through an ad (for example, following a 'learn more' link). */ + default void onAdClicked() {} + + /** Called when the user taps a non-clickthrough part of an ad. */ + default void onAdTapped() {} + } + + /** Provides views for the ad UI. */ + interface AdViewProvider { + + /** Returns the {@link ViewGroup} on top of the player that will show any ad UI. */ + ViewGroup getAdViewGroup(); + + /** + * Returns an array of views that are shown on top of the ad view group, but that are essential + * for controlling playback and should be excluded from ad viewability measurements by the + * {@link AdsLoader} (if it supports this). + * + * <p>Each view must be either a fully transparent overlay (for capturing touch events), or a + * small piece of transient UI that is essential to the user experience of playback (such as a + * button to pause/resume playback or a transient full-screen or cast button). For more + * information see the documentation for your ads loader. + */ + View[] getAdOverlayViews(); + } + + // Methods called by the application. + + /** + * Sets the player that will play the loaded ads. + * + * <p>This method must be called before the player is prepared with media using this ads loader. + * + * <p>This method must also be called on the main thread and only players which are accessed on + * the main thread are supported ({@code player.getApplicationLooper() == + * Looper.getMainLooper()}). + * + * @param player The player instance that will play the loaded ads. May be null to delete the + * reference to a previously set player. + */ + void setPlayer(@Nullable Player player); + + /** + * Releases the loader. Must be called by the application on the main thread when the instance is + * no longer needed. + */ + void release(); + + // Methods called by AdsMediaSource. + + /** + * Sets the supported content types for ad media. Must be called before the first call to {@link + * #start(EventListener, AdViewProvider)}. Subsequent calls may be ignored. Called on the main + * thread by {@link AdsMediaSource}. + * + * @param contentTypes The supported content types for ad media. Each element must be one of + * {@link C#TYPE_DASH}, {@link C#TYPE_HLS}, {@link C#TYPE_SS} and {@link C#TYPE_OTHER}. + */ + void setSupportedContentTypes(@C.ContentType int... contentTypes); + + /** + * Starts using the ads loader for playback. Called on the main thread by {@link AdsMediaSource}. + * + * @param eventListener Listener for ads loader events. + * @param adViewProvider Provider of views for the ad UI. + */ + void start(EventListener eventListener, AdViewProvider adViewProvider); + + /** + * Stops using the ads loader for playback and deregisters the event listener. Called on the main + * thread by {@link AdsMediaSource}. + */ + void stop(); + + /** + * Notifies the ads loader that the player was not able to prepare media for a given ad. + * Implementations should update the ad playback state as the specified ad has failed to load. + * Called on the main thread by {@link AdsMediaSource}. + * + * @param adGroupIndex The index of the ad group. + * @param adIndexInAdGroup The index of the ad in the ad group. + * @param exception The preparation error. + */ + void handlePrepareError(int adGroupIndex, int adIndexInAdGroup, IOException exception); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsMediaSource.java new file mode 100644 index 0000000000..02c33a3d34 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/AdsMediaSource.java @@ -0,0 +1,439 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MaskingMediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ProgressiveMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +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.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A {@link MediaSource} that inserts ads linearly with a provided content media source. This source + * cannot be used as a child source in a composition. It must be the top-level source used to + * prepare the player. + */ +public final class AdsMediaSource extends CompositeMediaSource<MediaPeriodId> { + + /** + * Wrapper for exceptions that occur while loading ads, which are notified via {@link + * MediaSourceEventListener#onLoadError(int, MediaPeriodId, LoadEventInfo, MediaLoadData, + * IOException, boolean)}. + */ + public static final class AdLoadException extends IOException { + + /** + * Types of ad load exceptions. One of {@link #TYPE_AD}, {@link #TYPE_AD_GROUP}, {@link + * #TYPE_ALL_ADS} or {@link #TYPE_UNEXPECTED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_AD, TYPE_AD_GROUP, TYPE_ALL_ADS, TYPE_UNEXPECTED}) + public @interface Type {} + /** Type for when an ad failed to load. The ad will be skipped. */ + public static final int TYPE_AD = 0; + /** Type for when an ad group failed to load. The ad group will be skipped. */ + public static final int TYPE_AD_GROUP = 1; + /** Type for when all ad groups failed to load. All ads will be skipped. */ + public static final int TYPE_ALL_ADS = 2; + /** Type for when an unexpected error occurred while loading ads. All ads will be skipped. */ + public static final int TYPE_UNEXPECTED = 3; + + /** Returns a new ad load exception of {@link #TYPE_AD}. */ + public static AdLoadException createForAd(Exception error) { + return new AdLoadException(TYPE_AD, error); + } + + /** Returns a new ad load exception of {@link #TYPE_AD_GROUP}. */ + public static AdLoadException createForAdGroup(Exception error, int adGroupIndex) { + return new AdLoadException( + TYPE_AD_GROUP, new IOException("Failed to load ad group " + adGroupIndex, error)); + } + + /** Returns a new ad load exception of {@link #TYPE_ALL_ADS}. */ + public static AdLoadException createForAllAds(Exception error) { + return new AdLoadException(TYPE_ALL_ADS, error); + } + + /** Returns a new ad load exception of {@link #TYPE_UNEXPECTED}. */ + public static AdLoadException createForUnexpected(RuntimeException error) { + return new AdLoadException(TYPE_UNEXPECTED, error); + } + + /** The {@link Type} of the ad load exception. */ + public final @Type int type; + + private AdLoadException(@Type int type, Exception cause) { + super(cause); + this.type = type; + } + + /** + * Returns the {@link RuntimeException} that caused the exception if its type is {@link + * #TYPE_UNEXPECTED}. + */ + public RuntimeException getRuntimeExceptionForUnexpected() { + Assertions.checkState(type == TYPE_UNEXPECTED); + return (RuntimeException) Assertions.checkNotNull(getCause()); + } + } + + // Used to identify the content "child" source for CompositeMediaSource. + private static final MediaPeriodId DUMMY_CONTENT_MEDIA_PERIOD_ID = + new MediaPeriodId(/* periodUid= */ new Object()); + + private final MediaSource contentMediaSource; + private final MediaSourceFactory adMediaSourceFactory; + private final AdsLoader adsLoader; + private final AdsLoader.AdViewProvider adViewProvider; + private final Handler mainHandler; + private final Map<MediaSource, List<MaskingMediaPeriod>> maskingMediaPeriodByAdMediaSource; + private final Timeline.Period period; + + // Accessed on the player thread. + @Nullable private ComponentListener componentListener; + @Nullable private Timeline contentTimeline; + @Nullable private AdPlaybackState adPlaybackState; + private @NullableType MediaSource[][] adGroupMediaSources; + private @NullableType Timeline[][] adGroupTimelines; + + /** + * Constructs a new source that inserts ads linearly with the content specified by {@code + * contentMediaSource}. Ad media is loaded using {@link ProgressiveMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param dataSourceFactory Factory for data sources used to load ad media. + * @param adsLoader The loader for ads. + * @param adViewProvider Provider of views for the ad UI. + */ + public AdsMediaSource( + MediaSource contentMediaSource, + DataSource.Factory dataSourceFactory, + AdsLoader adsLoader, + AdsLoader.AdViewProvider adViewProvider) { + this( + contentMediaSource, + new ProgressiveMediaSource.Factory(dataSourceFactory), + adsLoader, + adViewProvider); + } + + /** + * Constructs a new source that inserts ads linearly with the content specified by {@code + * contentMediaSource}. + * + * @param contentMediaSource The {@link MediaSource} providing the content to play. + * @param adMediaSourceFactory Factory for media sources used to load ad media. + * @param adsLoader The loader for ads. + * @param adViewProvider Provider of views for the ad UI. + */ + public AdsMediaSource( + MediaSource contentMediaSource, + MediaSourceFactory adMediaSourceFactory, + AdsLoader adsLoader, + AdsLoader.AdViewProvider adViewProvider) { + this.contentMediaSource = contentMediaSource; + this.adMediaSourceFactory = adMediaSourceFactory; + this.adsLoader = adsLoader; + this.adViewProvider = adViewProvider; + mainHandler = new Handler(Looper.getMainLooper()); + maskingMediaPeriodByAdMediaSource = new HashMap<>(); + period = new Timeline.Period(); + adGroupMediaSources = new MediaSource[0][]; + adGroupTimelines = new Timeline[0][]; + adsLoader.setSupportedContentTypes(adMediaSourceFactory.getSupportedTypes()); + } + + @Override + @Nullable + public Object getTag() { + return contentMediaSource.getTag(); + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + super.prepareSourceInternal(mediaTransferListener); + ComponentListener componentListener = new ComponentListener(); + this.componentListener = componentListener; + prepareChildSource(DUMMY_CONTENT_MEDIA_PERIOD_ID, contentMediaSource); + mainHandler.post(() -> adsLoader.start(componentListener, adViewProvider)); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + AdPlaybackState adPlaybackState = Assertions.checkNotNull(this.adPlaybackState); + if (adPlaybackState.adGroupCount > 0 && id.isAd()) { + int adGroupIndex = id.adGroupIndex; + int adIndexInAdGroup = id.adIndexInAdGroup; + Uri adUri = + Assertions.checkNotNull(adPlaybackState.adGroups[adGroupIndex].uris[adIndexInAdGroup]); + if (adGroupMediaSources[adGroupIndex].length <= adIndexInAdGroup) { + int adCount = adIndexInAdGroup + 1; + adGroupMediaSources[adGroupIndex] = + Arrays.copyOf(adGroupMediaSources[adGroupIndex], adCount); + adGroupTimelines[adGroupIndex] = Arrays.copyOf(adGroupTimelines[adGroupIndex], adCount); + } + MediaSource mediaSource = adGroupMediaSources[adGroupIndex][adIndexInAdGroup]; + if (mediaSource == null) { + mediaSource = adMediaSourceFactory.createMediaSource(adUri); + adGroupMediaSources[adGroupIndex][adIndexInAdGroup] = mediaSource; + maskingMediaPeriodByAdMediaSource.put(mediaSource, new ArrayList<>()); + prepareChildSource(id, mediaSource); + } + MaskingMediaPeriod maskingMediaPeriod = + new MaskingMediaPeriod(mediaSource, id, allocator, startPositionUs); + maskingMediaPeriod.setPrepareErrorListener( + new AdPrepareErrorListener(adUri, adGroupIndex, adIndexInAdGroup)); + List<MaskingMediaPeriod> mediaPeriods = maskingMediaPeriodByAdMediaSource.get(mediaSource); + if (mediaPeriods == null) { + Object periodUid = + Assertions.checkNotNull(adGroupTimelines[adGroupIndex][adIndexInAdGroup]) + .getUidOfPeriod(/* periodIndex= */ 0); + MediaPeriodId adSourceMediaPeriodId = new MediaPeriodId(periodUid, id.windowSequenceNumber); + maskingMediaPeriod.createPeriod(adSourceMediaPeriodId); + } else { + // Keep track of the masking media period so it can be populated with the real media period + // when the source's info becomes available. + mediaPeriods.add(maskingMediaPeriod); + } + return maskingMediaPeriod; + } else { + MaskingMediaPeriod mediaPeriod = + new MaskingMediaPeriod(contentMediaSource, id, allocator, startPositionUs); + mediaPeriod.createPeriod(id); + return mediaPeriod; + } + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + MaskingMediaPeriod maskingMediaPeriod = (MaskingMediaPeriod) mediaPeriod; + List<MaskingMediaPeriod> mediaPeriods = + maskingMediaPeriodByAdMediaSource.get(maskingMediaPeriod.mediaSource); + if (mediaPeriods != null) { + mediaPeriods.remove(maskingMediaPeriod); + } + maskingMediaPeriod.releasePeriod(); + } + + @Override + protected void releaseSourceInternal() { + super.releaseSourceInternal(); + Assertions.checkNotNull(componentListener).release(); + componentListener = null; + maskingMediaPeriodByAdMediaSource.clear(); + contentTimeline = null; + adPlaybackState = null; + adGroupMediaSources = new MediaSource[0][]; + adGroupTimelines = new Timeline[0][]; + mainHandler.post(adsLoader::stop); + } + + @Override + protected void onChildSourceInfoRefreshed( + MediaPeriodId mediaPeriodId, MediaSource mediaSource, Timeline timeline) { + if (mediaPeriodId.isAd()) { + int adGroupIndex = mediaPeriodId.adGroupIndex; + int adIndexInAdGroup = mediaPeriodId.adIndexInAdGroup; + onAdSourceInfoRefreshed(mediaSource, adGroupIndex, adIndexInAdGroup, timeline); + } else { + onContentSourceInfoRefreshed(timeline); + } + } + + @Override + protected @Nullable MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( + MediaPeriodId childId, MediaPeriodId mediaPeriodId) { + // The child id for the content period is just DUMMY_CONTENT_MEDIA_PERIOD_ID. That's why we need + // to forward the reported mediaPeriodId in this case. + return childId.isAd() ? childId : mediaPeriodId; + } + + // Internal methods. + + private void onAdPlaybackState(AdPlaybackState adPlaybackState) { + if (this.adPlaybackState == null) { + adGroupMediaSources = new MediaSource[adPlaybackState.adGroupCount][]; + Arrays.fill(adGroupMediaSources, new MediaSource[0]); + adGroupTimelines = new Timeline[adPlaybackState.adGroupCount][]; + Arrays.fill(adGroupTimelines, new Timeline[0]); + } + this.adPlaybackState = adPlaybackState; + maybeUpdateSourceInfo(); + } + + private void onContentSourceInfoRefreshed(Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + contentTimeline = timeline; + maybeUpdateSourceInfo(); + } + + private void onAdSourceInfoRefreshed(MediaSource mediaSource, int adGroupIndex, + int adIndexInAdGroup, Timeline timeline) { + Assertions.checkArgument(timeline.getPeriodCount() == 1); + adGroupTimelines[adGroupIndex][adIndexInAdGroup] = timeline; + List<MaskingMediaPeriod> mediaPeriods = maskingMediaPeriodByAdMediaSource.remove(mediaSource); + if (mediaPeriods != null) { + Object periodUid = timeline.getUidOfPeriod(/* periodIndex= */ 0); + for (int i = 0; i < mediaPeriods.size(); i++) { + MaskingMediaPeriod mediaPeriod = mediaPeriods.get(i); + MediaPeriodId adSourceMediaPeriodId = + new MediaPeriodId(periodUid, mediaPeriod.id.windowSequenceNumber); + mediaPeriod.createPeriod(adSourceMediaPeriodId); + } + } + maybeUpdateSourceInfo(); + } + + private void maybeUpdateSourceInfo() { + Timeline contentTimeline = this.contentTimeline; + if (adPlaybackState != null && contentTimeline != null) { + adPlaybackState = adPlaybackState.withAdDurationsUs(getAdDurations(adGroupTimelines, period)); + Timeline timeline = + adPlaybackState.adGroupCount == 0 + ? contentTimeline + : new SinglePeriodAdTimeline(contentTimeline, adPlaybackState); + refreshSourceInfo(timeline); + } + } + + private static long[][] getAdDurations( + @NullableType Timeline[][] adTimelines, Timeline.Period period) { + long[][] adDurations = new long[adTimelines.length][]; + for (int i = 0; i < adTimelines.length; i++) { + adDurations[i] = new long[adTimelines[i].length]; + for (int j = 0; j < adTimelines[i].length; j++) { + adDurations[i][j] = + adTimelines[i][j] == null + ? C.TIME_UNSET + : adTimelines[i][j].getPeriod(/* periodIndex= */ 0, period).getDurationUs(); + } + } + return adDurations; + } + + /** Listener for component events. All methods are called on the main thread. */ + private final class ComponentListener implements AdsLoader.EventListener { + + private final Handler playerHandler; + + private volatile boolean released; + + /** + * Creates new listener which forwards ad playback states on the creating thread and all other + * events on the external event listener thread. + */ + public ComponentListener() { + playerHandler = new Handler(); + } + + /** Releases the component listener. */ + public void release() { + released = true; + playerHandler.removeCallbacksAndMessages(null); + } + + @Override + public void onAdPlaybackState(final AdPlaybackState adPlaybackState) { + if (released) { + return; + } + playerHandler.post( + () -> { + if (released) { + return; + } + AdsMediaSource.this.onAdPlaybackState(adPlaybackState); + }); + } + + @Override + public void onAdLoadError(final AdLoadException error, DataSpec dataSpec) { + if (released) { + return; + } + createEventDispatcher(/* mediaPeriodId= */ null) + .loadError( + dataSpec, + dataSpec.uri, + /* responseHeaders= */ Collections.emptyMap(), + C.DATA_TYPE_AD, + C.TRACK_TYPE_UNKNOWN, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0, + error, + /* wasCanceled= */ true); + } + } + + private final class AdPrepareErrorListener implements MaskingMediaPeriod.PrepareErrorListener { + + private final Uri adUri; + private final int adGroupIndex; + private final int adIndexInAdGroup; + + public AdPrepareErrorListener(Uri adUri, int adGroupIndex, int adIndexInAdGroup) { + this.adUri = adUri; + this.adGroupIndex = adGroupIndex; + this.adIndexInAdGroup = adIndexInAdGroup; + } + + @Override + public void onPrepareError(MediaPeriodId mediaPeriodId, final IOException exception) { + createEventDispatcher(mediaPeriodId) + .loadError( + new DataSpec(adUri), + adUri, + /* responseHeaders= */ Collections.emptyMap(), + C.DATA_TYPE_AD, + C.TRACK_TYPE_UNKNOWN, + /* loadDurationMs= */ 0, + /* bytesLoaded= */ 0, + AdLoadException.createForAd(exception), + /* wasCanceled= */ true); + mainHandler.post( + () -> adsLoader.handlePrepareError(adGroupIndex, adIndexInAdGroup, exception)); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java new file mode 100644 index 0000000000..44f6d0bc66 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/ads/SinglePeriodAdTimeline.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.ads; + +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.ForwardingTimeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** A {@link Timeline} for sources that have ads. */ +@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) +public final class SinglePeriodAdTimeline extends ForwardingTimeline { + + private final AdPlaybackState adPlaybackState; + + /** + * Creates a new timeline with a single period containing ads. + * + * @param contentTimeline The timeline of the content alongside which ads will be played. It must + * have one window and one period. + * @param adPlaybackState The state of the period's ads. + */ + public SinglePeriodAdTimeline(Timeline contentTimeline, AdPlaybackState adPlaybackState) { + super(contentTimeline); + Assertions.checkState(contentTimeline.getPeriodCount() == 1); + Assertions.checkState(contentTimeline.getWindowCount() == 1); + this.adPlaybackState = adPlaybackState; + } + + @Override + public Period getPeriod(int periodIndex, Period period, boolean setIds) { + timeline.getPeriod(periodIndex, period, setIds); + period.set( + period.id, + period.uid, + period.windowIndex, + period.durationUs, + period.getPositionInWindowUs(), + adPlaybackState); + return period; + } + + @Override + public Window getWindow(int windowIndex, Window window, long defaultPositionProjectionUs) { + window = super.getWindow(windowIndex, window, defaultPositionProjectionUs); + if (window.durationUs == C.TIME_UNSET) { + window.durationUs = adPlaybackState.contentDurationUs; + } + return window; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunk.java new file mode 100644 index 0000000000..406cd1617a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunk.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; + +/** + * A base implementation of {@link MediaChunk} that outputs to a {@link BaseMediaChunkOutput}. + */ +public abstract class BaseMediaChunk extends MediaChunk { + + /** + * The time from which output will begin, or {@link C#TIME_UNSET} if output will begin from the + * start of the chunk. + */ + public final long clippedStartTimeUs; + /** + * The time from which output will end, or {@link C#TIME_UNSET} if output will end at the end of + * the chunk. + */ + public final long clippedEndTimeUs; + + private BaseMediaChunkOutput output; + private int[] firstSampleIndices; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param clippedStartTimeUs The time in the chunk from which output will begin, or {@link + * C#TIME_UNSET} to output from the start of the chunk. + * @param clippedEndTimeUs The time in the chunk from which output will end, or {@link + * C#TIME_UNSET} to output to the end of the chunk. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + */ + public BaseMediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long clippedStartTimeUs, + long clippedEndTimeUs, + long chunkIndex) { + super(dataSource, dataSpec, trackFormat, trackSelectionReason, trackSelectionData, startTimeUs, + endTimeUs, chunkIndex); + this.clippedStartTimeUs = clippedStartTimeUs; + this.clippedEndTimeUs = clippedEndTimeUs; + } + + /** + * Initializes the chunk for loading, setting the {@link BaseMediaChunkOutput} that will receive + * samples as they are loaded. + * + * @param output The output that will receive the loaded media samples. + */ + public void init(BaseMediaChunkOutput output) { + this.output = output; + firstSampleIndices = output.getWriteIndices(); + } + + /** + * Returns the index of the first sample in the specified track of the output that will originate + * from this chunk. + */ + public final int getFirstSampleIndex(int trackIndex) { + return firstSampleIndices[trackIndex]; + } + + /** + * Returns the output most recently passed to {@link #init(BaseMediaChunkOutput)}. + */ + protected final BaseMediaChunkOutput getOutput() { + return output; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkIterator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkIterator.java new file mode 100644 index 0000000000..3987260578 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkIterator.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import java.util.NoSuchElementException; + +/** + * Base class for {@link MediaChunkIterator}s. Handles {@link #next()} and {@link #isEnded()}, and + * provides a bounds check for child classes. + */ +public abstract class BaseMediaChunkIterator implements MediaChunkIterator { + + private final long fromIndex; + private final long toIndex; + + private long currentIndex; + + /** + * Creates base iterator. + * + * @param fromIndex The first available index. + * @param toIndex The last available index. + */ + @SuppressWarnings("method.invocation.invalid") + public BaseMediaChunkIterator(long fromIndex, long toIndex) { + this.fromIndex = fromIndex; + this.toIndex = toIndex; + reset(); + } + + @Override + public boolean isEnded() { + return currentIndex > toIndex; + } + + @Override + public boolean next() { + currentIndex++; + return !isEnded(); + } + + @Override + public void reset() { + currentIndex = fromIndex - 1; + } + + /** + * Verifies that the iterator points to a valid element. + * + * @throws NoSuchElementException If the iterator does not point to a valid element. + */ + protected final void checkInBounds() { + if (currentIndex < fromIndex || currentIndex > toIndex) { + throw new NoSuchElementException(); + } + } + + /** Returns the current index this iterator is pointing to. */ + protected final long getCurrentIndex() { + return currentIndex; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java new file mode 100644 index 0000000000..5d1f93bf01 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/BaseMediaChunkOutput.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; + +/** + * A {@link TrackOutputProvider} that provides {@link TrackOutput TrackOutputs} based on a + * predefined mapping from track type to output. + */ +public final class BaseMediaChunkOutput implements TrackOutputProvider { + + private static final String TAG = "BaseMediaChunkOutput"; + + private final int[] trackTypes; + private final SampleQueue[] sampleQueues; + + /** + * @param trackTypes The track types of the individual track outputs. + * @param sampleQueues The individual sample queues. + */ + public BaseMediaChunkOutput(int[] trackTypes, SampleQueue[] sampleQueues) { + this.trackTypes = trackTypes; + this.sampleQueues = sampleQueues; + } + + @Override + public TrackOutput track(int id, int type) { + for (int i = 0; i < trackTypes.length; i++) { + if (type == trackTypes[i]) { + return sampleQueues[i]; + } + } + Log.e(TAG, "Unmatched track of type: " + type); + return new DummyTrackOutput(); + } + + /** + * Returns the current absolute write indices of the individual sample queues. + */ + public int[] getWriteIndices() { + int[] writeIndices = new int[sampleQueues.length]; + for (int i = 0; i < sampleQueues.length; i++) { + if (sampleQueues[i] != null) { + writeIndices[i] = sampleQueues[i].getWriteIndex(); + } + } + return writeIndices; + } + + /** + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples + * subsequently written to the sample queues. + */ + public void setSampleOffsetUs(long sampleOffsetUs) { + for (SampleQueue sampleQueue : sampleQueues) { + if (sampleQueue != null) { + sampleQueue.setSampleOffsetUs(sampleOffsetUs); + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/Chunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/Chunk.java new file mode 100644 index 0000000000..3f4450eddd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/Chunk.java @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.StatsDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.List; +import java.util.Map; + +/** + * An abstract base class for {@link Loadable} implementations that load chunks of data required + * for the playback of streams. + */ +public abstract class Chunk implements Loadable { + + /** + * The {@link DataSpec} that defines the data to be loaded. + */ + public final DataSpec dataSpec; + /** + * The type of the chunk. One of the {@code DATA_TYPE_*} constants defined in {@link C}. For + * reporting only. + */ + public final int type; + /** + * The format of the track to which this chunk belongs, or null if the chunk does not belong to + * a track. + */ + public final Format trackFormat; + /** + * One of the {@link C} {@code SELECTION_REASON_*} constants if the chunk belongs to a track. + * {@link C#SELECTION_REASON_UNKNOWN} if the chunk does not belong to a track. + */ + public final int trackSelectionReason; + /** + * Optional data associated with the selection of the track to which this chunk belongs. Null if + * the chunk does not belong to a track. + */ + @Nullable public final Object trackSelectionData; + /** + * The start time of the media contained by the chunk, or {@link C#TIME_UNSET} if the data + * being loaded does not contain media samples. + */ + public final long startTimeUs; + /** + * The end time of the media contained by the chunk, or {@link C#TIME_UNSET} if the data being + * loaded does not contain media samples. + */ + public final long endTimeUs; + + protected final StatsDataSource dataSource; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param type See {@link #type}. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs See {@link #startTimeUs}. + * @param endTimeUs See {@link #endTimeUs}. + */ + public Chunk( + DataSource dataSource, + DataSpec dataSpec, + int type, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs) { + this.dataSource = new StatsDataSource(dataSource); + this.dataSpec = Assertions.checkNotNull(dataSpec); + this.type = type; + this.trackFormat = trackFormat; + this.trackSelectionReason = trackSelectionReason; + this.trackSelectionData = trackSelectionData; + this.startTimeUs = startTimeUs; + this.endTimeUs = endTimeUs; + } + + /** + * Returns the duration of the chunk in microseconds. + */ + public final long getDurationUs() { + return endTimeUs - startTimeUs; + } + + /** + * Returns the number of bytes that have been loaded. Must only be called after the load + * completed, failed, or was canceled. + */ + public final long bytesLoaded() { + return dataSource.getBytesRead(); + } + + /** + * Returns the {@link Uri} associated with the last {@link DataSource#open} call. If redirection + * occurred, this is the redirected uri. Must only be called after the load completed, failed, or + * was canceled. + * + * @see DataSource#getUri() + */ + public final Uri getUri() { + return dataSource.getLastOpenedUri(); + } + + /** + * Returns the response headers associated with the last {@link DataSource#open} call. Must only + * be called after the load completed, failed, or was canceled. + * + * @see DataSource#getResponseHeaders() + */ + public final Map<String, List<String>> getResponseHeaders() { + return dataSource.getLastResponseHeaders(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java new file mode 100644 index 0000000000..04cef9198c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkExtractorWrapper.java @@ -0,0 +1,220 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import android.util.SparseArray; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.io.IOException; + +/** + * An {@link Extractor} wrapper for loading chunks that contain a single primary track, and possibly + * additional embedded tracks. + * <p> + * The wrapper allows switching of the {@link TrackOutput}s that receive parsed data. + */ +public final class ChunkExtractorWrapper implements ExtractorOutput { + + /** + * Provides {@link TrackOutput} instances to be written to by the wrapper. + */ + public interface TrackOutputProvider { + + /** + * Called to get the {@link TrackOutput} for a specific track. + * <p> + * The same {@link TrackOutput} is returned if multiple calls are made with the same {@code id}. + * + * @param id A track identifier. + * @param type The type of the track. Typically one of the + * {@link org.mozilla.thirdparty.com.google.android.exoplayer2C} {@code TRACK_TYPE_*} constants. + * @return The {@link TrackOutput} for the given track identifier. + */ + TrackOutput track(int id, int type); + + } + + public final Extractor extractor; + + private final int primaryTrackType; + private final Format primaryTrackManifestFormat; + private final SparseArray<BindingTrackOutput> bindingTrackOutputs; + + private boolean extractorInitialized; + private TrackOutputProvider trackOutputProvider; + private long endTimeUs; + private SeekMap seekMap; + private Format[] sampleFormats; + + /** + * @param extractor The extractor to wrap. + * @param primaryTrackType The type of the primary track. Typically one of the + * {@link org.mozilla.thirdparty.com.google.android.exoplayer2C} {@code TRACK_TYPE_*} constants. + * @param primaryTrackManifestFormat A manifest defined {@link Format} whose data should be merged + * into any sample {@link Format} output from the {@link Extractor} for the primary track. + */ + public ChunkExtractorWrapper(Extractor extractor, int primaryTrackType, + Format primaryTrackManifestFormat) { + this.extractor = extractor; + this.primaryTrackType = primaryTrackType; + this.primaryTrackManifestFormat = primaryTrackManifestFormat; + bindingTrackOutputs = new SparseArray<>(); + } + + /** + * Returns the {@link SeekMap} most recently output by the extractor, or null. + */ + public SeekMap getSeekMap() { + return seekMap; + } + + /** + * Returns the sample {@link Format}s most recently output by the extractor, or null. + */ + public Format[] getSampleFormats() { + return sampleFormats; + } + + /** + * Initializes the wrapper to output to {@link TrackOutput}s provided by the specified {@link + * TrackOutputProvider}, and configures the extractor to receive data from a new chunk. + * + * @param trackOutputProvider The provider of {@link TrackOutput}s that will receive sample data. + * @param startTimeUs The start position in the new chunk, or {@link C#TIME_UNSET} to output + * samples from the start of the chunk. + * @param endTimeUs The end position in the new chunk, or {@link C#TIME_UNSET} to output samples + * to the end of the chunk. + */ + public void init( + @Nullable TrackOutputProvider trackOutputProvider, long startTimeUs, long endTimeUs) { + this.trackOutputProvider = trackOutputProvider; + this.endTimeUs = endTimeUs; + if (!extractorInitialized) { + extractor.init(this); + if (startTimeUs != C.TIME_UNSET) { + extractor.seek(/* position= */ 0, startTimeUs); + } + extractorInitialized = true; + } else { + extractor.seek(/* position= */ 0, startTimeUs == C.TIME_UNSET ? 0 : startTimeUs); + for (int i = 0; i < bindingTrackOutputs.size(); i++) { + bindingTrackOutputs.valueAt(i).bind(trackOutputProvider, endTimeUs); + } + } + } + + // ExtractorOutput implementation. + + @Override + public TrackOutput track(int id, int type) { + BindingTrackOutput bindingTrackOutput = bindingTrackOutputs.get(id); + if (bindingTrackOutput == null) { + // Assert that if we're seeing a new track we have not seen endTracks. + Assertions.checkState(sampleFormats == null); + // TODO: Manifest formats for embedded tracks should also be passed here. + bindingTrackOutput = new BindingTrackOutput(id, type, + type == primaryTrackType ? primaryTrackManifestFormat : null); + bindingTrackOutput.bind(trackOutputProvider, endTimeUs); + bindingTrackOutputs.put(id, bindingTrackOutput); + } + return bindingTrackOutput; + } + + @Override + public void endTracks() { + Format[] sampleFormats = new Format[bindingTrackOutputs.size()]; + for (int i = 0; i < bindingTrackOutputs.size(); i++) { + sampleFormats[i] = bindingTrackOutputs.valueAt(i).sampleFormat; + } + this.sampleFormats = sampleFormats; + } + + @Override + public void seekMap(SeekMap seekMap) { + this.seekMap = seekMap; + } + + // Internal logic. + + private static final class BindingTrackOutput implements TrackOutput { + + private final int id; + private final int type; + private final Format manifestFormat; + private final DummyTrackOutput dummyTrackOutput; + + public Format sampleFormat; + private TrackOutput trackOutput; + private long endTimeUs; + + public BindingTrackOutput(int id, int type, Format manifestFormat) { + this.id = id; + this.type = type; + this.manifestFormat = manifestFormat; + dummyTrackOutput = new DummyTrackOutput(); + } + + public void bind(TrackOutputProvider trackOutputProvider, long endTimeUs) { + if (trackOutputProvider == null) { + trackOutput = dummyTrackOutput; + return; + } + this.endTimeUs = endTimeUs; + trackOutput = trackOutputProvider.track(id, type); + if (sampleFormat != null) { + trackOutput.format(sampleFormat); + } + } + + @Override + public void format(Format format) { + sampleFormat = manifestFormat != null ? format.copyWithManifestFormatInfo(manifestFormat) + : format; + trackOutput.format(sampleFormat); + } + + @Override + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + return trackOutput.sampleData(input, length, allowEndOfInput); + } + + @Override + public void sampleData(ParsableByteArray data, int length) { + trackOutput.sampleData(data, length); + } + + @Override + public void sampleMetadata(long timeUs, @C.BufferFlags int flags, int size, int offset, + CryptoData cryptoData) { + if (endTimeUs != C.TIME_UNSET && timeUs >= endTimeUs) { + trackOutput = dummyTrackOutput; + } + trackOutput.sampleMetadata(timeUs, flags, size, offset, cryptoData); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkHolder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkHolder.java new file mode 100644 index 0000000000..ef9daddd2c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkHolder.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; + +/** + * Holds a chunk or an indication that the end of the stream has been reached. + */ +public final class ChunkHolder { + + /** The chunk. */ + @Nullable public Chunk chunk; + + /** + * Indicates that the end of the stream has been reached. + */ + public boolean endOfStream; + + /** + * Clears the holder. + */ + public void clear() { + chunk = null; + endOfStream = false; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java new file mode 100644 index 0000000000..a789805cd7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -0,0 +1,791 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A {@link SampleStream} that loads media in {@link Chunk}s, obtained from a {@link ChunkSource}. + * May also be configured to expose additional embedded {@link SampleStream}s. + */ +public class ChunkSampleStream<T extends ChunkSource> implements SampleStream, SequenceableLoader, + Loader.Callback<Chunk>, Loader.ReleaseCallback { + + /** A callback to be notified when a sample stream has finished being released. */ + public interface ReleaseCallback<T extends ChunkSource> { + + /** + * Called when the {@link ChunkSampleStream} has finished being released. + * + * @param chunkSampleStream The released sample stream. + */ + void onSampleStreamReleased(ChunkSampleStream<T> chunkSampleStream); + } + + private static final String TAG = "ChunkSampleStream"; + + public final int primaryTrackType; + + @Nullable private final int[] embeddedTrackTypes; + @Nullable private final Format[] embeddedTrackFormats; + private final boolean[] embeddedTracksSelected; + private final T chunkSource; + private final SequenceableLoader.Callback<ChunkSampleStream<T>> callback; + private final EventDispatcher eventDispatcher; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final Loader loader; + private final ChunkHolder nextChunkHolder; + private final ArrayList<BaseMediaChunk> mediaChunks; + private final List<BaseMediaChunk> readOnlyMediaChunks; + private final SampleQueue primarySampleQueue; + private final SampleQueue[] embeddedSampleQueues; + private final BaseMediaChunkOutput chunkOutput; + + private Format primaryDownstreamTrackFormat; + @Nullable private ReleaseCallback<T> releaseCallback; + private long pendingResetPositionUs; + private long lastSeekPositionUs; + private int nextNotifyPrimaryFormatMediaChunkIndex; + + /* package */ long decodeOnlyUntilPositionUs; + /* package */ boolean loadingFinished; + + /** + * Constructs an instance. + * + * @param primaryTrackType The type of the primary track. One of the {@link C} {@code + * TRACK_TYPE_*} constants. + * @param embeddedTrackTypes The types of any embedded tracks, or null. + * @param embeddedTrackFormats The formats of the embedded tracks, or null. + * @param chunkSource A {@link ChunkSource} from which chunks to load are obtained. + * @param callback An {@link Callback} for the stream. + * @param allocator An {@link Allocator} from which allocations can be obtained. + * @param positionUs The position from which to start loading media. + * @param drmSessionManager The {@link DrmSessionManager} to obtain {@link DrmSession DrmSessions} + * from. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + */ + public ChunkSampleStream( + int primaryTrackType, + @Nullable int[] embeddedTrackTypes, + @Nullable Format[] embeddedTrackFormats, + T chunkSource, + Callback<ChunkSampleStream<T>> callback, + Allocator allocator, + long positionUs, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher) { + this.primaryTrackType = primaryTrackType; + this.embeddedTrackTypes = embeddedTrackTypes; + this.embeddedTrackFormats = embeddedTrackFormats; + this.chunkSource = chunkSource; + this.callback = callback; + this.eventDispatcher = eventDispatcher; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + loader = new Loader("Loader:ChunkSampleStream"); + nextChunkHolder = new ChunkHolder(); + mediaChunks = new ArrayList<>(); + readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks); + + int embeddedTrackCount = embeddedTrackTypes == null ? 0 : embeddedTrackTypes.length; + embeddedSampleQueues = new SampleQueue[embeddedTrackCount]; + embeddedTracksSelected = new boolean[embeddedTrackCount]; + int[] trackTypes = new int[1 + embeddedTrackCount]; + SampleQueue[] sampleQueues = new SampleQueue[1 + embeddedTrackCount]; + + primarySampleQueue = new SampleQueue(allocator, drmSessionManager); + trackTypes[0] = primaryTrackType; + sampleQueues[0] = primarySampleQueue; + + for (int i = 0; i < embeddedTrackCount; i++) { + SampleQueue sampleQueue = + new SampleQueue(allocator, DrmSessionManager.getDummyDrmSessionManager()); + embeddedSampleQueues[i] = sampleQueue; + sampleQueues[i + 1] = sampleQueue; + trackTypes[i + 1] = embeddedTrackTypes[i]; + } + + chunkOutput = new BaseMediaChunkOutput(trackTypes, sampleQueues); + pendingResetPositionUs = positionUs; + lastSeekPositionUs = positionUs; + } + + /** + * Discards buffered media up to the specified position. + * + * @param positionUs The position to discard up to, in microseconds. + * @param toKeyframe If true then for each track discards samples up to the keyframe before or at + * the specified position, rather than any sample before or at that position. + */ + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (isPendingReset()) { + return; + } + int oldFirstSampleIndex = primarySampleQueue.getFirstIndex(); + primarySampleQueue.discardTo(positionUs, toKeyframe, true); + int newFirstSampleIndex = primarySampleQueue.getFirstIndex(); + if (newFirstSampleIndex > oldFirstSampleIndex) { + long discardToUs = primarySampleQueue.getFirstTimestampUs(); + for (int i = 0; i < embeddedSampleQueues.length; i++) { + embeddedSampleQueues[i].discardTo(discardToUs, toKeyframe, embeddedTracksSelected[i]); + } + } + discardDownstreamMediaChunks(newFirstSampleIndex); + } + + /** + * Selects the embedded track, returning a new {@link EmbeddedSampleStream} from which the track's + * samples can be consumed. {@link EmbeddedSampleStream#release()} must be called on the returned + * stream when the track is no longer required, and before calling this method again to obtain + * another stream for the same track. + * + * @param positionUs The current playback position in microseconds. + * @param trackType The type of the embedded track to enable. + * @return The {@link EmbeddedSampleStream} for the embedded track. + */ + public EmbeddedSampleStream selectEmbeddedTrack(long positionUs, int trackType) { + for (int i = 0; i < embeddedSampleQueues.length; i++) { + if (embeddedTrackTypes[i] == trackType) { + Assertions.checkState(!embeddedTracksSelected[i]); + embeddedTracksSelected[i] = true; + embeddedSampleQueues[i].seekTo(positionUs, /* allowTimeBeyondBuffer= */ true); + return new EmbeddedSampleStream(this, embeddedSampleQueues[i], i); + } + } + // Should never happen. + throw new IllegalStateException(); + } + + /** + * Returns the {@link ChunkSource} used by this stream. + */ + public T getChunkSource() { + return chunkSource; + } + + /** + * Returns an estimate of the position up to which data is buffered. + * + * @return An estimate of the absolute position in microseconds up to which data is buffered, or + * {@link C#TIME_END_OF_SOURCE} if the track is fully buffered. + */ + @Override + public long getBufferedPositionUs() { + if (loadingFinished) { + return C.TIME_END_OF_SOURCE; + } else if (isPendingReset()) { + return pendingResetPositionUs; + } else { + long bufferedPositionUs = lastSeekPositionUs; + BaseMediaChunk lastMediaChunk = getLastMediaChunk(); + BaseMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk + : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null; + if (lastCompletedMediaChunk != null) { + bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); + } + return Math.max(bufferedPositionUs, primarySampleQueue.getLargestQueuedTimestampUs()); + } + } + + /** + * Adjusts a seek position given the specified {@link SeekParameters}. Chunk boundaries are used + * as sync points. + * + * @param positionUs The seek position in microseconds. + * @param seekParameters Parameters that control how the seek is performed. + * @return The adjusted seek position, in microseconds. + */ + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return chunkSource.getAdjustedSeekPositionUs(positionUs, seekParameters); + } + + /** + * Seeks to the specified position in microseconds. + * + * @param positionUs The seek position in microseconds. + */ + public void seekToUs(long positionUs) { + lastSeekPositionUs = positionUs; + if (isPendingReset()) { + // A reset is already pending. We only need to update its position. + pendingResetPositionUs = positionUs; + return; + } + + // Detect whether the seek is to the start of a chunk that's at least partially buffered. + BaseMediaChunk seekToMediaChunk = null; + for (int i = 0; i < mediaChunks.size(); i++) { + BaseMediaChunk mediaChunk = mediaChunks.get(i); + long mediaChunkStartTimeUs = mediaChunk.startTimeUs; + if (mediaChunkStartTimeUs == positionUs && mediaChunk.clippedStartTimeUs == C.TIME_UNSET) { + seekToMediaChunk = mediaChunk; + break; + } else if (mediaChunkStartTimeUs > positionUs) { + // We're not going to find a chunk with a matching start time. + break; + } + } + + // See if we can seek inside the primary sample queue. + boolean seekInsideBuffer; + if (seekToMediaChunk != null) { + // When seeking to the start of a chunk we use the index of the first sample in the chunk + // rather than the seek position. This ensures we seek to the keyframe at the start of the + // chunk even if the sample timestamps are slightly offset from the chunk start times. + seekInsideBuffer = primarySampleQueue.seekTo(seekToMediaChunk.getFirstSampleIndex(0)); + decodeOnlyUntilPositionUs = 0; + } else { + seekInsideBuffer = + primarySampleQueue.seekTo( + positionUs, /* allowTimeBeyondBuffer= */ positionUs < getNextLoadPositionUs()); + decodeOnlyUntilPositionUs = lastSeekPositionUs; + } + + if (seekInsideBuffer) { + // We can seek inside the buffer. + nextNotifyPrimaryFormatMediaChunkIndex = + primarySampleIndexToMediaChunkIndex( + primarySampleQueue.getReadIndex(), /* minChunkIndex= */ 0); + // Seek the embedded sample queues. + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true); + } + } else { + // We can't seek inside the buffer, and so need to reset. + pendingResetPositionUs = positionUs; + loadingFinished = false; + mediaChunks.clear(); + nextNotifyPrimaryFormatMediaChunkIndex = 0; + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + loader.clearFatalError(); + primarySampleQueue.reset(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.reset(); + } + } + } + } + + /** + * Releases the stream. + * + * <p>This method should be called when the stream is no longer required. Either this method or + * {@link #release(ReleaseCallback)} can be used to release this stream. + */ + public void release() { + release(null); + } + + /** + * Releases the stream. + * + * <p>This method should be called when the stream is no longer required. Either this method or + * {@link #release()} can be used to release this stream. + * + * @param callback An optional callback to be called on the loading thread once the loader has + * been released. + */ + public void release(@Nullable ReleaseCallback<T> callback) { + this.releaseCallback = callback; + // Discard as much as we can synchronously. + primarySampleQueue.preRelease(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.preRelease(); + } + loader.release(this); + } + + @Override + public void onLoaderReleased() { + primarySampleQueue.release(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.release(); + } + if (releaseCallback != null) { + releaseCallback.onSampleStreamReleased(this); + } + } + + // SampleStream implementation. + + @Override + public boolean isReady() { + return !isPendingReset() && primarySampleQueue.isReady(loadingFinished); + } + + @Override + public void maybeThrowError() throws IOException { + loader.maybeThrowError(); + primarySampleQueue.maybeThrowError(); + if (!loader.isLoading()) { + chunkSource.maybeThrowError(); + } + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + if (isPendingReset()) { + return C.RESULT_NOTHING_READ; + } + maybeNotifyPrimaryTrackFormatChanged(); + + return primarySampleQueue.read( + formatHolder, buffer, formatRequired, loadingFinished, decodeOnlyUntilPositionUs); + } + + @Override + public int skipData(long positionUs) { + if (isPendingReset()) { + return 0; + } + int skipCount; + if (loadingFinished && positionUs > primarySampleQueue.getLargestQueuedTimestampUs()) { + skipCount = primarySampleQueue.advanceToEnd(); + } else { + skipCount = primarySampleQueue.advanceTo(positionUs); + } + maybeNotifyPrimaryTrackFormatChanged(); + return skipCount; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + chunkSource.onChunkLoadCompleted(loadable); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + callback.onContinueLoadingRequested(this); + } + + @Override + public void onLoadCanceled(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + if (!released) { + primarySampleQueue.reset(); + for (SampleQueue embeddedSampleQueue : embeddedSampleQueues) { + embeddedSampleQueue.reset(); + } + callback.onContinueLoadingRequested(this); + } + } + + @Override + public LoadErrorAction onLoadError( + Chunk loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long bytesLoaded = loadable.bytesLoaded(); + boolean isMediaChunk = isMediaChunk(loadable); + int lastChunkIndex = mediaChunks.size() - 1; + boolean cancelable = + bytesLoaded == 0 || !isMediaChunk || !haveReadFromMediaChunk(lastChunkIndex); + long blacklistDurationMs = + cancelable + ? loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount) + : C.TIME_UNSET; + LoadErrorAction loadErrorAction = null; + if (chunkSource.onChunkLoadError(loadable, cancelable, error, blacklistDurationMs)) { + if (cancelable) { + loadErrorAction = Loader.DONT_RETRY; + if (isMediaChunk) { + BaseMediaChunk removed = discardUpstreamMediaChunksFromIndex(lastChunkIndex); + Assertions.checkState(removed == loadable); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + } + } else { + Log.w(TAG, "Ignoring attempt to cancel non-cancelable load."); + } + } + + if (loadErrorAction == null) { + // The load was not cancelled. Either the load must be retried or the error propagated. + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelayMs != C.TIME_UNSET + ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs) + : Loader.DONT_RETRY_FATAL; + } + + boolean canceled = !loadErrorAction.isRetry(); + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded, + error, + canceled); + if (canceled) { + callback.onContinueLoadingRequested(this); + } + return loadErrorAction; + } + + // SequenceableLoader implementation + + @Override + public boolean continueLoading(long positionUs) { + if (loadingFinished || loader.isLoading() || loader.hasFatalError()) { + return false; + } + + boolean pendingReset = isPendingReset(); + List<BaseMediaChunk> chunkQueue; + long loadPositionUs; + if (pendingReset) { + chunkQueue = Collections.emptyList(); + loadPositionUs = pendingResetPositionUs; + } else { + chunkQueue = readOnlyMediaChunks; + loadPositionUs = getLastMediaChunk().endTimeUs; + } + chunkSource.getNextChunk(positionUs, loadPositionUs, chunkQueue, nextChunkHolder); + boolean endOfStream = nextChunkHolder.endOfStream; + Chunk loadable = nextChunkHolder.chunk; + nextChunkHolder.clear(); + + if (endOfStream) { + pendingResetPositionUs = C.TIME_UNSET; + loadingFinished = true; + return true; + } + + if (loadable == null) { + return false; + } + + if (isMediaChunk(loadable)) { + BaseMediaChunk mediaChunk = (BaseMediaChunk) loadable; + if (pendingReset) { + boolean resetToMediaChunk = mediaChunk.startTimeUs == pendingResetPositionUs; + // Only enable setting of the decode only flag if we're not resetting to a chunk boundary. + decodeOnlyUntilPositionUs = resetToMediaChunk ? 0 : pendingResetPositionUs; + pendingResetPositionUs = C.TIME_UNSET; + } + mediaChunk.init(chunkOutput); + mediaChunks.add(mediaChunk); + } else if (loadable instanceof InitializationChunk) { + ((InitializationChunk) loadable).init(chunkOutput); + } + long elapsedRealtimeMs = + loader.startLoading( + loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); + eventDispatcher.loadStarted( + loadable.dataSpec, + loadable.type, + primaryTrackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs); + return true; + } + + @Override + public boolean isLoading() { + return loader.isLoading(); + } + + @Override + public long getNextLoadPositionUs() { + if (isPendingReset()) { + return pendingResetPositionUs; + } else { + return loadingFinished ? C.TIME_END_OF_SOURCE : getLastMediaChunk().endTimeUs; + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + if (loader.isLoading() || loader.hasFatalError() || isPendingReset()) { + return; + } + + int currentQueueSize = mediaChunks.size(); + int preferredQueueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); + if (currentQueueSize <= preferredQueueSize) { + return; + } + + int newQueueSize = currentQueueSize; + for (int i = preferredQueueSize; i < currentQueueSize; i++) { + if (!haveReadFromMediaChunk(i)) { + newQueueSize = i; + break; + } + } + if (newQueueSize == currentQueueSize) { + return; + } + + long endTimeUs = getLastMediaChunk().endTimeUs; + BaseMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(newQueueSize); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + loadingFinished = false; + eventDispatcher.upstreamDiscarded(primaryTrackType, firstRemovedChunk.startTimeUs, endTimeUs); + } + + // Internal methods + + private boolean isMediaChunk(Chunk chunk) { + return chunk instanceof BaseMediaChunk; + } + + /** Returns whether samples have been read from media chunk at given index. */ + private boolean haveReadFromMediaChunk(int mediaChunkIndex) { + BaseMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); + if (primarySampleQueue.getReadIndex() > mediaChunk.getFirstSampleIndex(0)) { + return true; + } + for (int i = 0; i < embeddedSampleQueues.length; i++) { + if (embeddedSampleQueues[i].getReadIndex() > mediaChunk.getFirstSampleIndex(i + 1)) { + return true; + } + } + return false; + } + + /* package */ boolean isPendingReset() { + return pendingResetPositionUs != C.TIME_UNSET; + } + + private void discardDownstreamMediaChunks(int discardToSampleIndex) { + int discardToMediaChunkIndex = + primarySampleIndexToMediaChunkIndex(discardToSampleIndex, /* minChunkIndex= */ 0); + // Don't discard any chunks that we haven't reported the primary format change for yet. + discardToMediaChunkIndex = + Math.min(discardToMediaChunkIndex, nextNotifyPrimaryFormatMediaChunkIndex); + if (discardToMediaChunkIndex > 0) { + Util.removeRange(mediaChunks, /* fromIndex= */ 0, /* toIndex= */ discardToMediaChunkIndex); + nextNotifyPrimaryFormatMediaChunkIndex -= discardToMediaChunkIndex; + } + } + + private void maybeNotifyPrimaryTrackFormatChanged() { + int readSampleIndex = primarySampleQueue.getReadIndex(); + int notifyToMediaChunkIndex = + primarySampleIndexToMediaChunkIndex( + readSampleIndex, /* minChunkIndex= */ nextNotifyPrimaryFormatMediaChunkIndex - 1); + while (nextNotifyPrimaryFormatMediaChunkIndex <= notifyToMediaChunkIndex) { + maybeNotifyPrimaryTrackFormatChanged(nextNotifyPrimaryFormatMediaChunkIndex++); + } + } + + private void maybeNotifyPrimaryTrackFormatChanged(int mediaChunkReadIndex) { + BaseMediaChunk currentChunk = mediaChunks.get(mediaChunkReadIndex); + Format trackFormat = currentChunk.trackFormat; + if (!trackFormat.equals(primaryDownstreamTrackFormat)) { + eventDispatcher.downstreamFormatChanged(primaryTrackType, trackFormat, + currentChunk.trackSelectionReason, currentChunk.trackSelectionData, + currentChunk.startTimeUs); + } + primaryDownstreamTrackFormat = trackFormat; + } + + /** + * Returns the media chunk index corresponding to a given primary sample index. + * + * @param primarySampleIndex The primary sample index for which the corresponding media chunk + * index is required. + * @param minChunkIndex A minimum chunk index from which to start searching, or -1 if no hint can + * be provided. + * @return The index of the media chunk corresponding to the sample index, or -1 if the list of + * media chunks is empty, or {@code minChunkIndex} if the sample precedes the first chunk in + * the search (i.e. the chunk at {@code minChunkIndex}, or at index 0 if {@code minChunkIndex} + * is -1. + */ + private int primarySampleIndexToMediaChunkIndex(int primarySampleIndex, int minChunkIndex) { + for (int i = minChunkIndex + 1; i < mediaChunks.size(); i++) { + if (mediaChunks.get(i).getFirstSampleIndex(0) > primarySampleIndex) { + return i - 1; + } + } + return mediaChunks.size() - 1; + } + + private BaseMediaChunk getLastMediaChunk() { + return mediaChunks.get(mediaChunks.size() - 1); + } + + /** + * Discard upstream media chunks from {@code chunkIndex} and corresponding samples from sample + * queues. + * + * @param chunkIndex The index of the first chunk to discard. + * @return The chunk at given index. + */ + private BaseMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { + BaseMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); + Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); + nextNotifyPrimaryFormatMediaChunkIndex = + Math.max(nextNotifyPrimaryFormatMediaChunkIndex, mediaChunks.size()); + primarySampleQueue.discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(0)); + for (int i = 0; i < embeddedSampleQueues.length; i++) { + embeddedSampleQueues[i].discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(i + 1)); + } + return firstRemovedChunk; + } + + /** + * A {@link SampleStream} embedded in a {@link ChunkSampleStream}. + */ + public final class EmbeddedSampleStream implements SampleStream { + + public final ChunkSampleStream<T> parent; + + private final SampleQueue sampleQueue; + private final int index; + + private boolean notifiedDownstreamFormat; + + public EmbeddedSampleStream(ChunkSampleStream<T> parent, SampleQueue sampleQueue, int index) { + this.parent = parent; + this.sampleQueue = sampleQueue; + this.index = index; + } + + @Override + public boolean isReady() { + return !isPendingReset() && sampleQueue.isReady(loadingFinished); + } + + @Override + public int skipData(long positionUs) { + if (isPendingReset()) { + return 0; + } + maybeNotifyDownstreamFormat(); + int skipCount; + if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { + skipCount = sampleQueue.advanceToEnd(); + } else { + skipCount = sampleQueue.advanceTo(positionUs); + } + return skipCount; + } + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. Errors will be thrown from the primary stream. + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean formatRequired) { + if (isPendingReset()) { + return C.RESULT_NOTHING_READ; + } + maybeNotifyDownstreamFormat(); + return sampleQueue.read( + formatHolder, + buffer, + formatRequired, + loadingFinished, + decodeOnlyUntilPositionUs); + } + + public void release() { + Assertions.checkState(embeddedTracksSelected[index]); + embeddedTracksSelected[index] = false; + } + + private void maybeNotifyDownstreamFormat() { + if (!notifiedDownstreamFormat) { + eventDispatcher.downstreamFormatChanged( + embeddedTrackTypes[index], + embeddedTrackFormats[index], + C.SELECTION_REASON_UNKNOWN, + /* trackSelectionData= */ null, + lastSeekPositionUs); + notifiedDownstreamFormat = true; + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSource.java new file mode 100644 index 0000000000..33cee8e20e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ChunkSource.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import java.io.IOException; +import java.util.List; + +/** + * A provider of {@link Chunk}s for a {@link ChunkSampleStream} to load. + */ +public interface ChunkSource { + + /** + * Adjusts a seek position given the specified {@link SeekParameters}. Chunk boundaries are used + * as sync points. + * + * @param positionUs The seek position in microseconds. + * @param seekParameters Parameters that control how the seek is performed. + * @return The adjusted seek position, in microseconds. + */ + long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters); + + /** + * If the source is currently having difficulty providing chunks, then this method throws the + * underlying error. Otherwise does nothing. + * <p> + * This method should only be called after the source has been prepared. + * + * @throws IOException The underlying error. + */ + void maybeThrowError() throws IOException; + + /** + * Evaluates whether {@link MediaChunk}s should be removed from the back of the queue. + * <p> + * Removing {@link MediaChunk}s from the back of the queue can be useful if they could be replaced + * with chunks of a significantly higher quality (e.g. because the available bandwidth has + * substantially increased). + * + * @param playbackPositionUs The current playback position. + * @param queue The queue of buffered {@link MediaChunk}s. + * @return The preferred queue size. + */ + int getPreferredQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue); + + /** + * Returns the next chunk to load. + * + * <p>If a chunk is available then {@link ChunkHolder#chunk} is set. If the end of the stream has + * been reached then {@link ChunkHolder#endOfStream} is set. If a chunk is not available but the + * end of the stream has not been reached, the {@link ChunkHolder} is not modified. + * + * @param playbackPositionUs The current playback position in microseconds. If playback of the + * period to which this chunk source belongs has not yet started, the value will be the + * starting position in the period minus the duration of any media in previous periods still + * to be played. + * @param loadPositionUs The current load position in microseconds. If {@code queue} is empty, + * this is the starting position from which chunks should be provided. Else it's equal to + * {@link MediaChunk#endTimeUs} of the last chunk in the {@code queue}. + * @param queue The queue of buffered {@link MediaChunk}s. + * @param out A holder to populate. + */ + void getNextChunk( + long playbackPositionUs, + long loadPositionUs, + List<? extends MediaChunk> queue, + ChunkHolder out); + + /** + * Called when the {@link ChunkSampleStream} has finished loading a chunk obtained from this + * source. + * + * <p>This method should only be called when the source is enabled. + * + * @param chunk The chunk whose load has been completed. + */ + void onChunkLoadCompleted(Chunk chunk); + + /** + * Called when the {@link ChunkSampleStream} encounters an error loading a chunk obtained from + * this source. + * + * <p>This method should only be called when the source is enabled. + * + * @param chunk The chunk whose load encountered the error. + * @param cancelable Whether the load can be canceled. + * @param e The error. + * @param blacklistDurationMs The duration for which the associated track may be blacklisted, or + * {@link C#TIME_UNSET} if the track may not be blacklisted. + * @return Whether the load should be canceled so that a replacement chunk can be loaded instead. + * Must be {@code false} if {@code cancelable} is {@code false}. If {@code true}, {@link + * #getNextChunk(long, long, List, ChunkHolder)} will be called to obtain the replacement + * chunk. + */ + boolean onChunkLoadError(Chunk chunk, boolean cancelable, Exception e, long blacklistDurationMs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java new file mode 100644 index 0000000000..98865e8b0e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/ContainerMediaChunk.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A {@link BaseMediaChunk} that uses an {@link Extractor} to decode sample data. + */ +public class ContainerMediaChunk extends BaseMediaChunk { + + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + private final int chunkCount; + private final long sampleOffsetUs; + private final ChunkExtractorWrapper extractorWrapper; + + private long nextLoadPosition; + private volatile boolean loadCanceled; + private boolean loadCompleted; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param clippedStartTimeUs The time in the chunk from which output will begin, or {@link + * C#TIME_UNSET} to output from the start of the chunk. + * @param clippedEndTimeUs The time in the chunk from which output will end, or {@link + * C#TIME_UNSET} to output to the end of the chunk. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + * @param chunkCount The number of chunks in the underlying media that are spanned by this + * instance. Normally equal to one, but may be larger if multiple chunks as defined by the + * underlying media are being merged into a single load. + * @param sampleOffsetUs An offset to add to the sample timestamps parsed by the extractor. + * @param extractorWrapper A wrapped extractor to use for parsing the data. + */ + public ContainerMediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long clippedStartTimeUs, + long clippedEndTimeUs, + long chunkIndex, + int chunkCount, + long sampleOffsetUs, + ChunkExtractorWrapper extractorWrapper) { + super( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + startTimeUs, + endTimeUs, + clippedStartTimeUs, + clippedEndTimeUs, + chunkIndex); + this.chunkCount = chunkCount; + this.sampleOffsetUs = sampleOffsetUs; + this.extractorWrapper = extractorWrapper; + } + + @Override + public long getNextChunkIndex() { + return chunkIndex + chunkCount; + } + + @Override + public boolean isLoadCompleted() { + return loadCompleted; + } + + // Loadable implementation. + + @Override + public final void cancelLoad() { + loadCanceled = true; + } + + @SuppressWarnings("NonAtomicVolatileUpdate") + @Override + public final void load() throws IOException, InterruptedException { + if (nextLoadPosition == 0) { + // Configure the output and set it as the target for the extractor wrapper. + BaseMediaChunkOutput output = getOutput(); + output.setSampleOffsetUs(sampleOffsetUs); + extractorWrapper.init( + getTrackOutputProvider(output), + clippedStartTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedStartTimeUs - sampleOffsetUs), + clippedEndTimeUs == C.TIME_UNSET ? C.TIME_UNSET : (clippedEndTimeUs - sampleOffsetUs)); + } + try { + // Create and open the input. + DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition); + ExtractorInput input = + new DefaultExtractorInput( + dataSource, loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); + // Load and decode the sample data. + try { + Extractor extractor = extractorWrapper.extractor; + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, DUMMY_POSITION_HOLDER); + } + Assertions.checkState(result != Extractor.RESULT_SEEK); + } finally { + nextLoadPosition = input.getPosition() - dataSpec.absoluteStreamPosition; + } + } finally { + Util.closeQuietly(dataSource); + } + loadCompleted = true; + } + + /** + * Returns the {@link TrackOutputProvider} to be used by the wrapped extractor. + * + * @param baseMediaChunkOutput The {@link BaseMediaChunkOutput} most recently passed to {@link + * #init(BaseMediaChunkOutput)}. + * @return A {@link TrackOutputProvider} to be used by the wrapped extractor. + */ + protected TrackOutputProvider getTrackOutputProvider(BaseMediaChunkOutput baseMediaChunkOutput) { + return baseMediaChunkOutput; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/DataChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/DataChunk.java new file mode 100644 index 0000000000..583f8ceeee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/DataChunk.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; + +/** + * A base class for {@link Chunk} implementations where the data should be loaded into a + * {@code byte[]} before being consumed. + */ +public abstract class DataChunk extends Chunk { + + private static final int READ_GRANULARITY = 16 * 1024; + + private byte[] data; + + private volatile boolean loadCanceled; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param type See {@link #type}. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param data An optional recycled array that can be used as a holder for the data. + */ + public DataChunk( + DataSource dataSource, + DataSpec dataSpec, + int type, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + byte[] data) { + super(dataSource, dataSpec, type, trackFormat, trackSelectionReason, trackSelectionData, + C.TIME_UNSET, C.TIME_UNSET); + this.data = data; + } + + /** + * Returns the array in which the data is held. + * <p> + * This method should be used for recycling the holder only, and not for reading the data. + * + * @return The array in which the data is held. + */ + public byte[] getDataHolder() { + return data; + } + + // Loadable implementation + + @Override + public final void cancelLoad() { + loadCanceled = true; + } + + @Override + public final void load() throws IOException, InterruptedException { + try { + dataSource.open(dataSpec); + int limit = 0; + int bytesRead = 0; + while (bytesRead != C.RESULT_END_OF_INPUT && !loadCanceled) { + maybeExpandData(limit); + bytesRead = dataSource.read(data, limit, READ_GRANULARITY); + if (bytesRead != -1) { + limit += bytesRead; + } + } + if (!loadCanceled) { + consume(data, limit); + } + } finally { + Util.closeQuietly(dataSource); + } + } + + /** + * Called by {@link #load()}. Implementations should override this method to consume the loaded + * data. + * + * @param data An array containing the data. + * @param limit The limit of the data. + * @throws IOException If an error occurs consuming the loaded data. + */ + protected abstract void consume(byte[] data, int limit) throws IOException; + + private void maybeExpandData(int limit) { + if (data == null) { + data = new byte[READ_GRANULARITY]; + } else if (data.length < limit + READ_GRANULARITY) { + // The new length is calculated as (data.length + READ_GRANULARITY) rather than + // (limit + READ_GRANULARITY) in order to avoid small increments in the length. + data = Arrays.copyOf(data, data.length + READ_GRANULARITY); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/InitializationChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/InitializationChunk.java new file mode 100644 index 0000000000..db6e82c2c7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/InitializationChunk.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.ChunkExtractorWrapper.TrackOutputProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link Chunk} that uses an {@link Extractor} to decode initialization data for single track. + */ +public final class InitializationChunk extends Chunk { + + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + private final ChunkExtractorWrapper extractorWrapper; + + @MonotonicNonNull private TrackOutputProvider trackOutputProvider; + private long nextLoadPosition; + private volatile boolean loadCanceled; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param extractorWrapper A wrapped extractor to use for parsing the initialization data. + */ + public InitializationChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + ChunkExtractorWrapper extractorWrapper) { + super(dataSource, dataSpec, C.DATA_TYPE_MEDIA_INITIALIZATION, trackFormat, trackSelectionReason, + trackSelectionData, C.TIME_UNSET, C.TIME_UNSET); + this.extractorWrapper = extractorWrapper; + } + + /** + * Initializes the chunk for loading, setting a {@link TrackOutputProvider} for track outputs to + * which formats will be written as they are loaded. + * + * @param trackOutputProvider The {@link TrackOutputProvider} for track outputs to which formats + * will be written as they are loaded. + */ + public void init(TrackOutputProvider trackOutputProvider) { + this.trackOutputProvider = trackOutputProvider; + } + + // Loadable implementation. + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @SuppressWarnings("NonAtomicVolatileUpdate") + @Override + public void load() throws IOException, InterruptedException { + if (nextLoadPosition == 0) { + extractorWrapper.init( + trackOutputProvider, /* startTimeUs= */ C.TIME_UNSET, /* endTimeUs= */ C.TIME_UNSET); + } + try { + // Create and open the input. + DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition); + ExtractorInput input = + new DefaultExtractorInput( + dataSource, loadDataSpec.absoluteStreamPosition, dataSource.open(loadDataSpec)); + // Load and decode the initialization data. + try { + Extractor extractor = extractorWrapper.extractor; + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, DUMMY_POSITION_HOLDER); + } + Assertions.checkState(result != Extractor.RESULT_SEEK); + } finally { + nextLoadPosition = input.getPosition() - dataSpec.absoluteStreamPosition; + } + } finally { + Util.closeQuietly(dataSource); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunk.java new file mode 100644 index 0000000000..81c9d216b9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunk.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * An abstract base class for {@link Chunk}s that contain media samples. + */ +public abstract class MediaChunk extends Chunk { + + /** The chunk index, or {@link C#INDEX_UNSET} if it is not known. */ + public final long chunkIndex; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + */ + public MediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long chunkIndex) { + super(dataSource, dataSpec, C.DATA_TYPE_MEDIA, trackFormat, trackSelectionReason, + trackSelectionData, startTimeUs, endTimeUs); + Assertions.checkNotNull(trackFormat); + this.chunkIndex = chunkIndex; + } + + /** Returns the next chunk index or {@link C#INDEX_UNSET} if it is not known. */ + public long getNextChunkIndex() { + return chunkIndex != C.INDEX_UNSET ? chunkIndex + 1 : C.INDEX_UNSET; + } + + /** + * Returns whether the chunk has been fully loaded. + */ + public abstract boolean isLoadCompleted(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkIterator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkIterator.java new file mode 100644 index 0000000000..c6f5b1d41e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkIterator.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.util.NoSuchElementException; + +/** + * Iterator for media chunk sequences. + * + * <p>The iterator initially points in front of the first available element. The first call to + * {@link #next()} moves the iterator to the first element. Check the return value of {@link + * #next()} or {@link #isEnded()} to determine whether the iterator reached the end of the available + * data. + */ +public interface MediaChunkIterator { + + /** An empty media chunk iterator without available data. */ + MediaChunkIterator EMPTY = + new MediaChunkIterator() { + @Override + public boolean isEnded() { + return true; + } + + @Override + public boolean next() { + return false; + } + + @Override + public DataSpec getDataSpec() { + throw new NoSuchElementException(); + } + + @Override + public long getChunkStartTimeUs() { + throw new NoSuchElementException(); + } + + @Override + public long getChunkEndTimeUs() { + throw new NoSuchElementException(); + } + + @Override + public void reset() { + // Do nothing. + } + }; + + /** Returns whether the iteration has reached the end of the available data. */ + boolean isEnded(); + + /** + * Moves the iterator to the next media chunk. + * + * <p>Check the return value or {@link #isEnded()} to determine whether the iterator reached the + * end of the available data. + * + * @return Whether the iterator points to a media chunk with available data. + */ + boolean next(); + + /** + * Returns the {@link DataSpec} used to load the media chunk. + * + * @throws java.util.NoSuchElementException If the method is called before the first call to + * {@link #next()} or when {@link #isEnded()} is true. + */ + DataSpec getDataSpec(); + + /** + * Returns the media start time of the chunk, in microseconds. + * + * @throws java.util.NoSuchElementException If the method is called before the first call to + * {@link #next()} or when {@link #isEnded()} is true. + */ + long getChunkStartTimeUs(); + + /** + * Returns the media end time of the chunk, in microseconds. + * + * @throws java.util.NoSuchElementException If the method is called before the first call to + * {@link #next()} or when {@link #isEnded()} is true. + */ + long getChunkEndTimeUs(); + + /** Resets the iterator to the initial position. */ + void reset(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkListIterator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkListIterator.java new file mode 100644 index 0000000000..1b3004418e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/MediaChunkListIterator.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.util.List; + +/** A {@link MediaChunkIterator} which iterates over a {@link List} of {@link MediaChunk}s. */ +public final class MediaChunkListIterator extends BaseMediaChunkIterator { + + private final List<? extends MediaChunk> chunks; + private final boolean reverseOrder; + + /** + * Creates iterator. + * + * @param chunks The list of chunks to iterate over. + * @param reverseOrder Whether to iterate in reverse order. + */ + public MediaChunkListIterator(List<? extends MediaChunk> chunks, boolean reverseOrder) { + super(0, chunks.size() - 1); + this.chunks = chunks; + this.reverseOrder = reverseOrder; + } + + @Override + public DataSpec getDataSpec() { + return getCurrentChunk().dataSpec; + } + + @Override + public long getChunkStartTimeUs() { + return getCurrentChunk().startTimeUs; + } + + @Override + public long getChunkEndTimeUs() { + return getCurrentChunk().endTimeUs; + } + + private MediaChunk getCurrentChunk() { + int index = (int) super.getCurrentIndex(); + if (reverseOrder) { + index = chunks.size() - 1 - index; + } + return chunks.get(index); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java new file mode 100644 index 0000000000..b3d30408ee --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/chunk/SingleSampleMediaChunk.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; + +/** + * A {@link BaseMediaChunk} for chunks consisting of a single raw sample. + */ +public final class SingleSampleMediaChunk extends BaseMediaChunk { + + private final int trackType; + private final Format sampleFormat; + + private long nextLoadPosition; + private boolean loadCompleted; + + /** + * @param dataSource The source from which the data should be loaded. + * @param dataSpec Defines the data to be loaded. + * @param trackFormat See {@link #trackFormat}. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param startTimeUs The start time of the media contained by the chunk, in microseconds. + * @param endTimeUs The end time of the media contained by the chunk, in microseconds. + * @param chunkIndex The index of the chunk, or {@link C#INDEX_UNSET} if it is not known. + * @param trackType The type of the chunk. Typically one of the {@link C} {@code TRACK_TYPE_*} + * constants. + * @param sampleFormat The {@link Format} of the sample in the chunk. + */ + public SingleSampleMediaChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long chunkIndex, + int trackType, + Format sampleFormat) { + super( + dataSource, + dataSpec, + trackFormat, + trackSelectionReason, + trackSelectionData, + startTimeUs, + endTimeUs, + /* clippedStartTimeUs= */ C.TIME_UNSET, + /* clippedEndTimeUs= */ C.TIME_UNSET, + chunkIndex); + this.trackType = trackType; + this.sampleFormat = sampleFormat; + } + + + @Override + public boolean isLoadCompleted() { + return loadCompleted; + } + + // Loadable implementation. + + @Override + public void cancelLoad() { + // Do nothing. + } + + @SuppressWarnings("NonAtomicVolatileUpdate") + @Override + public void load() throws IOException, InterruptedException { + BaseMediaChunkOutput output = getOutput(); + output.setSampleOffsetUs(0); + TrackOutput trackOutput = output.track(0, trackType); + trackOutput.format(sampleFormat); + try { + // Create and open the input. + DataSpec loadDataSpec = dataSpec.subrange(nextLoadPosition); + long length = dataSource.open(loadDataSpec); + if (length != C.LENGTH_UNSET) { + length += nextLoadPosition; + } + ExtractorInput extractorInput = + new DefaultExtractorInput(dataSource, nextLoadPosition, length); + // Load the sample data. + int result = 0; + while (result != C.RESULT_END_OF_INPUT) { + nextLoadPosition += result; + result = trackOutput.sampleData(extractorInput, Integer.MAX_VALUE, true); + } + int sampleSize = (int) nextLoadPosition; + trackOutput.sampleMetadata(startTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + } finally { + Util.closeQuietly(dataSource); + } + loadCompleted = true; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java new file mode 100644 index 0000000000..4643c0402c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/Aes128DataSource.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceInputStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.Key; +import java.security.NoSuchAlgorithmException; +import java.security.spec.AlgorithmParameterSpec; +import java.util.List; +import java.util.Map; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A {@link DataSource} that decrypts data read from an upstream source, encrypted with AES-128 with + * a 128-bit key and PKCS7 padding. + * + * <p>Note that this {@link DataSource} does not support being opened from arbitrary offsets. It is + * designed specifically for reading whole files as defined in an HLS media playlist. For this + * reason the implementation is private to the HLS package. + */ +/* package */ class Aes128DataSource implements DataSource { + + private final DataSource upstream; + private final byte[] encryptionKey; + private final byte[] encryptionIv; + + @Nullable private CipherInputStream cipherInputStream; + + /** + * @param upstream The upstream {@link DataSource}. + * @param encryptionKey The encryption key. + * @param encryptionIv The encryption initialization vector. + */ + public Aes128DataSource(DataSource upstream, byte[] encryptionKey, byte[] encryptionIv) { + this.upstream = upstream; + this.encryptionKey = encryptionKey; + this.encryptionIv = encryptionIv; + } + + @Override + public final void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public final long open(DataSpec dataSpec) throws IOException { + Cipher cipher; + try { + cipher = getCipherInstance(); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new RuntimeException(e); + } + + Key cipherKey = new SecretKeySpec(encryptionKey, "AES"); + AlgorithmParameterSpec cipherIV = new IvParameterSpec(encryptionIv); + + try { + cipher.init(Cipher.DECRYPT_MODE, cipherKey, cipherIV); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new RuntimeException(e); + } + + DataSourceInputStream inputStream = new DataSourceInputStream(upstream, dataSpec); + cipherInputStream = new CipherInputStream(inputStream, cipher); + inputStream.open(); + + return C.LENGTH_UNSET; + } + + @Override + public final int read(byte[] buffer, int offset, int readLength) throws IOException { + Assertions.checkNotNull(cipherInputStream); + int bytesRead = cipherInputStream.read(buffer, offset, readLength); + if (bytesRead < 0) { + return C.RESULT_END_OF_INPUT; + } + return bytesRead; + } + + @Override + @Nullable + public final Uri getUri() { + return upstream.getUri(); + } + + @Override + public final Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + if (cipherInputStream != null) { + cipherInputStream = null; + upstream.close(); + } + } + + protected Cipher getCipherInstance() throws NoSuchPaddingException, NoSuchAlgorithmException { + return Cipher.getInstance("AES/CBC/PKCS7Padding"); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java new file mode 100644 index 0000000000..cbe2f797b7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsDataSourceFactory.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; + +/** + * Default implementation of {@link HlsDataSourceFactory}. + */ +public final class DefaultHlsDataSourceFactory implements HlsDataSourceFactory { + + private final DataSource.Factory dataSourceFactory; + + /** + * @param dataSourceFactory The {@link DataSource.Factory} to use for all data types. + */ + public DefaultHlsDataSourceFactory(DataSource.Factory dataSourceFactory) { + this.dataSourceFactory = dataSourceFactory; + } + + @Override + public DataSource createDataSource(int dataType) { + return dataSourceFactory.createDataSource(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java new file mode 100644 index 0000000000..6f39e1bff8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/DefaultHlsExtractorFactory.java @@ -0,0 +1,338 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp3.Mp3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac3Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.Ac4Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.AdtsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.DefaultTsPayloadReaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ts.TsExtractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.EOFException; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Default {@link HlsExtractorFactory} implementation. + */ +public final class DefaultHlsExtractorFactory implements HlsExtractorFactory { + + public static final String AAC_FILE_EXTENSION = ".aac"; + public static final String AC3_FILE_EXTENSION = ".ac3"; + public static final String EC3_FILE_EXTENSION = ".ec3"; + public static final String AC4_FILE_EXTENSION = ".ac4"; + public static final String MP3_FILE_EXTENSION = ".mp3"; + public static final String MP4_FILE_EXTENSION = ".mp4"; + public static final String M4_FILE_EXTENSION_PREFIX = ".m4"; + public static final String MP4_FILE_EXTENSION_PREFIX = ".mp4"; + public static final String CMF_FILE_EXTENSION_PREFIX = ".cmf"; + public static final String VTT_FILE_EXTENSION = ".vtt"; + public static final String WEBVTT_FILE_EXTENSION = ".webvtt"; + + @DefaultTsPayloadReaderFactory.Flags private final int payloadReaderFactoryFlags; + private final boolean exposeCea608WhenMissingDeclarations; + + /** + * Equivalent to {@link #DefaultHlsExtractorFactory(int, boolean) new + * DefaultHlsExtractorFactory(payloadReaderFactoryFlags = 0, exposeCea608WhenMissingDeclarations = + * true)} + */ + public DefaultHlsExtractorFactory() { + this(/* payloadReaderFactoryFlags= */ 0, /* exposeCea608WhenMissingDeclarations */ true); + } + + /** + * Creates a factory for HLS segment extractors. + * + * @param payloadReaderFactoryFlags Flags to add when constructing any {@link + * DefaultTsPayloadReaderFactory} instances. Other flags may be added on top of {@code + * payloadReaderFactoryFlags} when creating {@link DefaultTsPayloadReaderFactory}. + * @param exposeCea608WhenMissingDeclarations Whether created {@link TsExtractor} instances should + * expose a CEA-608 track should the master playlist contain no Closed Captions declarations. + * If the master playlist contains any Closed Captions declarations, this flag is ignored. + */ + public DefaultHlsExtractorFactory( + int payloadReaderFactoryFlags, boolean exposeCea608WhenMissingDeclarations) { + this.payloadReaderFactoryFlags = payloadReaderFactoryFlags; + this.exposeCea608WhenMissingDeclarations = exposeCea608WhenMissingDeclarations; + } + + @Override + public Result createExtractor( + @Nullable Extractor previousExtractor, + Uri uri, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster, + Map<String, List<String>> responseHeaders, + ExtractorInput extractorInput) + throws InterruptedException, IOException { + + if (previousExtractor != null) { + // A extractor has already been successfully used. Return one of the same type. + if (isReusable(previousExtractor)) { + return buildResult(previousExtractor); + } else { + Result result = + buildResultForSameExtractorType(previousExtractor, format, timestampAdjuster); + if (result == null) { + throw new IllegalArgumentException( + "Unexpected previousExtractor type: " + previousExtractor.getClass().getSimpleName()); + } + } + } + + // Try selecting the extractor by the file extension. + Extractor extractorByFileExtension = + createExtractorByFileExtension(uri, format, muxedCaptionFormats, timestampAdjuster); + extractorInput.resetPeekPosition(); + if (sniffQuietly(extractorByFileExtension, extractorInput)) { + return buildResult(extractorByFileExtension); + } + + // We need to manually sniff each known type, without retrying the one selected by file + // extension. + + if (!(extractorByFileExtension instanceof WebvttExtractor)) { + WebvttExtractor webvttExtractor = new WebvttExtractor(format.language, timestampAdjuster); + if (sniffQuietly(webvttExtractor, extractorInput)) { + return buildResult(webvttExtractor); + } + } + + if (!(extractorByFileExtension instanceof AdtsExtractor)) { + AdtsExtractor adtsExtractor = new AdtsExtractor(); + if (sniffQuietly(adtsExtractor, extractorInput)) { + return buildResult(adtsExtractor); + } + } + + if (!(extractorByFileExtension instanceof Ac3Extractor)) { + Ac3Extractor ac3Extractor = new Ac3Extractor(); + if (sniffQuietly(ac3Extractor, extractorInput)) { + return buildResult(ac3Extractor); + } + } + + if (!(extractorByFileExtension instanceof Ac4Extractor)) { + Ac4Extractor ac4Extractor = new Ac4Extractor(); + if (sniffQuietly(ac4Extractor, extractorInput)) { + return buildResult(ac4Extractor); + } + } + + if (!(extractorByFileExtension instanceof Mp3Extractor)) { + Mp3Extractor mp3Extractor = + new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); + if (sniffQuietly(mp3Extractor, extractorInput)) { + return buildResult(mp3Extractor); + } + } + + if (!(extractorByFileExtension instanceof FragmentedMp4Extractor)) { + FragmentedMp4Extractor fragmentedMp4Extractor = + createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); + if (sniffQuietly(fragmentedMp4Extractor, extractorInput)) { + return buildResult(fragmentedMp4Extractor); + } + } + + if (!(extractorByFileExtension instanceof TsExtractor)) { + TsExtractor tsExtractor = + createTsExtractor( + payloadReaderFactoryFlags, + exposeCea608WhenMissingDeclarations, + format, + muxedCaptionFormats, + timestampAdjuster); + if (sniffQuietly(tsExtractor, extractorInput)) { + return buildResult(tsExtractor); + } + } + + // Fall back on the extractor created by file extension. + return buildResult(extractorByFileExtension); + } + + private Extractor createExtractorByFileExtension( + Uri uri, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster) { + String lastPathSegment = uri.getLastPathSegment(); + if (lastPathSegment == null) { + lastPathSegment = ""; + } + if (MimeTypes.TEXT_VTT.equals(format.sampleMimeType) + || lastPathSegment.endsWith(WEBVTT_FILE_EXTENSION) + || lastPathSegment.endsWith(VTT_FILE_EXTENSION)) { + return new WebvttExtractor(format.language, timestampAdjuster); + } else if (lastPathSegment.endsWith(AAC_FILE_EXTENSION)) { + return new AdtsExtractor(); + } else if (lastPathSegment.endsWith(AC3_FILE_EXTENSION) + || lastPathSegment.endsWith(EC3_FILE_EXTENSION)) { + return new Ac3Extractor(); + } else if (lastPathSegment.endsWith(AC4_FILE_EXTENSION)) { + return new Ac4Extractor(); + } else if (lastPathSegment.endsWith(MP3_FILE_EXTENSION)) { + return new Mp3Extractor(/* flags= */ 0, /* forcedFirstSampleTimestampUs= */ 0); + } else if (lastPathSegment.endsWith(MP4_FILE_EXTENSION) + || lastPathSegment.startsWith(M4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 4) + || lastPathSegment.startsWith(MP4_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5) + || lastPathSegment.startsWith(CMF_FILE_EXTENSION_PREFIX, lastPathSegment.length() - 5)) { + return createFragmentedMp4Extractor(timestampAdjuster, format, muxedCaptionFormats); + } else { + // For any other file extension, we assume TS format. + return createTsExtractor( + payloadReaderFactoryFlags, + exposeCea608WhenMissingDeclarations, + format, + muxedCaptionFormats, + timestampAdjuster); + } + } + + private static TsExtractor createTsExtractor( + @DefaultTsPayloadReaderFactory.Flags int userProvidedPayloadReaderFactoryFlags, + boolean exposeCea608WhenMissingDeclarations, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster) { + @DefaultTsPayloadReaderFactory.Flags + int payloadReaderFactoryFlags = + DefaultTsPayloadReaderFactory.FLAG_IGNORE_SPLICE_INFO_STREAM + | userProvidedPayloadReaderFactoryFlags; + if (muxedCaptionFormats != null) { + // The playlist declares closed caption renditions, we should ignore descriptors. + payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_OVERRIDE_CAPTION_DESCRIPTORS; + } else if (exposeCea608WhenMissingDeclarations) { + // The playlist does not provide any closed caption information. We preemptively declare a + // closed caption track on channel 0. + muxedCaptionFormats = + Collections.singletonList( + Format.createTextSampleFormat( + /* id= */ null, + MimeTypes.APPLICATION_CEA608, + /* selectionFlags= */ 0, + /* language= */ null)); + } else { + muxedCaptionFormats = Collections.emptyList(); + } + String codecs = format.codecs; + if (!TextUtils.isEmpty(codecs)) { + // Sometimes AAC and H264 streams are declared in TS chunks even though they don't really + // exist. If we know from the codec attribute that they don't exist, then we can + // explicitly ignore them even if they're declared. + if (!MimeTypes.AUDIO_AAC.equals(MimeTypes.getAudioMediaMimeType(codecs))) { + payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_AAC_STREAM; + } + if (!MimeTypes.VIDEO_H264.equals(MimeTypes.getVideoMediaMimeType(codecs))) { + payloadReaderFactoryFlags |= DefaultTsPayloadReaderFactory.FLAG_IGNORE_H264_STREAM; + } + } + + return new TsExtractor( + TsExtractor.MODE_HLS, + timestampAdjuster, + new DefaultTsPayloadReaderFactory(payloadReaderFactoryFlags, muxedCaptionFormats)); + } + + private static FragmentedMp4Extractor createFragmentedMp4Extractor( + TimestampAdjuster timestampAdjuster, + Format format, + @Nullable List<Format> muxedCaptionFormats) { + // Only enable the EMSG TrackOutput if this is the 'variant' track (i.e. the main one) to avoid + // creating a separate EMSG track for every audio track in a video stream. + return new FragmentedMp4Extractor( + /* flags= */ isFmp4Variant(format) ? FragmentedMp4Extractor.FLAG_ENABLE_EMSG_TRACK : 0, + timestampAdjuster, + /* sideloadedTrack= */ null, + muxedCaptionFormats != null ? muxedCaptionFormats : Collections.emptyList()); + } + + /** Returns true if this {@code format} represents a 'variant' track (i.e. the main one). */ + private static boolean isFmp4Variant(Format format) { + Metadata metadata = format.metadata; + if (metadata == null) { + return false; + } + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof HlsTrackMetadataEntry) { + return !((HlsTrackMetadataEntry) entry).variantInfos.isEmpty(); + } + } + return false; + } + + @Nullable + private static Result buildResultForSameExtractorType( + Extractor previousExtractor, Format format, TimestampAdjuster timestampAdjuster) { + if (previousExtractor instanceof WebvttExtractor) { + return buildResult(new WebvttExtractor(format.language, timestampAdjuster)); + } else if (previousExtractor instanceof AdtsExtractor) { + return buildResult(new AdtsExtractor()); + } else if (previousExtractor instanceof Ac3Extractor) { + return buildResult(new Ac3Extractor()); + } else if (previousExtractor instanceof Ac4Extractor) { + return buildResult(new Ac4Extractor()); + } else if (previousExtractor instanceof Mp3Extractor) { + return buildResult(new Mp3Extractor()); + } else { + return null; + } + } + + private static Result buildResult(Extractor extractor) { + return new Result( + extractor, + extractor instanceof AdtsExtractor + || extractor instanceof Ac3Extractor + || extractor instanceof Ac4Extractor + || extractor instanceof Mp3Extractor, + isReusable(extractor)); + } + + private static boolean sniffQuietly(Extractor extractor, ExtractorInput input) + throws InterruptedException, IOException { + boolean result = false; + try { + result = extractor.sniff(input); + } catch (EOFException e) { + // Do nothing. + } finally { + input.resetPeekPosition(); + } + return result; + } + + private static boolean isReusable(Extractor previousExtractor) { + return previousExtractor instanceof TsExtractor + || previousExtractor instanceof FragmentedMp4Extractor; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java new file mode 100644 index 0000000000..eab538582d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/FullSegmentEncryptionKeyCache.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * LRU cache that holds up to {@code maxSize} full-segment-encryption keys. Which each addition, + * once the cache's size exceeds {@code maxSize}, the oldest item (according to insertion order) is + * removed. + */ +/* package */ final class FullSegmentEncryptionKeyCache { + + private final LinkedHashMap<Uri, byte[]> backingMap; + + public FullSegmentEncryptionKeyCache(int maxSize) { + backingMap = + new LinkedHashMap<Uri, byte[]>( + /* initialCapacity= */ maxSize + 1, /* loadFactor= */ 1, /* accessOrder= */ false) { + @Override + protected boolean removeEldestEntry(Map.Entry<Uri, byte[]> eldest) { + return size() > maxSize; + } + }; + } + + /** + * Returns the {@code encryptionKey} cached against this {@code uri}, or null if {@code uri} is + * null or not present in the cache. + */ + @Nullable + public byte[] get(@Nullable Uri uri) { + if (uri == null) { + return null; + } + return backingMap.get(uri); + } + + /** + * Inserts an entry into the cache. + * + * @throws NullPointerException if {@code uri} or {@code encryptionKey} are null. + */ + @Nullable + public byte[] put(Uri uri, byte[] encryptionKey) { + return backingMap.put(Assertions.checkNotNull(uri), Assertions.checkNotNull(encryptionKey)); + } + + /** + * Returns true if {@code uri} is present in the cache. + * + * @throws NullPointerException if {@code uri} is null. + */ + public boolean containsUri(Uri uri) { + return backingMap.containsKey(Assertions.checkNotNull(uri)); + } + + /** + * Removes {@code uri} from the cache. If {@code uri} was present in the cahce, this returns the + * corresponding {@code encryptionKey}, otherwise null. + * + * @throws NullPointerException if {@code uri} is null. + */ + @Nullable + public byte[] remove(Uri uri) { + return backingMap.remove(Assertions.checkNotNull(uri)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java new file mode 100644 index 0000000000..da935389d8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsChunkSource.java @@ -0,0 +1,668 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.BehindLiveWindowException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.BaseMediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.Chunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.DataChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.BaseTrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Source of Hls (possibly adaptive) chunks. */ +/* package */ class HlsChunkSource { + + /** + * Chunk holder that allows the scheduling of retries. + */ + public static final class HlsChunkHolder { + + public HlsChunkHolder() { + clear(); + } + + /** The chunk to be loaded next. */ + @Nullable public Chunk chunk; + + /** + * Indicates that the end of the stream has been reached. + */ + public boolean endOfStream; + + /** Indicates that the chunk source is waiting for the referred playlist to be refreshed. */ + @Nullable public Uri playlistUrl; + + /** + * Clears the holder. + */ + public void clear() { + chunk = null; + endOfStream = false; + playlistUrl = null; + } + + } + + /** + * The maximum number of keys that the key cache can hold. This value must be 2 or greater in + * order to hold initialization segment and media segment keys simultaneously. + */ + private static final int KEY_CACHE_SIZE = 4; + + private final HlsExtractorFactory extractorFactory; + private final DataSource mediaDataSource; + private final DataSource encryptionDataSource; + private final TimestampAdjusterProvider timestampAdjusterProvider; + private final Uri[] playlistUrls; + private final Format[] playlistFormats; + private final HlsPlaylistTracker playlistTracker; + private final TrackGroup trackGroup; + @Nullable private final List<Format> muxedCaptionFormats; + private final FullSegmentEncryptionKeyCache keyCache; + + private boolean isTimestampMaster; + private byte[] scratchSpace; + @Nullable private IOException fatalError; + @Nullable private Uri expectedPlaylistUrl; + private boolean independentSegments; + + // Note: The track group in the selection is typically *not* equal to trackGroup. This is due to + // the way in which HlsSampleStreamWrapper generates track groups. Use only index based methods + // in TrackSelection to avoid unexpected behavior. + private TrackSelection trackSelection; + private long liveEdgeInPeriodTimeUs; + private boolean seenExpectedPlaylistError; + + /** + * @param extractorFactory An {@link HlsExtractorFactory} from which to obtain the extractors for + * media chunks. + * @param playlistTracker The {@link HlsPlaylistTracker} from which to obtain media playlists. + * @param playlistUrls The {@link Uri}s of the media playlists that can be adapted between by this + * chunk source. + * @param playlistFormats The {@link Format Formats} corresponding to the media playlists. + * @param dataSourceFactory An {@link HlsDataSourceFactory} to create {@link DataSource}s for the + * chunks. + * @param mediaTransferListener The transfer listener which should be informed of any media data + * transfers. May be null if no listener is available. + * @param timestampAdjusterProvider A provider of {@link TimestampAdjuster} instances. If multiple + * {@link HlsChunkSource}s are used for a single playback, they should all share the same + * provider. + * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption + * information is available in the master playlist. + */ + public HlsChunkSource( + HlsExtractorFactory extractorFactory, + HlsPlaylistTracker playlistTracker, + Uri[] playlistUrls, + Format[] playlistFormats, + HlsDataSourceFactory dataSourceFactory, + @Nullable TransferListener mediaTransferListener, + TimestampAdjusterProvider timestampAdjusterProvider, + @Nullable List<Format> muxedCaptionFormats) { + this.extractorFactory = extractorFactory; + this.playlistTracker = playlistTracker; + this.playlistUrls = playlistUrls; + this.playlistFormats = playlistFormats; + this.timestampAdjusterProvider = timestampAdjusterProvider; + this.muxedCaptionFormats = muxedCaptionFormats; + keyCache = new FullSegmentEncryptionKeyCache(KEY_CACHE_SIZE); + scratchSpace = Util.EMPTY_BYTE_ARRAY; + liveEdgeInPeriodTimeUs = C.TIME_UNSET; + mediaDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_MEDIA); + if (mediaTransferListener != null) { + mediaDataSource.addTransferListener(mediaTransferListener); + } + encryptionDataSource = dataSourceFactory.createDataSource(C.DATA_TYPE_DRM); + trackGroup = new TrackGroup(playlistFormats); + int[] initialTrackSelection = new int[playlistUrls.length]; + for (int i = 0; i < playlistUrls.length; i++) { + initialTrackSelection[i] = i; + } + trackSelection = new InitializationTrackSelection(trackGroup, initialTrackSelection); + } + + /** + * If the source is currently having difficulty providing chunks, then this method throws the + * underlying error. Otherwise does nothing. + * + * @throws IOException The underlying error. + */ + public void maybeThrowError() throws IOException { + if (fatalError != null) { + throw fatalError; + } + if (expectedPlaylistUrl != null && seenExpectedPlaylistError) { + playlistTracker.maybeThrowPlaylistRefreshError(expectedPlaylistUrl); + } + } + + /** + * Returns the track group exposed by the source. + */ + public TrackGroup getTrackGroup() { + return trackGroup; + } + + /** + * Sets the current track selection. + * + * @param trackSelection The {@link TrackSelection}. + */ + public void setTrackSelection(TrackSelection trackSelection) { + this.trackSelection = trackSelection; + } + + /** Returns the current {@link TrackSelection}. */ + public TrackSelection getTrackSelection() { + return trackSelection; + } + + /** + * Resets the source. + */ + public void reset() { + fatalError = null; + } + + /** + * Sets whether this chunk source is responsible for initializing timestamp adjusters. + * + * @param isTimestampMaster True if this chunk source is responsible for initializing timestamp + * adjusters. + */ + public void setIsTimestampMaster(boolean isTimestampMaster) { + this.isTimestampMaster = isTimestampMaster; + } + + /** + * Returns the next chunk to load. + * + * <p>If a chunk is available then {@link HlsChunkHolder#chunk} is set. If the end of the stream + * has been reached then {@link HlsChunkHolder#endOfStream} is set. If a chunk is not available + * but the end of the stream has not been reached, {@link HlsChunkHolder#playlistUrl} is set to + * contain the {@link Uri} that refers to the playlist that needs refreshing. + * + * @param playbackPositionUs The current playback position relative to the period start in + * microseconds. If playback of the period to which this chunk source belongs has not yet + * started, the value will be the starting position in the period minus the duration of any + * media in previous periods still to be played. + * @param loadPositionUs The current load position relative to the period start in microseconds. + * @param queue The queue of buffered {@link HlsMediaChunk}s. + * @param allowEndOfStream Whether {@link HlsChunkHolder#endOfStream} is allowed to be set for + * non-empty media playlists. If {@code false}, the last available chunk is returned instead. + * If the media playlist is empty, {@link HlsChunkHolder#endOfStream} is always set. + * @param out A holder to populate. + */ + public void getNextChunk( + long playbackPositionUs, + long loadPositionUs, + List<HlsMediaChunk> queue, + boolean allowEndOfStream, + HlsChunkHolder out) { + HlsMediaChunk previous = queue.isEmpty() ? null : queue.get(queue.size() - 1); + int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); + long bufferedDurationUs = loadPositionUs - playbackPositionUs; + long timeToLiveEdgeUs = resolveTimeToLiveEdgeUs(playbackPositionUs); + if (previous != null && !independentSegments) { + // Unless segments are known to be independent, switching tracks requires downloading + // overlapping segments. Hence we subtract the previous segment's duration from the buffered + // duration. + // This may affect the live-streaming adaptive track selection logic, when we compare the + // buffered duration to time-to-live-edge to decide whether to switch. Therefore, we subtract + // the duration of the last loaded segment from timeToLiveEdgeUs as well. + long subtractedDurationUs = previous.getDurationUs(); + bufferedDurationUs = Math.max(0, bufferedDurationUs - subtractedDurationUs); + if (timeToLiveEdgeUs != C.TIME_UNSET) { + timeToLiveEdgeUs = Math.max(0, timeToLiveEdgeUs - subtractedDurationUs); + } + } + + // Select the track. + MediaChunkIterator[] mediaChunkIterators = createMediaChunkIterators(previous, loadPositionUs); + trackSelection.updateSelectedTrack( + playbackPositionUs, bufferedDurationUs, timeToLiveEdgeUs, queue, mediaChunkIterators); + int selectedTrackIndex = trackSelection.getSelectedIndexInTrackGroup(); + + boolean switchingTrack = oldTrackIndex != selectedTrackIndex; + Uri selectedPlaylistUrl = playlistUrls[selectedTrackIndex]; + if (!playlistTracker.isSnapshotValid(selectedPlaylistUrl)) { + out.playlistUrl = selectedPlaylistUrl; + seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl); + expectedPlaylistUrl = selectedPlaylistUrl; + // Retry when playlist is refreshed. + return; + } + HlsMediaPlaylist mediaPlaylist = + playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true); + // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be non-null. + Assertions.checkNotNull(mediaPlaylist); + independentSegments = mediaPlaylist.hasIndependentSegments; + + updateLiveEdgeTimeUs(mediaPlaylist); + + // Select the chunk. + long startOfPlaylistInPeriodUs = + mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + long chunkMediaSequence = + getChunkMediaSequence( + previous, switchingTrack, mediaPlaylist, startOfPlaylistInPeriodUs, loadPositionUs); + if (chunkMediaSequence < mediaPlaylist.mediaSequence && previous != null && switchingTrack) { + // We try getting the next chunk without adapting in case that's the reason for falling + // behind the live window. + selectedTrackIndex = oldTrackIndex; + selectedPlaylistUrl = playlistUrls[selectedTrackIndex]; + mediaPlaylist = + playlistTracker.getPlaylistSnapshot(selectedPlaylistUrl, /* isForPlayback= */ true); + // playlistTracker snapshot is valid (checked by if() above), so mediaPlaylist must be + // non-null. + Assertions.checkNotNull(mediaPlaylist); + startOfPlaylistInPeriodUs = + mediaPlaylist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + chunkMediaSequence = previous.getNextChunkIndex(); + } + + if (chunkMediaSequence < mediaPlaylist.mediaSequence) { + fatalError = new BehindLiveWindowException(); + return; + } + + int segmentIndexInPlaylist = (int) (chunkMediaSequence - mediaPlaylist.mediaSequence); + int availableSegmentCount = mediaPlaylist.segments.size(); + if (segmentIndexInPlaylist >= availableSegmentCount) { + if (mediaPlaylist.hasEndTag) { + if (allowEndOfStream || availableSegmentCount == 0) { + out.endOfStream = true; + return; + } + segmentIndexInPlaylist = availableSegmentCount - 1; + } else /* Live */ { + out.playlistUrl = selectedPlaylistUrl; + seenExpectedPlaylistError &= selectedPlaylistUrl.equals(expectedPlaylistUrl); + expectedPlaylistUrl = selectedPlaylistUrl; + return; + } + } + // We have a valid playlist snapshot, we can discard any playlist errors at this point. + seenExpectedPlaylistError = false; + expectedPlaylistUrl = null; + + // Handle encryption. + HlsMediaPlaylist.Segment segment = mediaPlaylist.segments.get(segmentIndexInPlaylist); + + // Check if the segment or its initialization segment are fully encrypted. + Uri initSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment.initializationSegment); + out.chunk = maybeCreateEncryptionChunkFor(initSegmentKeyUri, selectedTrackIndex); + if (out.chunk != null) { + return; + } + Uri mediaSegmentKeyUri = getFullEncryptionKeyUri(mediaPlaylist, segment); + out.chunk = maybeCreateEncryptionChunkFor(mediaSegmentKeyUri, selectedTrackIndex); + if (out.chunk != null) { + return; + } + + out.chunk = + HlsMediaChunk.createInstance( + extractorFactory, + mediaDataSource, + playlistFormats[selectedTrackIndex], + startOfPlaylistInPeriodUs, + mediaPlaylist, + segmentIndexInPlaylist, + selectedPlaylistUrl, + muxedCaptionFormats, + trackSelection.getSelectionReason(), + trackSelection.getSelectionData(), + isTimestampMaster, + timestampAdjusterProvider, + previous, + /* mediaSegmentKey= */ keyCache.get(mediaSegmentKeyUri), + /* initSegmentKey= */ keyCache.get(initSegmentKeyUri)); + } + + /** + * Called when the {@link HlsSampleStreamWrapper} has finished loading a chunk obtained from this + * source. + * + * @param chunk The chunk whose load has been completed. + */ + public void onChunkLoadCompleted(Chunk chunk) { + if (chunk instanceof EncryptionKeyChunk) { + EncryptionKeyChunk encryptionKeyChunk = (EncryptionKeyChunk) chunk; + scratchSpace = encryptionKeyChunk.getDataHolder(); + keyCache.put( + encryptionKeyChunk.dataSpec.uri, Assertions.checkNotNull(encryptionKeyChunk.getResult())); + } + } + + /** + * Attempts to blacklist the track associated with the given chunk. Blacklisting will fail if the + * track is the only non-blacklisted track in the selection. + * + * @param chunk The chunk whose load caused the blacklisting attempt. + * @param blacklistDurationMs The number of milliseconds for which the track selection should be + * blacklisted. + * @return Whether the blacklisting succeeded. + */ + public boolean maybeBlacklistTrack(Chunk chunk, long blacklistDurationMs) { + return trackSelection.blacklist( + trackSelection.indexOf(trackGroup.indexOf(chunk.trackFormat)), blacklistDurationMs); + } + + /** + * Called when a playlist load encounters an error. + * + * @param playlistUrl The {@link Uri} of the playlist whose load encountered an error. + * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or {@link + * C#TIME_UNSET} if the playlist should not be blacklisted. + * @return True if blacklisting did not encounter errors. False otherwise. + */ + public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + int trackGroupIndex = C.INDEX_UNSET; + for (int i = 0; i < playlistUrls.length; i++) { + if (playlistUrls[i].equals(playlistUrl)) { + trackGroupIndex = i; + break; + } + } + if (trackGroupIndex == C.INDEX_UNSET) { + return true; + } + int trackSelectionIndex = trackSelection.indexOf(trackGroupIndex); + if (trackSelectionIndex == C.INDEX_UNSET) { + return true; + } + seenExpectedPlaylistError |= playlistUrl.equals(expectedPlaylistUrl); + return blacklistDurationMs == C.TIME_UNSET + || trackSelection.blacklist(trackSelectionIndex, blacklistDurationMs); + } + + /** + * Returns an array of {@link MediaChunkIterator}s for upcoming media chunks. + * + * @param previous The previous media chunk. May be null. + * @param loadPositionUs The position at which the iterators will start. + * @return Array of {@link MediaChunkIterator}s for each track. + */ + public MediaChunkIterator[] createMediaChunkIterators( + @Nullable HlsMediaChunk previous, long loadPositionUs) { + int oldTrackIndex = previous == null ? C.INDEX_UNSET : trackGroup.indexOf(previous.trackFormat); + MediaChunkIterator[] chunkIterators = new MediaChunkIterator[trackSelection.length()]; + for (int i = 0; i < chunkIterators.length; i++) { + int trackIndex = trackSelection.getIndexInTrackGroup(i); + Uri playlistUrl = playlistUrls[trackIndex]; + if (!playlistTracker.isSnapshotValid(playlistUrl)) { + chunkIterators[i] = MediaChunkIterator.EMPTY; + continue; + } + HlsMediaPlaylist playlist = + playlistTracker.getPlaylistSnapshot(playlistUrl, /* isForPlayback= */ false); + // Playlist snapshot is valid (checked by if() above) so playlist must be non-null. + Assertions.checkNotNull(playlist); + long startOfPlaylistInPeriodUs = + playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + boolean switchingTrack = trackIndex != oldTrackIndex; + long chunkMediaSequence = + getChunkMediaSequence( + previous, switchingTrack, playlist, startOfPlaylistInPeriodUs, loadPositionUs); + if (chunkMediaSequence < playlist.mediaSequence) { + chunkIterators[i] = MediaChunkIterator.EMPTY; + continue; + } + int chunkIndex = (int) (chunkMediaSequence - playlist.mediaSequence); + chunkIterators[i] = + new HlsMediaPlaylistSegmentIterator(playlist, startOfPlaylistInPeriodUs, chunkIndex); + } + return chunkIterators; + } + + // Private methods. + + /** + * Returns the media sequence number of the segment to load next in {@code mediaPlaylist}. + * + * @param previous The last (at least partially) loaded segment. + * @param switchingTrack Whether the segment to load is not preceded by a segment in the same + * track. + * @param mediaPlaylist The media playlist to which the segment to load belongs. + * @param startOfPlaylistInPeriodUs The start of {@code mediaPlaylist} relative to the period + * start in microseconds. + * @param loadPositionUs The current load position relative to the period start in microseconds. + * @return The media sequence of the segment to load. + */ + private long getChunkMediaSequence( + @Nullable HlsMediaChunk previous, + boolean switchingTrack, + HlsMediaPlaylist mediaPlaylist, + long startOfPlaylistInPeriodUs, + long loadPositionUs) { + if (previous == null || switchingTrack) { + long endOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs + mediaPlaylist.durationUs; + long targetPositionInPeriodUs = + (previous == null || independentSegments) ? loadPositionUs : previous.startTimeUs; + if (!mediaPlaylist.hasEndTag && targetPositionInPeriodUs >= endOfPlaylistInPeriodUs) { + // If the playlist is too old to contain the chunk, we need to refresh it. + return mediaPlaylist.mediaSequence + mediaPlaylist.segments.size(); + } + long targetPositionInPlaylistUs = targetPositionInPeriodUs - startOfPlaylistInPeriodUs; + return Util.binarySearchFloor( + mediaPlaylist.segments, + /* value= */ targetPositionInPlaylistUs, + /* inclusive= */ true, + /* stayInBounds= */ !playlistTracker.isLive() || previous == null) + + mediaPlaylist.mediaSequence; + } + // We ignore the case of previous not having loaded completely, in which case we load the next + // segment. + return previous.getNextChunkIndex(); + } + + private long resolveTimeToLiveEdgeUs(long playbackPositionUs) { + final boolean resolveTimeToLiveEdgePossible = liveEdgeInPeriodTimeUs != C.TIME_UNSET; + return resolveTimeToLiveEdgePossible + ? liveEdgeInPeriodTimeUs - playbackPositionUs + : C.TIME_UNSET; + } + + private void updateLiveEdgeTimeUs(HlsMediaPlaylist mediaPlaylist) { + liveEdgeInPeriodTimeUs = + mediaPlaylist.hasEndTag + ? C.TIME_UNSET + : (mediaPlaylist.getEndTimeUs() - playlistTracker.getInitialStartTimeUs()); + } + + @Nullable + private Chunk maybeCreateEncryptionChunkFor(@Nullable Uri keyUri, int selectedTrackIndex) { + if (keyUri == null) { + return null; + } + + byte[] encryptionKey = keyCache.remove(keyUri); + if (encryptionKey != null) { + // The key was present in the key cache. We re-insert it to prevent it from being evicted by + // the following key addition. Note that removal of the key is necessary to affect the + // eviction order. + keyCache.put(keyUri, encryptionKey); + return null; + } + DataSpec dataSpec = new DataSpec(keyUri, 0, C.LENGTH_UNSET, null, DataSpec.FLAG_ALLOW_GZIP); + return new EncryptionKeyChunk( + encryptionDataSource, + dataSpec, + playlistFormats[selectedTrackIndex], + trackSelection.getSelectionReason(), + trackSelection.getSelectionData(), + scratchSpace); + } + + @Nullable + private static Uri getFullEncryptionKeyUri(HlsMediaPlaylist playlist, @Nullable Segment segment) { + if (segment == null || segment.fullSegmentEncryptionKeyUri == null) { + return null; + } + return UriUtil.resolveToUri(playlist.baseUri, segment.fullSegmentEncryptionKeyUri); + } + + // Private classes. + + /** + * A {@link TrackSelection} to use for initialization. + */ + private static final class InitializationTrackSelection extends BaseTrackSelection { + + private int selectedIndex; + + public InitializationTrackSelection(TrackGroup group, int[] tracks) { + super(group, tracks); + selectedIndex = indexOf(group.getFormat(0)); + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + long nowMs = SystemClock.elapsedRealtime(); + if (!isBlacklisted(selectedIndex, nowMs)) { + return; + } + // Try from lowest bitrate to highest. + for (int i = length - 1; i >= 0; i--) { + if (!isBlacklisted(i, nowMs)) { + selectedIndex = i; + return; + } + } + // Should never happen. + throw new IllegalStateException(); + } + + @Override + public int getSelectedIndex() { + return selectedIndex; + } + + @Override + public int getSelectionReason() { + return C.SELECTION_REASON_UNKNOWN; + } + + @Override + @Nullable + public Object getSelectionData() { + return null; + } + + } + + private static final class EncryptionKeyChunk extends DataChunk { + + private byte @MonotonicNonNull [] result; + + public EncryptionKeyChunk( + DataSource dataSource, + DataSpec dataSpec, + Format trackFormat, + int trackSelectionReason, + @Nullable Object trackSelectionData, + byte[] scratchSpace) { + super(dataSource, dataSpec, C.DATA_TYPE_DRM, trackFormat, trackSelectionReason, + trackSelectionData, scratchSpace); + } + + @Override + protected void consume(byte[] data, int limit) { + result = Arrays.copyOf(data, limit); + } + + /** Return the result of this chunk, or null if loading is not complete. */ + @Nullable + public byte[] getResult() { + return result; + } + + } + + /** {@link MediaChunkIterator} wrapping a {@link HlsMediaPlaylist}. */ + private static final class HlsMediaPlaylistSegmentIterator extends BaseMediaChunkIterator { + + private final HlsMediaPlaylist playlist; + private final long startOfPlaylistInPeriodUs; + + /** + * Creates iterator. + * + * @param playlist The {@link HlsMediaPlaylist} to wrap. + * @param startOfPlaylistInPeriodUs The start time of the playlist in the period, in + * microseconds. + * @param chunkIndex The index of the first available chunk in the playlist. + */ + public HlsMediaPlaylistSegmentIterator( + HlsMediaPlaylist playlist, long startOfPlaylistInPeriodUs, int chunkIndex) { + super(/* fromIndex= */ chunkIndex, /* toIndex= */ playlist.segments.size() - 1); + this.playlist = playlist; + this.startOfPlaylistInPeriodUs = startOfPlaylistInPeriodUs; + } + + @Override + public DataSpec getDataSpec() { + checkInBounds(); + Segment segment = playlist.segments.get((int) getCurrentIndex()); + Uri chunkUri = UriUtil.resolveToUri(playlist.baseUri, segment.url); + return new DataSpec( + chunkUri, segment.byterangeOffset, segment.byterangeLength, /* key= */ null); + } + + @Override + public long getChunkStartTimeUs() { + checkInBounds(); + Segment segment = playlist.segments.get((int) getCurrentIndex()); + return startOfPlaylistInPeriodUs + segment.relativeStartTimeUs; + } + + @Override + public long getChunkEndTimeUs() { + checkInBounds(); + Segment segment = playlist.segments.get((int) getCurrentIndex()); + long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + segment.relativeStartTimeUs; + return segmentStartTimeInPeriodUs + segment.durationUs; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java new file mode 100644 index 0000000000..66fac54b8d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsDataSourceFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; + +/** + * Creates {@link DataSource}s for HLS playlists, encryption and media chunks. + */ +public interface HlsDataSourceFactory { + + /** + * Creates a {@link DataSource} for the given data type. + * + * @param dataType The data type for which the {@link DataSource} will be used. One of {@link C} + * {@code .DATA_TYPE_*} constants. + * @return A {@link DataSource} for the given data type. + */ + DataSource createDataSource(int dataType); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java new file mode 100644 index 0000000000..8f445f97ed --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsExtractorFactory.java @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Factory for HLS media chunk extractors. + */ +public interface HlsExtractorFactory { + + /** Holds an {@link Extractor} and associated parameters. */ + final class Result { + + /** The created extractor; */ + public final Extractor extractor; + /** Whether the segments for which {@link #extractor} is created are packed audio segments. */ + public final boolean isPackedAudioExtractor; + /** + * Whether {@link #extractor} may be reused for following continuous (no immediately preceding + * discontinuities) segments of the same variant. + */ + public final boolean isReusable; + + /** + * Creates a result. + * + * @param extractor See {@link #extractor}. + * @param isPackedAudioExtractor See {@link #isPackedAudioExtractor}. + * @param isReusable See {@link #isReusable}. + */ + public Result(Extractor extractor, boolean isPackedAudioExtractor, boolean isReusable) { + this.extractor = extractor; + this.isPackedAudioExtractor = isPackedAudioExtractor; + this.isReusable = isReusable; + } + } + + HlsExtractorFactory DEFAULT = new DefaultHlsExtractorFactory(); + + /** + * Creates an {@link Extractor} for extracting HLS media chunks. + * + * @param previousExtractor A previously used {@link Extractor} which can be reused if the current + * chunk is a continuation of the previously extracted chunk, or null otherwise. It is the + * responsibility of implementers to only reuse extractors that are suited for reusage. + * @param uri The URI of the media chunk. + * @param format A {@link Format} associated with the chunk to extract. + * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption + * information is available in the master playlist. + * @param timestampAdjuster Adjuster corresponding to the provided discontinuity sequence number. + * @param responseHeaders The HTTP response headers associated with the media segment or + * initialization section to extract. + * @param sniffingExtractorInput The first extractor input that will be passed to the returned + * extractor's {@link Extractor#read(ExtractorInput, PositionHolder)}. Must only be used to + * call {@link Extractor#sniff(ExtractorInput)}. + * @return A {@link Result}. + * @throws InterruptedException If the thread is interrupted while sniffing. + * @throws IOException If an I/O error is encountered while sniffing. + */ + Result createExtractor( + @Nullable Extractor previousExtractor, + Uri uri, + Format format, + @Nullable List<Format> muxedCaptionFormats, + TimestampAdjuster timestampAdjuster, + Map<String, List<String>> responseHeaders, + ExtractorInput sniffingExtractorInput) + throws InterruptedException, IOException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java new file mode 100644 index 0000000000..52a5632134 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsManifest.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; + +/** + * Holds a master playlist along with a snapshot of one of its media playlists. + */ +public final class HlsManifest { + + /** + * The master playlist of an HLS stream. + */ + public final HlsMasterPlaylist masterPlaylist; + /** + * A snapshot of a media playlist referred to by {@link #masterPlaylist}. + */ + public final HlsMediaPlaylist mediaPlaylist; + + /** + * @param masterPlaylist The master playlist. + * @param mediaPlaylist The media playlist. + */ + HlsManifest(HlsMasterPlaylist masterPlaylist, HlsMediaPlaylist mediaPlaylist) { + this.masterPlaylist = masterPlaylist; + this.mediaPlaylist = mediaPlaylist; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java new file mode 100644 index 0000000000..173e53faad --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaChunk.java @@ -0,0 +1,519 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DefaultExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.Id3Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.PrivFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.math.BigInteger; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * An HLS {@link MediaChunk}. + */ +/* package */ final class HlsMediaChunk extends MediaChunk { + + /** + * Creates a new instance. + * + * @param extractorFactory A {@link HlsExtractorFactory} from which the HLS media chunk extractor + * is obtained. + * @param dataSource The source from which the data should be loaded. + * @param format The chunk format. + * @param startOfPlaylistInPeriodUs The position of the playlist in the period in microseconds. + * @param mediaPlaylist The media playlist from which this chunk was obtained. + * @param playlistUrl The url of the playlist from which this chunk was obtained. + * @param muxedCaptionFormats List of muxed caption {@link Format}s. Null if no closed caption + * information is available in the master playlist. + * @param trackSelectionReason See {@link #trackSelectionReason}. + * @param trackSelectionData See {@link #trackSelectionData}. + * @param isMasterTimestampSource True if the chunk can initialize the timestamp adjuster. + * @param timestampAdjusterProvider The provider from which to obtain the {@link + * TimestampAdjuster}. + * @param previousChunk The {@link HlsMediaChunk} that preceded this one. May be null. + * @param mediaSegmentKey The media segment decryption key, if fully encrypted. Null otherwise. + * @param initSegmentKey The initialization segment decryption key, if fully encrypted. Null + * otherwise. + */ + public static HlsMediaChunk createInstance( + HlsExtractorFactory extractorFactory, + DataSource dataSource, + Format format, + long startOfPlaylistInPeriodUs, + HlsMediaPlaylist mediaPlaylist, + int segmentIndexInPlaylist, + Uri playlistUrl, + @Nullable List<Format> muxedCaptionFormats, + int trackSelectionReason, + @Nullable Object trackSelectionData, + boolean isMasterTimestampSource, + TimestampAdjusterProvider timestampAdjusterProvider, + @Nullable HlsMediaChunk previousChunk, + @Nullable byte[] mediaSegmentKey, + @Nullable byte[] initSegmentKey) { + // Media segment. + HlsMediaPlaylist.Segment mediaSegment = mediaPlaylist.segments.get(segmentIndexInPlaylist); + DataSpec dataSpec = + new DataSpec( + UriUtil.resolveToUri(mediaPlaylist.baseUri, mediaSegment.url), + mediaSegment.byterangeOffset, + mediaSegment.byterangeLength, + /* key= */ null); + boolean mediaSegmentEncrypted = mediaSegmentKey != null; + byte[] mediaSegmentIv = + mediaSegmentEncrypted + ? getEncryptionIvArray(Assertions.checkNotNull(mediaSegment.encryptionIV)) + : null; + DataSource mediaDataSource = buildDataSource(dataSource, mediaSegmentKey, mediaSegmentIv); + + // Init segment. + HlsMediaPlaylist.Segment initSegment = mediaSegment.initializationSegment; + DataSpec initDataSpec = null; + boolean initSegmentEncrypted = false; + DataSource initDataSource = null; + if (initSegment != null) { + initSegmentEncrypted = initSegmentKey != null; + byte[] initSegmentIv = + initSegmentEncrypted + ? getEncryptionIvArray(Assertions.checkNotNull(initSegment.encryptionIV)) + : null; + Uri initSegmentUri = UriUtil.resolveToUri(mediaPlaylist.baseUri, initSegment.url); + initDataSpec = + new DataSpec( + initSegmentUri, + initSegment.byterangeOffset, + initSegment.byterangeLength, + /* key= */ null); + initDataSource = buildDataSource(dataSource, initSegmentKey, initSegmentIv); + } + + long segmentStartTimeInPeriodUs = startOfPlaylistInPeriodUs + mediaSegment.relativeStartTimeUs; + long segmentEndTimeInPeriodUs = segmentStartTimeInPeriodUs + mediaSegment.durationUs; + int discontinuitySequenceNumber = + mediaPlaylist.discontinuitySequence + mediaSegment.relativeDiscontinuitySequence; + + Extractor previousExtractor = null; + Id3Decoder id3Decoder; + ParsableByteArray scratchId3Data; + boolean shouldSpliceIn; + if (previousChunk != null) { + id3Decoder = previousChunk.id3Decoder; + scratchId3Data = previousChunk.scratchId3Data; + shouldSpliceIn = + !playlistUrl.equals(previousChunk.playlistUrl) || !previousChunk.loadCompleted; + previousExtractor = + previousChunk.isExtractorReusable + && previousChunk.discontinuitySequenceNumber == discontinuitySequenceNumber + && !shouldSpliceIn + ? previousChunk.extractor + : null; + } else { + id3Decoder = new Id3Decoder(); + scratchId3Data = new ParsableByteArray(Id3Decoder.ID3_HEADER_LENGTH); + shouldSpliceIn = false; + } + + return new HlsMediaChunk( + extractorFactory, + mediaDataSource, + dataSpec, + format, + mediaSegmentEncrypted, + initDataSource, + initDataSpec, + initSegmentEncrypted, + playlistUrl, + muxedCaptionFormats, + trackSelectionReason, + trackSelectionData, + segmentStartTimeInPeriodUs, + segmentEndTimeInPeriodUs, + /* chunkMediaSequence= */ mediaPlaylist.mediaSequence + segmentIndexInPlaylist, + discontinuitySequenceNumber, + mediaSegment.hasGapTag, + isMasterTimestampSource, + /* timestampAdjuster= */ timestampAdjusterProvider.getAdjuster(discontinuitySequenceNumber), + mediaSegment.drmInitData, + previousExtractor, + id3Decoder, + scratchId3Data, + shouldSpliceIn); + } + + public static final String PRIV_TIMESTAMP_FRAME_OWNER = + "com.apple.streaming.transportStreamTimestamp"; + private static final PositionHolder DUMMY_POSITION_HOLDER = new PositionHolder(); + + private static final AtomicInteger uidSource = new AtomicInteger(); + + /** + * A unique identifier for the chunk. + */ + public final int uid; + + /** + * The discontinuity sequence number of the chunk. + */ + public final int discontinuitySequenceNumber; + + /** The url of the playlist from which this chunk was obtained. */ + public final Uri playlistUrl; + + @Nullable private final DataSource initDataSource; + @Nullable private final DataSpec initDataSpec; + @Nullable private final Extractor previousExtractor; + + private final boolean isMasterTimestampSource; + private final boolean hasGapTag; + private final TimestampAdjuster timestampAdjuster; + private final boolean shouldSpliceIn; + private final HlsExtractorFactory extractorFactory; + @Nullable private final List<Format> muxedCaptionFormats; + @Nullable private final DrmInitData drmInitData; + private final Id3Decoder id3Decoder; + private final ParsableByteArray scratchId3Data; + private final boolean mediaSegmentEncrypted; + private final boolean initSegmentEncrypted; + + @MonotonicNonNull private Extractor extractor; + private boolean isExtractorReusable; + @MonotonicNonNull private HlsSampleStreamWrapper output; + // nextLoadPosition refers to the init segment if initDataLoadRequired is true. + // Otherwise, nextLoadPosition refers to the media segment. + private int nextLoadPosition; + private boolean initDataLoadRequired; + private volatile boolean loadCanceled; + private boolean loadCompleted; + + private HlsMediaChunk( + HlsExtractorFactory extractorFactory, + DataSource mediaDataSource, + DataSpec dataSpec, + Format format, + boolean mediaSegmentEncrypted, + @Nullable DataSource initDataSource, + @Nullable DataSpec initDataSpec, + boolean initSegmentEncrypted, + Uri playlistUrl, + @Nullable List<Format> muxedCaptionFormats, + int trackSelectionReason, + @Nullable Object trackSelectionData, + long startTimeUs, + long endTimeUs, + long chunkMediaSequence, + int discontinuitySequenceNumber, + boolean hasGapTag, + boolean isMasterTimestampSource, + TimestampAdjuster timestampAdjuster, + @Nullable DrmInitData drmInitData, + @Nullable Extractor previousExtractor, + Id3Decoder id3Decoder, + ParsableByteArray scratchId3Data, + boolean shouldSpliceIn) { + super( + mediaDataSource, + dataSpec, + format, + trackSelectionReason, + trackSelectionData, + startTimeUs, + endTimeUs, + chunkMediaSequence); + this.mediaSegmentEncrypted = mediaSegmentEncrypted; + this.discontinuitySequenceNumber = discontinuitySequenceNumber; + this.initDataSpec = initDataSpec; + this.initDataSource = initDataSource; + this.initDataLoadRequired = initDataSpec != null; + this.initSegmentEncrypted = initSegmentEncrypted; + this.playlistUrl = playlistUrl; + this.isMasterTimestampSource = isMasterTimestampSource; + this.timestampAdjuster = timestampAdjuster; + this.hasGapTag = hasGapTag; + this.extractorFactory = extractorFactory; + this.muxedCaptionFormats = muxedCaptionFormats; + this.drmInitData = drmInitData; + this.previousExtractor = previousExtractor; + this.id3Decoder = id3Decoder; + this.scratchId3Data = scratchId3Data; + this.shouldSpliceIn = shouldSpliceIn; + uid = uidSource.getAndIncrement(); + } + + /** + * Initializes the chunk for loading, setting the {@link HlsSampleStreamWrapper} that will receive + * samples as they are loaded. + * + * @param output The output that will receive the loaded samples. + */ + public void init(HlsSampleStreamWrapper output) { + this.output = output; + output.init(uid, shouldSpliceIn); + } + + @Override + public boolean isLoadCompleted() { + return loadCompleted; + } + + // Loadable implementation + + @Override + public void cancelLoad() { + loadCanceled = true; + } + + @Override + public void load() throws IOException, InterruptedException { + // output == null means init() hasn't been called. + Assertions.checkNotNull(output); + if (extractor == null && previousExtractor != null) { + extractor = previousExtractor; + isExtractorReusable = true; + initDataLoadRequired = false; + } + maybeLoadInitData(); + if (!loadCanceled) { + if (!hasGapTag) { + loadMedia(); + } + loadCompleted = true; + } + } + + // Internal methods. + + @RequiresNonNull("output") + private void maybeLoadInitData() throws IOException, InterruptedException { + if (!initDataLoadRequired) { + return; + } + // initDataLoadRequired => initDataSource != null && initDataSpec != null + Assertions.checkNotNull(initDataSource); + Assertions.checkNotNull(initDataSpec); + feedDataToExtractor(initDataSource, initDataSpec, initSegmentEncrypted); + nextLoadPosition = 0; + initDataLoadRequired = false; + } + + @RequiresNonNull("output") + private void loadMedia() throws IOException, InterruptedException { + if (!isMasterTimestampSource) { + timestampAdjuster.waitUntilInitialized(); + } else if (timestampAdjuster.getFirstSampleTimestampUs() == TimestampAdjuster.DO_NOT_OFFSET) { + // We're the master and we haven't set the desired first sample timestamp yet. + timestampAdjuster.setFirstSampleTimestampUs(startTimeUs); + } + feedDataToExtractor(dataSource, dataSpec, mediaSegmentEncrypted); + } + + /** + * Attempts to feed the given {@code dataSpec} to {@code this.extractor}. Whenever the operation + * concludes (because of a thrown exception or because the operation finishes), the number of fed + * bytes is written to {@code nextLoadPosition}. + */ + @RequiresNonNull("output") + private void feedDataToExtractor( + DataSource dataSource, DataSpec dataSpec, boolean dataIsEncrypted) + throws IOException, InterruptedException { + // If we previously fed part of this chunk to the extractor, we need to skip it this time. For + // encrypted content we need to skip the data by reading it through the source, so as to ensure + // correct decryption of the remainder of the chunk. For clear content, we can request the + // remainder of the chunk directly. + DataSpec loadDataSpec; + boolean skipLoadedBytes; + if (dataIsEncrypted) { + loadDataSpec = dataSpec; + skipLoadedBytes = nextLoadPosition != 0; + } else { + loadDataSpec = dataSpec.subrange(nextLoadPosition); + skipLoadedBytes = false; + } + try { + ExtractorInput input = prepareExtraction(dataSource, loadDataSpec); + if (skipLoadedBytes) { + input.skipFully(nextLoadPosition); + } + try { + int result = Extractor.RESULT_CONTINUE; + while (result == Extractor.RESULT_CONTINUE && !loadCanceled) { + result = extractor.read(input, DUMMY_POSITION_HOLDER); + } + } finally { + nextLoadPosition = (int) (input.getPosition() - dataSpec.absoluteStreamPosition); + } + } finally { + Util.closeQuietly(dataSource); + } + } + + @RequiresNonNull("output") + @EnsuresNonNull("extractor") + private DefaultExtractorInput prepareExtraction(DataSource dataSource, DataSpec dataSpec) + throws IOException, InterruptedException { + long bytesToRead = dataSource.open(dataSpec); + DefaultExtractorInput extractorInput = + new DefaultExtractorInput(dataSource, dataSpec.absoluteStreamPosition, bytesToRead); + + if (extractor == null) { + long id3Timestamp = peekId3PrivTimestamp(extractorInput); + extractorInput.resetPeekPosition(); + + HlsExtractorFactory.Result result = + extractorFactory.createExtractor( + previousExtractor, + dataSpec.uri, + trackFormat, + muxedCaptionFormats, + timestampAdjuster, + dataSource.getResponseHeaders(), + extractorInput); + extractor = result.extractor; + isExtractorReusable = result.isReusable; + if (result.isPackedAudioExtractor) { + output.setSampleOffsetUs( + id3Timestamp != C.TIME_UNSET + ? timestampAdjuster.adjustTsTimestamp(id3Timestamp) + : startTimeUs); + } else { + // In case the container format changes mid-stream to non-packed-audio, we need to reset + // the timestamp offset. + output.setSampleOffsetUs(/* sampleOffsetUs= */ 0L); + } + output.onNewExtractor(); + extractor.init(output); + } + output.setDrmInitData(drmInitData); + return extractorInput; + } + + /** + * Peek the presentation timestamp of the first sample in the chunk from an ID3 PRIV as defined + * in the HLS spec, version 20, Section 3.4. Returns {@link C#TIME_UNSET} if the frame is not + * found. This method only modifies the peek position. + * + * @param input The {@link ExtractorInput} to obtain the PRIV frame from. + * @return The parsed, adjusted timestamp in microseconds + * @throws IOException If an error occurred peeking from the input. + * @throws InterruptedException If the thread was interrupted. + */ + private long peekId3PrivTimestamp(ExtractorInput input) throws IOException, InterruptedException { + input.resetPeekPosition(); + try { + input.peekFully(scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } catch (EOFException e) { + // The input isn't long enough for there to be any ID3 data. + return C.TIME_UNSET; + } + scratchId3Data.reset(Id3Decoder.ID3_HEADER_LENGTH); + int id = scratchId3Data.readUnsignedInt24(); + if (id != Id3Decoder.ID3_TAG) { + return C.TIME_UNSET; + } + scratchId3Data.skipBytes(3); // version(2), flags(1). + int id3Size = scratchId3Data.readSynchSafeInt(); + int requiredCapacity = id3Size + Id3Decoder.ID3_HEADER_LENGTH; + if (requiredCapacity > scratchId3Data.capacity()) { + byte[] data = scratchId3Data.data; + scratchId3Data.reset(requiredCapacity); + System.arraycopy(data, 0, scratchId3Data.data, 0, Id3Decoder.ID3_HEADER_LENGTH); + } + input.peekFully(scratchId3Data.data, Id3Decoder.ID3_HEADER_LENGTH, id3Size); + Metadata metadata = id3Decoder.decode(scratchId3Data.data, id3Size); + if (metadata == null) { + return C.TIME_UNSET; + } + int metadataLength = metadata.length(); + for (int i = 0; i < metadataLength; i++) { + Metadata.Entry frame = metadata.get(i); + if (frame instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) frame; + if (PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) { + System.arraycopy( + privFrame.privateData, 0, scratchId3Data.data, 0, 8 /* timestamp size */); + scratchId3Data.reset(8); + // The top 31 bits should be zeros, but explicitly zero them to wrap in the case that the + // streaming provider forgot. See: https://github.com/google/ExoPlayer/pull/3495. + return scratchId3Data.readLong() & 0x1FFFFFFFFL; + } + } + } + return C.TIME_UNSET; + } + + // Internal methods. + + private static byte[] getEncryptionIvArray(String ivString) { + String trimmedIv; + if (Util.toLowerInvariant(ivString).startsWith("0x")) { + trimmedIv = ivString.substring(2); + } else { + trimmedIv = ivString; + } + + byte[] ivData = new BigInteger(trimmedIv, /* radix= */ 16).toByteArray(); + byte[] ivDataWithPadding = new byte[16]; + int offset = ivData.length > 16 ? ivData.length - 16 : 0; + System.arraycopy( + ivData, + offset, + ivDataWithPadding, + ivDataWithPadding.length - ivData.length + offset, + ivData.length - offset); + return ivDataWithPadding; + } + + /** + * If the segment is fully encrypted, returns an {@link Aes128DataSource} that wraps the original + * in order to decrypt the loaded data. Else returns the original. + * + * <p>{@code fullSegmentEncryptionKey} & {@code encryptionIv} can either both be null, or neither. + */ + private static DataSource buildDataSource( + DataSource dataSource, + @Nullable byte[] fullSegmentEncryptionKey, + @Nullable byte[] encryptionIv) { + if (fullSegmentEncryptionKey != null) { + Assertions.checkNotNull(encryptionIv); + return new Aes128DataSource(dataSource, fullSegmentEncryptionKey, encryptionIv); + } + return dataSource; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java new file mode 100644 index 0000000000..60aa5298c3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -0,0 +1,858 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +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.playlist.HlsMasterPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link MediaPeriod} that loads an HLS stream. + */ +public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper.Callback, + HlsPlaylistTracker.PlaylistEventListener { + + private final HlsExtractorFactory extractorFactory; + private final HlsPlaylistTracker playlistTracker; + private final HlsDataSourceFactory dataSourceFactory; + @Nullable private final TransferListener mediaTransferListener; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final EventDispatcher eventDispatcher; + private final Allocator allocator; + private final IdentityHashMap<SampleStream, Integer> streamWrapperIndices; + private final TimestampAdjusterProvider timestampAdjusterProvider; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private final boolean allowChunklessPreparation; + private final @HlsMediaSource.MetadataType int metadataType; + private final boolean useSessionKeys; + + @Nullable private Callback callback; + private int pendingPrepareCount; + private @MonotonicNonNull TrackGroupArray trackGroups; + private HlsSampleStreamWrapper[] sampleStreamWrappers; + private HlsSampleStreamWrapper[] enabledSampleStreamWrappers; + // Maps sample stream wrappers to variant/rendition index by matching array positions. + private int[][] manifestUrlIndicesPerWrapper; + private SequenceableLoader compositeSequenceableLoader; + private boolean notifiedReadingStarted; + + /** + * Creates an HLS media period. + * + * @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the segments. + * @param playlistTracker A tracker for HLS playlists. + * @param dataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for segments + * and keys. + * @param mediaTransferListener The transfer listener to inform of any media data transfers. May + * be null if no listener is available. + * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession + * DrmSessions} with. + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param compositeSequenceableLoaderFactory A factory to create composite {@link + * SequenceableLoader}s for when this media source loads data from multiple streams. + * @param allowChunklessPreparation Whether chunkless preparation is allowed. + * @param useSessionKeys Whether to use #EXT-X-SESSION-KEY tags. + */ + public HlsMediaPeriod( + HlsExtractorFactory extractorFactory, + HlsPlaylistTracker playlistTracker, + HlsDataSourceFactory dataSourceFactory, + @Nullable TransferListener mediaTransferListener, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + Allocator allocator, + CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + boolean allowChunklessPreparation, + @HlsMediaSource.MetadataType int metadataType, + boolean useSessionKeys) { + this.extractorFactory = extractorFactory; + this.playlistTracker = playlistTracker; + this.dataSourceFactory = dataSourceFactory; + this.mediaTransferListener = mediaTransferListener; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.allocator = allocator; + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.allowChunklessPreparation = allowChunklessPreparation; + this.metadataType = metadataType; + this.useSessionKeys = useSessionKeys; + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader(); + streamWrapperIndices = new IdentityHashMap<>(); + timestampAdjusterProvider = new TimestampAdjusterProvider(); + sampleStreamWrappers = new HlsSampleStreamWrapper[0]; + enabledSampleStreamWrappers = new HlsSampleStreamWrapper[0]; + manifestUrlIndicesPerWrapper = new int[0][]; + eventDispatcher.mediaPeriodCreated(); + } + + public void release() { + playlistTracker.removeListener(this); + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + sampleStreamWrapper.release(); + } + callback = null; + eventDispatcher.mediaPeriodReleased(); + } + + @Override + public void prepare(Callback callback, long positionUs) { + this.callback = callback; + playlistTracker.addListener(this); + buildAndPrepareSampleStreamWrappers(positionUs); + } + + @Override + public void maybeThrowPrepareError() throws IOException { + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + sampleStreamWrapper.maybeThrowPrepareError(); + } + } + + @Override + public TrackGroupArray getTrackGroups() { + // trackGroups will only be null if period hasn't been prepared or has been released. + return Assertions.checkNotNull(trackGroups); + } + + // TODO: When the master playlist does not de-duplicate variants by URL and allows Renditions with + // null URLs, this method must be updated to calculate stream keys that are compatible with those + // that may already be persisted for offline. + @Override + public List<StreamKey> getStreamKeys(List<TrackSelection> trackSelections) { + // See HlsMasterPlaylist.copy for interpretation of StreamKeys. + HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist()); + boolean hasVariants = !masterPlaylist.variants.isEmpty(); + int audioWrapperOffset = hasVariants ? 1 : 0; + // Subtitle sample stream wrappers are held last. + int subtitleWrapperOffset = sampleStreamWrappers.length - masterPlaylist.subtitles.size(); + + TrackGroupArray mainWrapperTrackGroups; + int mainWrapperPrimaryGroupIndex; + int[] mainWrapperVariantIndices; + if (hasVariants) { + HlsSampleStreamWrapper mainWrapper = sampleStreamWrappers[0]; + mainWrapperVariantIndices = manifestUrlIndicesPerWrapper[0]; + mainWrapperTrackGroups = mainWrapper.getTrackGroups(); + mainWrapperPrimaryGroupIndex = mainWrapper.getPrimaryTrackGroupIndex(); + } else { + mainWrapperVariantIndices = new int[0]; + mainWrapperTrackGroups = TrackGroupArray.EMPTY; + mainWrapperPrimaryGroupIndex = 0; + } + + List<StreamKey> streamKeys = new ArrayList<>(); + boolean needsPrimaryTrackGroupSelection = false; + boolean hasPrimaryTrackGroupSelection = false; + for (TrackSelection trackSelection : trackSelections) { + TrackGroup trackSelectionGroup = trackSelection.getTrackGroup(); + int mainWrapperTrackGroupIndex = mainWrapperTrackGroups.indexOf(trackSelectionGroup); + if (mainWrapperTrackGroupIndex != C.INDEX_UNSET) { + if (mainWrapperTrackGroupIndex == mainWrapperPrimaryGroupIndex) { + // Primary group in main wrapper. + hasPrimaryTrackGroupSelection = true; + for (int i = 0; i < trackSelection.length(); i++) { + int variantIndex = mainWrapperVariantIndices[trackSelection.getIndexInTrackGroup(i)]; + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, variantIndex)); + } + } else { + // Embedded group in main wrapper. + needsPrimaryTrackGroupSelection = true; + } + } else { + // Audio or subtitle group. + for (int i = audioWrapperOffset; i < sampleStreamWrappers.length; i++) { + TrackGroupArray wrapperTrackGroups = sampleStreamWrappers[i].getTrackGroups(); + int selectedTrackGroupIndex = wrapperTrackGroups.indexOf(trackSelectionGroup); + if (selectedTrackGroupIndex != C.INDEX_UNSET) { + int groupIndexType = + i < subtitleWrapperOffset + ? HlsMasterPlaylist.GROUP_INDEX_AUDIO + : HlsMasterPlaylist.GROUP_INDEX_SUBTITLE; + int[] selectedWrapperUrlIndices = manifestUrlIndicesPerWrapper[i]; + for (int trackIndex = 0; trackIndex < trackSelection.length(); trackIndex++) { + int renditionIndex = + selectedWrapperUrlIndices[trackSelection.getIndexInTrackGroup(trackIndex)]; + streamKeys.add(new StreamKey(groupIndexType, renditionIndex)); + } + break; + } + } + } + } + if (needsPrimaryTrackGroupSelection && !hasPrimaryTrackGroupSelection) { + // A track selection includes a variant-embedded track, but no variant is added yet. We use + // the valid variant with the lowest bitrate to reduce overhead. + int lowestBitrateIndex = mainWrapperVariantIndices[0]; + int lowestBitrate = masterPlaylist.variants.get(mainWrapperVariantIndices[0]).format.bitrate; + for (int i = 1; i < mainWrapperVariantIndices.length; i++) { + int variantBitrate = + masterPlaylist.variants.get(mainWrapperVariantIndices[i]).format.bitrate; + if (variantBitrate < lowestBitrate) { + lowestBitrate = variantBitrate; + lowestBitrateIndex = mainWrapperVariantIndices[i]; + } + } + streamKeys.add(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, lowestBitrateIndex)); + } + return streamKeys; + } + + @Override + public long selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs) { + // Map each selection and stream onto a child period index. + int[] streamChildIndices = new int[selections.length]; + int[] selectionChildIndices = new int[selections.length]; + for (int i = 0; i < selections.length; i++) { + streamChildIndices[i] = streams[i] == null ? C.INDEX_UNSET + : streamWrapperIndices.get(streams[i]); + selectionChildIndices[i] = C.INDEX_UNSET; + if (selections[i] != null) { + TrackGroup trackGroup = selections[i].getTrackGroup(); + for (int j = 0; j < sampleStreamWrappers.length; j++) { + if (sampleStreamWrappers[j].getTrackGroups().indexOf(trackGroup) != C.INDEX_UNSET) { + selectionChildIndices[i] = j; + break; + } + } + } + } + + boolean forceReset = false; + streamWrapperIndices.clear(); + // Select tracks for each child, copying the resulting streams back into a new streams array. + SampleStream[] newStreams = new SampleStream[selections.length]; + @NullableType SampleStream[] childStreams = new SampleStream[selections.length]; + @NullableType TrackSelection[] childSelections = new TrackSelection[selections.length]; + int newEnabledSampleStreamWrapperCount = 0; + HlsSampleStreamWrapper[] newEnabledSampleStreamWrappers = + new HlsSampleStreamWrapper[sampleStreamWrappers.length]; + for (int i = 0; i < sampleStreamWrappers.length; i++) { + for (int j = 0; j < selections.length; j++) { + childStreams[j] = streamChildIndices[j] == i ? streams[j] : null; + childSelections[j] = selectionChildIndices[j] == i ? selections[j] : null; + } + HlsSampleStreamWrapper sampleStreamWrapper = sampleStreamWrappers[i]; + boolean wasReset = sampleStreamWrapper.selectTracks(childSelections, mayRetainStreamFlags, + childStreams, streamResetFlags, positionUs, forceReset); + boolean wrapperEnabled = false; + for (int j = 0; j < selections.length; j++) { + SampleStream childStream = childStreams[j]; + if (selectionChildIndices[j] == i) { + // Assert that the child provided a stream for the selection. + Assertions.checkNotNull(childStream); + newStreams[j] = childStream; + wrapperEnabled = true; + streamWrapperIndices.put(childStream, i); + } else if (streamChildIndices[j] == i) { + // Assert that the child cleared any previous stream. + Assertions.checkState(childStream == null); + } + } + if (wrapperEnabled) { + newEnabledSampleStreamWrappers[newEnabledSampleStreamWrapperCount] = sampleStreamWrapper; + if (newEnabledSampleStreamWrapperCount++ == 0) { + // The first enabled wrapper is responsible for initializing timestamp adjusters. This + // way, if enabled, variants are responsible. Else audio renditions. Else text renditions. + sampleStreamWrapper.setIsTimestampMaster(true); + if (wasReset || enabledSampleStreamWrappers.length == 0 + || sampleStreamWrapper != enabledSampleStreamWrappers[0]) { + // The wrapper responsible for initializing the timestamp adjusters was reset or + // changed. We need to reset the timestamp adjuster provider and all other wrappers. + timestampAdjusterProvider.reset(); + forceReset = true; + } + } else { + sampleStreamWrapper.setIsTimestampMaster(false); + } + } + } + // Copy the new streams back into the streams array. + System.arraycopy(newStreams, 0, streams, 0, newStreams.length); + // Update the local state. + enabledSampleStreamWrappers = + Util.nullSafeArrayCopy(newEnabledSampleStreamWrappers, newEnabledSampleStreamWrapperCount); + compositeSequenceableLoader = + compositeSequenceableLoaderFactory.createCompositeSequenceableLoader( + enabledSampleStreamWrappers); + return positionUs; + } + + @Override + public void discardBuffer(long positionUs, boolean toKeyframe) { + for (HlsSampleStreamWrapper sampleStreamWrapper : enabledSampleStreamWrappers) { + sampleStreamWrapper.discardBuffer(positionUs, toKeyframe); + } + } + + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + + @Override + public boolean continueLoading(long positionUs) { + if (trackGroups == null) { + // Preparation is still going on. + for (HlsSampleStreamWrapper wrapper : sampleStreamWrappers) { + wrapper.continuePreparing(); + } + return false; + } else { + return compositeSequenceableLoader.continueLoading(positionUs); + } + } + + @Override + public boolean isLoading() { + return compositeSequenceableLoader.isLoading(); + } + + @Override + public long getNextLoadPositionUs() { + return compositeSequenceableLoader.getNextLoadPositionUs(); + } + + @Override + public long readDiscontinuity() { + if (!notifiedReadingStarted) { + eventDispatcher.readingStarted(); + notifiedReadingStarted = true; + } + return C.TIME_UNSET; + } + + @Override + public long getBufferedPositionUs() { + return compositeSequenceableLoader.getBufferedPositionUs(); + } + + @Override + public long seekToUs(long positionUs) { + if (enabledSampleStreamWrappers.length > 0) { + // We need to reset all wrappers if the one responsible for initializing timestamp adjusters + // is reset. Else each wrapper can decide whether to reset independently. + boolean forceReset = enabledSampleStreamWrappers[0].seekToUs(positionUs, false); + for (int i = 1; i < enabledSampleStreamWrappers.length; i++) { + enabledSampleStreamWrappers[i].seekToUs(positionUs, forceReset); + } + if (forceReset) { + timestampAdjusterProvider.reset(); + } + } + return positionUs; + } + + @Override + public long getAdjustedSeekPositionUs(long positionUs, SeekParameters seekParameters) { + return positionUs; + } + + // HlsSampleStreamWrapper.Callback implementation. + + @Override + public void onPrepared() { + if (--pendingPrepareCount > 0) { + return; + } + + int totalTrackGroupCount = 0; + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + totalTrackGroupCount += sampleStreamWrapper.getTrackGroups().length; + } + TrackGroup[] trackGroupArray = new TrackGroup[totalTrackGroupCount]; + int trackGroupIndex = 0; + for (HlsSampleStreamWrapper sampleStreamWrapper : sampleStreamWrappers) { + int wrapperTrackGroupCount = sampleStreamWrapper.getTrackGroups().length; + for (int j = 0; j < wrapperTrackGroupCount; j++) { + trackGroupArray[trackGroupIndex++] = sampleStreamWrapper.getTrackGroups().get(j); + } + } + trackGroups = new TrackGroupArray(trackGroupArray); + callback.onPrepared(this); + } + + @Override + public void onPlaylistRefreshRequired(Uri url) { + playlistTracker.refreshPlaylist(url); + } + + @Override + public void onContinueLoadingRequested(HlsSampleStreamWrapper sampleStreamWrapper) { + callback.onContinueLoadingRequested(this); + } + + // PlaylistListener implementation. + + @Override + public void onPlaylistChanged() { + callback.onContinueLoadingRequested(this); + } + + @Override + public boolean onPlaylistError(Uri url, long blacklistDurationMs) { + boolean noBlacklistingFailure = true; + for (HlsSampleStreamWrapper streamWrapper : sampleStreamWrappers) { + noBlacklistingFailure &= streamWrapper.onPlaylistError(url, blacklistDurationMs); + } + callback.onContinueLoadingRequested(this); + return noBlacklistingFailure; + } + + // Internal methods. + + private void buildAndPrepareSampleStreamWrappers(long positionUs) { + HlsMasterPlaylist masterPlaylist = Assertions.checkNotNull(playlistTracker.getMasterPlaylist()); + Map<String, DrmInitData> overridingDrmInitData = + useSessionKeys + ? deriveOverridingDrmInitData(masterPlaylist.sessionKeyDrmInitData) + : Collections.emptyMap(); + + boolean hasVariants = !masterPlaylist.variants.isEmpty(); + List<Rendition> audioRenditions = masterPlaylist.audios; + List<Rendition> subtitleRenditions = masterPlaylist.subtitles; + + pendingPrepareCount = 0; + ArrayList<HlsSampleStreamWrapper> sampleStreamWrappers = new ArrayList<>(); + ArrayList<int[]> manifestUrlIndicesPerWrapper = new ArrayList<>(); + + if (hasVariants) { + buildAndPrepareMainSampleStreamWrapper( + masterPlaylist, + positionUs, + sampleStreamWrappers, + manifestUrlIndicesPerWrapper, + overridingDrmInitData); + } + + // TODO: Build video stream wrappers here. + + buildAndPrepareAudioSampleStreamWrappers( + positionUs, + audioRenditions, + sampleStreamWrappers, + manifestUrlIndicesPerWrapper, + overridingDrmInitData); + + // Subtitle stream wrappers. We can always use master playlist information to prepare these. + for (int i = 0; i < subtitleRenditions.size(); i++) { + Rendition subtitleRendition = subtitleRenditions.get(i); + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_TEXT, + new Uri[] {subtitleRendition.url}, + new Format[] {subtitleRendition.format}, + null, + Collections.emptyList(), + overridingDrmInitData, + positionUs); + manifestUrlIndicesPerWrapper.add(new int[] {i}); + sampleStreamWrappers.add(sampleStreamWrapper); + sampleStreamWrapper.prepareWithMasterPlaylistInfo( + new TrackGroup[] {new TrackGroup(subtitleRendition.format)}, + /* primaryTrackGroupIndex= */ 0); + } + + this.sampleStreamWrappers = sampleStreamWrappers.toArray(new HlsSampleStreamWrapper[0]); + this.manifestUrlIndicesPerWrapper = manifestUrlIndicesPerWrapper.toArray(new int[0][]); + pendingPrepareCount = this.sampleStreamWrappers.length; + // Set timestamp master and trigger preparation (if not already prepared) + this.sampleStreamWrappers[0].setIsTimestampMaster(true); + for (HlsSampleStreamWrapper sampleStreamWrapper : this.sampleStreamWrappers) { + sampleStreamWrapper.continuePreparing(); + } + // All wrappers are enabled during preparation. + enabledSampleStreamWrappers = this.sampleStreamWrappers; + } + + /** + * This method creates and starts preparation of the main {@link HlsSampleStreamWrapper}. + * + * <p>The main sample stream wrapper is the first element of {@link #sampleStreamWrappers}. It + * provides {@link SampleStream}s for the variant urls in the master playlist. It may be adaptive + * and may contain multiple muxed tracks. + * + * <p>If chunkless preparation is allowed, the media period will try preparation without segment + * downloads. This is only possible if variants contain the CODECS attribute. If not, traditional + * preparation with segment downloads will take place. The following points apply to chunkless + * preparation: + * + * <ul> + * <li>A muxed audio track will be exposed if the codecs list contain an audio entry and the + * master playlist either contains an EXT-X-MEDIA tag without the URI attribute or does not + * contain any EXT-X-MEDIA tag. + * <li>Closed captions will only be exposed if they are declared by the master playlist. + * <li>An ID3 track is exposed preemptively, in case the segments contain an ID3 track. + * </ul> + * + * @param masterPlaylist The HLS master playlist. + * @param positionUs If preparation requires any chunk downloads, the position in microseconds at + * which downloading should start. Ignored otherwise. + * @param sampleStreamWrappers List to which the built main sample stream wrapper should be added. + * @param manifestUrlIndicesPerWrapper List to which the selected variant indices should be added. + * @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type + * (i.e. {@link DrmInitData#schemeType}). + */ + private void buildAndPrepareMainSampleStreamWrapper( + HlsMasterPlaylist masterPlaylist, + long positionUs, + List<HlsSampleStreamWrapper> sampleStreamWrappers, + List<int[]> manifestUrlIndicesPerWrapper, + Map<String, DrmInitData> overridingDrmInitData) { + int[] variantTypes = new int[masterPlaylist.variants.size()]; + int videoVariantCount = 0; + int audioVariantCount = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + Variant variant = masterPlaylist.variants.get(i); + Format format = variant.format; + if (format.height > 0 || Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_VIDEO) != null) { + variantTypes[i] = C.TRACK_TYPE_VIDEO; + videoVariantCount++; + } else if (Util.getCodecsOfType(format.codecs, C.TRACK_TYPE_AUDIO) != null) { + variantTypes[i] = C.TRACK_TYPE_AUDIO; + audioVariantCount++; + } else { + variantTypes[i] = C.TRACK_TYPE_UNKNOWN; + } + } + boolean useVideoVariantsOnly = false; + boolean useNonAudioVariantsOnly = false; + int selectedVariantsCount = variantTypes.length; + if (videoVariantCount > 0) { + // We've identified some variants as definitely containing video. Assume variants within the + // master playlist are marked consistently, and hence that we have the full set. Filter out + // any other variants, which are likely to be audio only. + useVideoVariantsOnly = true; + selectedVariantsCount = videoVariantCount; + } else if (audioVariantCount < variantTypes.length) { + // We've identified some variants, but not all, as being audio only. Filter them out to leave + // the remaining variants, which are likely to contain video. + useNonAudioVariantsOnly = true; + selectedVariantsCount = variantTypes.length - audioVariantCount; + } + Uri[] selectedPlaylistUrls = new Uri[selectedVariantsCount]; + Format[] selectedPlaylistFormats = new Format[selectedVariantsCount]; + int[] selectedVariantIndices = new int[selectedVariantsCount]; + int outIndex = 0; + for (int i = 0; i < masterPlaylist.variants.size(); i++) { + if ((!useVideoVariantsOnly || variantTypes[i] == C.TRACK_TYPE_VIDEO) + && (!useNonAudioVariantsOnly || variantTypes[i] != C.TRACK_TYPE_AUDIO)) { + Variant variant = masterPlaylist.variants.get(i); + selectedPlaylistUrls[outIndex] = variant.url; + selectedPlaylistFormats[outIndex] = variant.format; + selectedVariantIndices[outIndex++] = i; + } + } + String codecs = selectedPlaylistFormats[0].codecs; + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_DEFAULT, + selectedPlaylistUrls, + selectedPlaylistFormats, + masterPlaylist.muxedAudioFormat, + masterPlaylist.muxedCaptionFormats, + overridingDrmInitData, + positionUs); + sampleStreamWrappers.add(sampleStreamWrapper); + manifestUrlIndicesPerWrapper.add(selectedVariantIndices); + if (allowChunklessPreparation && codecs != null) { + boolean variantsContainVideoCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_VIDEO) != null; + boolean variantsContainAudioCodecs = Util.getCodecsOfType(codecs, C.TRACK_TYPE_AUDIO) != null; + List<TrackGroup> muxedTrackGroups = new ArrayList<>(); + if (variantsContainVideoCodecs) { + Format[] videoFormats = new Format[selectedVariantsCount]; + for (int i = 0; i < videoFormats.length; i++) { + videoFormats[i] = deriveVideoFormat(selectedPlaylistFormats[i]); + } + muxedTrackGroups.add(new TrackGroup(videoFormats)); + + if (variantsContainAudioCodecs + && (masterPlaylist.muxedAudioFormat != null || masterPlaylist.audios.isEmpty())) { + muxedTrackGroups.add( + new TrackGroup( + deriveAudioFormat( + selectedPlaylistFormats[0], + masterPlaylist.muxedAudioFormat, + /* isPrimaryTrackInVariant= */ false))); + } + List<Format> ccFormats = masterPlaylist.muxedCaptionFormats; + if (ccFormats != null) { + for (int i = 0; i < ccFormats.size(); i++) { + muxedTrackGroups.add(new TrackGroup(ccFormats.get(i))); + } + } + } else if (variantsContainAudioCodecs) { + // Variants only contain audio. + Format[] audioFormats = new Format[selectedVariantsCount]; + for (int i = 0; i < audioFormats.length; i++) { + audioFormats[i] = + deriveAudioFormat( + /* variantFormat= */ selectedPlaylistFormats[i], + masterPlaylist.muxedAudioFormat, + /* isPrimaryTrackInVariant= */ true); + } + muxedTrackGroups.add(new TrackGroup(audioFormats)); + } else { + // Variants contain codecs but no video or audio entries could be identified. + throw new IllegalArgumentException("Unexpected codecs attribute: " + codecs); + } + + TrackGroup id3TrackGroup = + new TrackGroup( + Format.createSampleFormat( + /* id= */ "ID3", + MimeTypes.APPLICATION_ID3, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* drmInitData= */ null)); + muxedTrackGroups.add(id3TrackGroup); + + sampleStreamWrapper.prepareWithMasterPlaylistInfo( + muxedTrackGroups.toArray(new TrackGroup[0]), + /* primaryTrackGroupIndex= */ 0, + /* optionalTrackGroupsIndices= */ muxedTrackGroups.indexOf(id3TrackGroup)); + } + } + + private void buildAndPrepareAudioSampleStreamWrappers( + long positionUs, + List<Rendition> audioRenditions, + List<HlsSampleStreamWrapper> sampleStreamWrappers, + List<int[]> manifestUrlsIndicesPerWrapper, + Map<String, DrmInitData> overridingDrmInitData) { + ArrayList<Uri> scratchPlaylistUrls = + new ArrayList<>(/* initialCapacity= */ audioRenditions.size()); + ArrayList<Format> scratchPlaylistFormats = + new ArrayList<>(/* initialCapacity= */ audioRenditions.size()); + ArrayList<Integer> scratchIndicesList = + new ArrayList<>(/* initialCapacity= */ audioRenditions.size()); + HashSet<String> alreadyGroupedNames = new HashSet<>(); + for (int renditionByNameIndex = 0; + renditionByNameIndex < audioRenditions.size(); + renditionByNameIndex++) { + String name = audioRenditions.get(renditionByNameIndex).name; + if (!alreadyGroupedNames.add(name)) { + // This name already has a corresponding group. + continue; + } + + boolean renditionsHaveCodecs = true; + scratchPlaylistUrls.clear(); + scratchPlaylistFormats.clear(); + scratchIndicesList.clear(); + // Group all renditions with matching name. + for (int renditionIndex = 0; renditionIndex < audioRenditions.size(); renditionIndex++) { + if (Util.areEqual(name, audioRenditions.get(renditionIndex).name)) { + Rendition rendition = audioRenditions.get(renditionIndex); + scratchIndicesList.add(renditionIndex); + scratchPlaylistUrls.add(rendition.url); + scratchPlaylistFormats.add(rendition.format); + renditionsHaveCodecs &= rendition.format.codecs != null; + } + } + + HlsSampleStreamWrapper sampleStreamWrapper = + buildSampleStreamWrapper( + C.TRACK_TYPE_AUDIO, + scratchPlaylistUrls.toArray(Util.castNonNullTypeArray(new Uri[0])), + scratchPlaylistFormats.toArray(new Format[0]), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ Collections.emptyList(), + overridingDrmInitData, + positionUs); + manifestUrlsIndicesPerWrapper.add(Util.toArray(scratchIndicesList)); + sampleStreamWrappers.add(sampleStreamWrapper); + + if (allowChunklessPreparation && renditionsHaveCodecs) { + Format[] renditionFormats = scratchPlaylistFormats.toArray(new Format[0]); + sampleStreamWrapper.prepareWithMasterPlaylistInfo( + new TrackGroup[] {new TrackGroup(renditionFormats)}, /* primaryTrackGroupIndex= */ 0); + } + } + } + + private HlsSampleStreamWrapper buildSampleStreamWrapper( + int trackType, + Uri[] playlistUrls, + Format[] playlistFormats, + @Nullable Format muxedAudioFormat, + @Nullable List<Format> muxedCaptionFormats, + Map<String, DrmInitData> overridingDrmInitData, + long positionUs) { + HlsChunkSource defaultChunkSource = + new HlsChunkSource( + extractorFactory, + playlistTracker, + playlistUrls, + playlistFormats, + dataSourceFactory, + mediaTransferListener, + timestampAdjusterProvider, + muxedCaptionFormats); + return new HlsSampleStreamWrapper( + trackType, + /* callback= */ this, + defaultChunkSource, + overridingDrmInitData, + allocator, + positionUs, + muxedAudioFormat, + drmSessionManager, + loadErrorHandlingPolicy, + eventDispatcher, + metadataType); + } + + private static Map<String, DrmInitData> deriveOverridingDrmInitData( + List<DrmInitData> sessionKeyDrmInitData) { + ArrayList<DrmInitData> mutableSessionKeyDrmInitData = new ArrayList<>(sessionKeyDrmInitData); + HashMap<String, DrmInitData> drmInitDataBySchemeType = new HashMap<>(); + for (int i = 0; i < mutableSessionKeyDrmInitData.size(); i++) { + DrmInitData drmInitData = sessionKeyDrmInitData.get(i); + String scheme = drmInitData.schemeType; + // Merge any subsequent drmInitData instances that have the same scheme type. This is valid + // due to the assumptions documented on HlsMediaSource.Builder.setUseSessionKeys, and is + // necessary to get data for different CDNs (e.g. Widevine and PlayReady) into a single + // drmInitData. + int j = i + 1; + while (j < mutableSessionKeyDrmInitData.size()) { + DrmInitData nextDrmInitData = mutableSessionKeyDrmInitData.get(j); + if (TextUtils.equals(nextDrmInitData.schemeType, scheme)) { + drmInitData = drmInitData.merge(nextDrmInitData); + mutableSessionKeyDrmInitData.remove(j); + } else { + j++; + } + } + drmInitDataBySchemeType.put(scheme, drmInitData); + } + return drmInitDataBySchemeType; + } + + private static Format deriveVideoFormat(Format variantFormat) { + String codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO); + String sampleMimeType = MimeTypes.getMediaMimeType(codecs); + return Format.createVideoContainerFormat( + variantFormat.id, + variantFormat.label, + variantFormat.containerMimeType, + sampleMimeType, + codecs, + variantFormat.metadata, + variantFormat.bitrate, + variantFormat.width, + variantFormat.height, + variantFormat.frameRate, + /* initializationData= */ null, + variantFormat.selectionFlags, + variantFormat.roleFlags); + } + + private static Format deriveAudioFormat( + Format variantFormat, @Nullable Format mediaTagFormat, boolean isPrimaryTrackInVariant) { + String codecs; + Metadata metadata; + int channelCount = Format.NO_VALUE; + int selectionFlags = 0; + int roleFlags = 0; + String language = null; + String label = null; + if (mediaTagFormat != null) { + codecs = mediaTagFormat.codecs; + metadata = mediaTagFormat.metadata; + channelCount = mediaTagFormat.channelCount; + selectionFlags = mediaTagFormat.selectionFlags; + roleFlags = mediaTagFormat.roleFlags; + language = mediaTagFormat.language; + label = mediaTagFormat.label; + } else { + codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_AUDIO); + metadata = variantFormat.metadata; + if (isPrimaryTrackInVariant) { + channelCount = variantFormat.channelCount; + selectionFlags = variantFormat.selectionFlags; + roleFlags = variantFormat.roleFlags; + language = variantFormat.language; + label = variantFormat.label; + } + } + String sampleMimeType = MimeTypes.getMediaMimeType(codecs); + int bitrate = isPrimaryTrackInVariant ? variantFormat.bitrate : Format.NO_VALUE; + return Format.createAudioContainerFormat( + variantFormat.id, + label, + variantFormat.containerMimeType, + sampleMimeType, + codecs, + metadata, + bitrate, + channelCount, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + selectionFlags, + roleFlags, + language); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java new file mode 100644 index 0000000000..2fa49e13f0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsMediaSource.java @@ -0,0 +1,528 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.net.Uri; +import android.os.Handler; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.BaseMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.CompositeSequenceableLoaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.DefaultCompositeSequenceableLoaderFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaPeriod; +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.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SinglePeriodTimeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistParserFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.FilteringHlsPlaylistParserFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.util.List; + +/** An HLS {@link MediaSource}. */ +public final class HlsMediaSource extends BaseMediaSource + implements HlsPlaylistTracker.PrimaryPlaylistListener { + + static { + ExoPlayerLibraryInfo.registerModule("goog.exo.hls"); + } + + /** + * The types of metadata that can be extracted from HLS streams. + * + * <p>Allowed values: + * + * <ul> + * <li>{@link #METADATA_TYPE_ID3} + * <li>{@link #METADATA_TYPE_EMSG} + * </ul> + * + * <p>See {@link Factory#setMetadataType(int)}. + */ + @Documented + @Retention(SOURCE) + @IntDef({METADATA_TYPE_ID3, METADATA_TYPE_EMSG}) + public @interface MetadataType {} + + /** Type for ID3 metadata in HLS streams. */ + public static final int METADATA_TYPE_ID3 = 1; + /** Type for ESMG metadata in HLS streams. */ + public static final int METADATA_TYPE_EMSG = 3; + + /** Factory for {@link HlsMediaSource}s. */ + public static final class Factory implements MediaSourceFactory { + + private final HlsDataSourceFactory hlsDataSourceFactory; + + private HlsExtractorFactory extractorFactory; + private HlsPlaylistParserFactory playlistParserFactory; + @Nullable private List<StreamKey> streamKeys; + private HlsPlaylistTracker.Factory playlistTrackerFactory; + private CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private DrmSessionManager<?> drmSessionManager; + private LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private boolean allowChunklessPreparation; + @MetadataType private int metadataType; + private boolean useSessionKeys; + private boolean isCreateCalled; + @Nullable private Object tag; + + /** + * Creates a new factory for {@link HlsMediaSource}s. + * + * @param dataSourceFactory A data source factory that will be wrapped by a {@link + * DefaultHlsDataSourceFactory} to create {@link DataSource}s for manifests, segments and + * keys. + */ + public Factory(DataSource.Factory dataSourceFactory) { + this(new DefaultHlsDataSourceFactory(dataSourceFactory)); + } + + /** + * Creates a new factory for {@link HlsMediaSource}s. + * + * @param hlsDataSourceFactory An {@link HlsDataSourceFactory} for {@link DataSource}s for + * manifests, segments and keys. + */ + public Factory(HlsDataSourceFactory hlsDataSourceFactory) { + this.hlsDataSourceFactory = Assertions.checkNotNull(hlsDataSourceFactory); + playlistParserFactory = new DefaultHlsPlaylistParserFactory(); + playlistTrackerFactory = DefaultHlsPlaylistTracker.FACTORY; + extractorFactory = HlsExtractorFactory.DEFAULT; + drmSessionManager = DrmSessionManager.getDummyDrmSessionManager(); + loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(); + compositeSequenceableLoaderFactory = new DefaultCompositeSequenceableLoaderFactory(); + metadataType = METADATA_TYPE_ID3; + } + + /** + * Sets a tag for the media source which will be published in the {@link + * org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline} of the source as {@link + * org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline.Window#tag}. + * + * @param tag A tag for the media source. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setTag(@Nullable Object tag) { + Assertions.checkState(!isCreateCalled); + this.tag = tag; + return this; + } + + /** + * Sets the factory for {@link Extractor}s for the segments. The default value is {@link + * HlsExtractorFactory#DEFAULT}. + * + * @param extractorFactory An {@link HlsExtractorFactory} for {@link Extractor}s for the + * segments. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setExtractorFactory(HlsExtractorFactory extractorFactory) { + Assertions.checkState(!isCreateCalled); + this.extractorFactory = Assertions.checkNotNull(extractorFactory); + return this; + } + + /** + * Sets the {@link LoadErrorHandlingPolicy}. The default value is created by calling {@link + * DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy()}. + * + * <p>Calling this method overrides any calls to {@link #setMinLoadableRetryCount(int)}. + * + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy loadErrorHandlingPolicy) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + return this; + } + + /** + * Sets the minimum number of times to retry if a loading error occurs. The default value is + * {@link DefaultLoadErrorHandlingPolicy#DEFAULT_MIN_LOADABLE_RETRY_COUNT}. + * + * <p>Calling this method is equivalent to calling {@link #setLoadErrorHandlingPolicy} with + * {@link DefaultLoadErrorHandlingPolicy#DefaultLoadErrorHandlingPolicy(int) + * DefaultLoadErrorHandlingPolicy(minLoadableRetryCount)} + * + * @param minLoadableRetryCount The minimum number of times to retry if a loading error occurs. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + * @deprecated Use {@link #setLoadErrorHandlingPolicy(LoadErrorHandlingPolicy)} instead. + */ + @Deprecated + public Factory setMinLoadableRetryCount(int minLoadableRetryCount) { + Assertions.checkState(!isCreateCalled); + this.loadErrorHandlingPolicy = new DefaultLoadErrorHandlingPolicy(minLoadableRetryCount); + return this; + } + + /** + * Sets the factory from which playlist parsers will be obtained. The default value is a {@link + * DefaultHlsPlaylistParserFactory}. + * + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setPlaylistParserFactory(HlsPlaylistParserFactory playlistParserFactory) { + Assertions.checkState(!isCreateCalled); + this.playlistParserFactory = Assertions.checkNotNull(playlistParserFactory); + return this; + } + + /** + * Sets the {@link HlsPlaylistTracker} factory. The default value is {@link + * DefaultHlsPlaylistTracker#FACTORY}. + * + * @param playlistTrackerFactory A factory for {@link HlsPlaylistTracker} instances. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setPlaylistTrackerFactory(HlsPlaylistTracker.Factory playlistTrackerFactory) { + Assertions.checkState(!isCreateCalled); + this.playlistTrackerFactory = Assertions.checkNotNull(playlistTrackerFactory); + return this; + } + + /** + * Sets the factory to create composite {@link SequenceableLoader}s for when this media source + * loads data from multiple streams (video, audio etc...). The default is an instance of {@link + * DefaultCompositeSequenceableLoaderFactory}. + * + * @param compositeSequenceableLoaderFactory A factory to create composite {@link + * SequenceableLoader}s for when this media source loads data from multiple streams (video, + * audio etc...). + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setCompositeSequenceableLoaderFactory( + CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory) { + Assertions.checkState(!isCreateCalled); + this.compositeSequenceableLoaderFactory = + Assertions.checkNotNull(compositeSequenceableLoaderFactory); + return this; + } + + /** + * Sets whether chunkless preparation is allowed. If true, preparation without chunk downloads + * will be enabled for streams that provide sufficient information in their master playlist. + * + * @param allowChunklessPreparation Whether chunkless preparation is allowed. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + public Factory setAllowChunklessPreparation(boolean allowChunklessPreparation) { + Assertions.checkState(!isCreateCalled); + this.allowChunklessPreparation = allowChunklessPreparation; + return this; + } + + /** + * Sets the type of metadata to extract from the HLS source (defaults to {@link + * #METADATA_TYPE_ID3}). + * + * <p>HLS supports in-band ID3 in both TS and fMP4 streams, but in the fMP4 case the data is + * wrapped in an EMSG box [<a href="https://aomediacodec.github.io/av1-id3/">spec</a>]. + * + * <p>If this is set to {@link #METADATA_TYPE_ID3} then raw ID3 metadata of will be extracted + * from TS sources. From fMP4 streams EMSGs containing metadata of this type (in the variant + * stream only) will be unwrapped to expose the inner data. All other in-band metadata will be + * dropped. + * + * <p>If this is set to {@link #METADATA_TYPE_EMSG} then all EMSG data from the fMP4 variant + * stream will be extracted. No metadata will be extracted from TS streams, since they don't + * support EMSG. + * + * @param metadataType The type of metadata to extract. + * @return This factory, for convenience. + */ + public Factory setMetadataType(@MetadataType int metadataType) { + Assertions.checkState(!isCreateCalled); + this.metadataType = metadataType; + return this; + } + + /** + * Sets whether to use #EXT-X-SESSION-KEY tags provided in the master playlist. If enabled, it's + * assumed that any single session key declared in the master playlist can be used to obtain all + * of the keys required for playback. For media where this is not true, this option should not + * be enabled. + * + * @param useSessionKeys Whether to use #EXT-X-SESSION-KEY tags. + * @return This factory, for convenience. + */ + public Factory setUseSessionKeys(boolean useSessionKeys) { + this.useSessionKeys = useSessionKeys; + return this; + } + + /** + * @deprecated Use {@link #createMediaSource(Uri)} and {@link #addEventListener(Handler, + * MediaSourceEventListener)} instead. + */ + @Deprecated + public HlsMediaSource createMediaSource( + Uri playlistUri, + @Nullable Handler eventHandler, + @Nullable MediaSourceEventListener eventListener) { + HlsMediaSource mediaSource = createMediaSource(playlistUri); + if (eventHandler != null && eventListener != null) { + mediaSource.addEventListener(eventHandler, eventListener); + } + return mediaSource; + } + + /** + * Sets the {@link DrmSessionManager} to use for acquiring {@link DrmSession DrmSessions}. The + * default value is {@link DrmSessionManager#DUMMY}. + * + * @param drmSessionManager The {@link DrmSessionManager}. + * @return This factory, for convenience. + * @throws IllegalStateException If one of the {@code create} methods has already been called. + */ + @Override + public Factory setDrmSessionManager(DrmSessionManager<?> drmSessionManager) { + Assertions.checkState(!isCreateCalled); + this.drmSessionManager = drmSessionManager; + return this; + } + + /** + * Returns a new {@link HlsMediaSource} using the current parameters. + * + * @return The new {@link HlsMediaSource}. + */ + @Override + public HlsMediaSource createMediaSource(Uri playlistUri) { + isCreateCalled = true; + if (streamKeys != null) { + playlistParserFactory = + new FilteringHlsPlaylistParserFactory(playlistParserFactory, streamKeys); + } + return new HlsMediaSource( + playlistUri, + hlsDataSourceFactory, + extractorFactory, + compositeSequenceableLoaderFactory, + drmSessionManager, + loadErrorHandlingPolicy, + playlistTrackerFactory.createTracker( + hlsDataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory), + allowChunklessPreparation, + metadataType, + useSessionKeys, + tag); + } + + @Override + public Factory setStreamKeys(List<StreamKey> streamKeys) { + Assertions.checkState(!isCreateCalled); + this.streamKeys = streamKeys; + return this; + } + + @Override + public int[] getSupportedTypes() { + return new int[] {C.TYPE_HLS}; + } + + } + + private final HlsExtractorFactory extractorFactory; + private final Uri manifestUri; + private final HlsDataSourceFactory dataSourceFactory; + private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final boolean allowChunklessPreparation; + private final @MetadataType int metadataType; + private final boolean useSessionKeys; + private final HlsPlaylistTracker playlistTracker; + @Nullable private final Object tag; + + @Nullable private TransferListener mediaTransferListener; + + private HlsMediaSource( + Uri manifestUri, + HlsDataSourceFactory dataSourceFactory, + HlsExtractorFactory extractorFactory, + CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistTracker playlistTracker, + boolean allowChunklessPreparation, + @MetadataType int metadataType, + boolean useSessionKeys, + @Nullable Object tag) { + this.manifestUri = manifestUri; + this.dataSourceFactory = dataSourceFactory; + this.extractorFactory = extractorFactory; + this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.playlistTracker = playlistTracker; + this.allowChunklessPreparation = allowChunklessPreparation; + this.metadataType = metadataType; + this.useSessionKeys = useSessionKeys; + this.tag = tag; + } + + @Override + @Nullable + public Object getTag() { + return tag; + } + + @Override + protected void prepareSourceInternal(@Nullable TransferListener mediaTransferListener) { + this.mediaTransferListener = mediaTransferListener; + drmSessionManager.prepare(); + EventDispatcher eventDispatcher = createEventDispatcher(/* mediaPeriodId= */ null); + playlistTracker.start(manifestUri, eventDispatcher, /* listener= */ this); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + playlistTracker.maybeThrowPrimaryPlaylistRefreshError(); + } + + @Override + public MediaPeriod createPeriod(MediaPeriodId id, Allocator allocator, long startPositionUs) { + EventDispatcher eventDispatcher = createEventDispatcher(id); + return new HlsMediaPeriod( + extractorFactory, + playlistTracker, + dataSourceFactory, + mediaTransferListener, + drmSessionManager, + loadErrorHandlingPolicy, + eventDispatcher, + allocator, + compositeSequenceableLoaderFactory, + allowChunklessPreparation, + metadataType, + useSessionKeys); + } + + @Override + public void releasePeriod(MediaPeriod mediaPeriod) { + ((HlsMediaPeriod) mediaPeriod).release(); + } + + @Override + protected void releaseSourceInternal() { + playlistTracker.stop(); + drmSessionManager.release(); + } + + @Override + public void onPrimaryPlaylistRefreshed(HlsMediaPlaylist playlist) { + SinglePeriodTimeline timeline; + long windowStartTimeMs = playlist.hasProgramDateTime ? C.usToMs(playlist.startTimeUs) + : C.TIME_UNSET; + // For playlist types EVENT and VOD we know segments are never removed, so the presentation + // started at the same time as the window. Otherwise, we don't know the presentation start time. + long presentationStartTimeMs = + playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT + || playlist.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD + ? windowStartTimeMs + : C.TIME_UNSET; + long windowDefaultStartPositionUs = playlist.startOffsetUs; + // masterPlaylist is non-null because the first playlist has been fetched by now. + HlsManifest manifest = + new HlsManifest(Assertions.checkNotNull(playlistTracker.getMasterPlaylist()), playlist); + if (playlistTracker.isLive()) { + long offsetFromInitialStartTimeUs = + playlist.startTimeUs - playlistTracker.getInitialStartTimeUs(); + long periodDurationUs = + playlist.hasEndTag ? offsetFromInitialStartTimeUs + playlist.durationUs : C.TIME_UNSET; + List<HlsMediaPlaylist.Segment> segments = playlist.segments; + if (windowDefaultStartPositionUs == C.TIME_UNSET) { + windowDefaultStartPositionUs = 0; + if (!segments.isEmpty()) { + int defaultStartSegmentIndex = Math.max(0, segments.size() - 3); + // We attempt to set the default start position to be at least twice the target duration + // behind the live edge. + long minStartPositionUs = playlist.durationUs - playlist.targetDurationUs * 2; + while (defaultStartSegmentIndex > 0 + && segments.get(defaultStartSegmentIndex).relativeStartTimeUs > minStartPositionUs) { + defaultStartSegmentIndex--; + } + windowDefaultStartPositionUs = segments.get(defaultStartSegmentIndex).relativeStartTimeUs; + } + } + timeline = + new SinglePeriodTimeline( + presentationStartTimeMs, + windowStartTimeMs, + periodDurationUs, + /* windowDurationUs= */ playlist.durationUs, + /* windowPositionInPeriodUs= */ offsetFromInitialStartTimeUs, + windowDefaultStartPositionUs, + /* isSeekable= */ true, + /* isDynamic= */ !playlist.hasEndTag, + /* isLive= */ true, + manifest, + tag); + } else /* not live */ { + if (windowDefaultStartPositionUs == C.TIME_UNSET) { + windowDefaultStartPositionUs = 0; + } + timeline = + new SinglePeriodTimeline( + presentationStartTimeMs, + windowStartTimeMs, + /* periodDurationUs= */ playlist.durationUs, + /* windowDurationUs= */ playlist.durationUs, + /* windowPositionInPeriodUs= */ 0, + windowDefaultStartPositionUs, + /* isSeekable= */ true, + /* isDynamic= */ false, + /* isLive= */ false, + manifest, + tag); + } + refreshSourceInfo(timeline); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java new file mode 100644 index 0000000000..5f44810af5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStream.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** + * {@link SampleStream} for a particular sample queue in HLS. + */ +/* package */ final class HlsSampleStream implements SampleStream { + + private final int trackGroupIndex; + private final HlsSampleStreamWrapper sampleStreamWrapper; + private int sampleQueueIndex; + + public HlsSampleStream(HlsSampleStreamWrapper sampleStreamWrapper, int trackGroupIndex) { + this.sampleStreamWrapper = sampleStreamWrapper; + this.trackGroupIndex = trackGroupIndex; + sampleQueueIndex = HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING; + } + + public void bindSampleQueue() { + Assertions.checkArgument(sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING); + sampleQueueIndex = sampleStreamWrapper.bindSampleQueueToSampleStream(trackGroupIndex); + } + + public void unbindSampleQueue() { + if (sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING) { + sampleStreamWrapper.unbindSampleQueue(trackGroupIndex); + sampleQueueIndex = HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING; + } + } + + // SampleStream implementation. + + @Override + public boolean isReady() { + return sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL + || (hasValidSampleQueueIndex() && sampleStreamWrapper.isReady(sampleQueueIndex)); + } + + @Override + public void maybeThrowError() throws IOException { + if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL) { + throw new SampleQueueMappingException( + sampleStreamWrapper.getTrackGroups().get(trackGroupIndex).getFormat(0).sampleMimeType); + } else if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING) { + sampleStreamWrapper.maybeThrowError(); + } else if (sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL) { + sampleStreamWrapper.maybeThrowError(sampleQueueIndex); + } + } + + @Override + public int readData(FormatHolder formatHolder, DecoderInputBuffer buffer, boolean requireFormat) { + if (sampleQueueIndex == HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL) { + buffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + return C.RESULT_BUFFER_READ; + } + return hasValidSampleQueueIndex() + ? sampleStreamWrapper.readData(sampleQueueIndex, formatHolder, buffer, requireFormat) + : C.RESULT_NOTHING_READ; + } + + @Override + public int skipData(long positionUs) { + return hasValidSampleQueueIndex() + ? sampleStreamWrapper.skipData(sampleQueueIndex, positionUs) + : 0; + } + + // Internal methods. + + private boolean hasValidSampleQueueIndex() { + return sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_PENDING + && sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL + && sampleQueueIndex != HlsSampleStreamWrapper.SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java new file mode 100644 index 0000000000..833abbc29f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -0,0 +1,1535 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.net.Uri; +import android.os.Handler; +import android.util.SparseIntArray; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +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.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.DummyTrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessage; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.emsg.EventMessageDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.id3.PrivFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue.UpstreamFormatChangedListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SequenceableLoader; +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.chunk.Chunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Allocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * Loads {@link HlsMediaChunk}s obtained from a {@link HlsChunkSource}, and provides + * {@link SampleStream}s from which the loaded media can be consumed. + */ +/* package */ final class HlsSampleStreamWrapper implements Loader.Callback<Chunk>, + Loader.ReleaseCallback, SequenceableLoader, ExtractorOutput, UpstreamFormatChangedListener { + + /** + * A callback to be notified of events. + */ + public interface Callback extends SequenceableLoader.Callback<HlsSampleStreamWrapper> { + + /** + * Called when the wrapper has been prepared. + * + * <p>Note: This method will be called on a later handler loop than the one on which either + * {@link #prepareWithMasterPlaylistInfo} or {@link #continuePreparing} are invoked. + */ + void onPrepared(); + + /** + * Called to schedule a {@link #continueLoading(long)} call when the playlist referred by the + * given url changes. + */ + void onPlaylistRefreshRequired(Uri playlistUrl); + } + + private static final String TAG = "HlsSampleStreamWrapper"; + + public static final int SAMPLE_QUEUE_INDEX_PENDING = -1; + public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL = -2; + public static final int SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL = -3; + + private static final Set<Integer> MAPPABLE_TYPES = + Collections.unmodifiableSet( + new HashSet<>( + Arrays.asList(C.TRACK_TYPE_AUDIO, C.TRACK_TYPE_VIDEO, C.TRACK_TYPE_METADATA))); + + private final int trackType; + private final Callback callback; + private final HlsChunkSource chunkSource; + private final Allocator allocator; + @Nullable private final Format muxedAudioFormat; + private final DrmSessionManager<?> drmSessionManager; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final Loader loader; + private final EventDispatcher eventDispatcher; + private final @HlsMediaSource.MetadataType int metadataType; + private final HlsChunkSource.HlsChunkHolder nextChunkHolder; + private final ArrayList<HlsMediaChunk> mediaChunks; + private final List<HlsMediaChunk> readOnlyMediaChunks; + // Using runnables rather than in-line method references to avoid repeated allocations. + private final Runnable maybeFinishPrepareRunnable; + private final Runnable onTracksEndedRunnable; + private final Handler handler; + private final ArrayList<HlsSampleStream> hlsSampleStreams; + private final Map<String, DrmInitData> overridingDrmInitData; + + private FormatAdjustingSampleQueue[] sampleQueues; + private int[] sampleQueueTrackIds; + private Set<Integer> sampleQueueMappingDoneByType; + private SparseIntArray sampleQueueIndicesByType; + @MonotonicNonNull private TrackOutput emsgUnwrappingTrackOutput; + private int primarySampleQueueType; + private int primarySampleQueueIndex; + private boolean sampleQueuesBuilt; + private boolean prepared; + private int enabledTrackGroupCount; + @MonotonicNonNull private Format upstreamTrackFormat; + @Nullable private Format downstreamTrackFormat; + private boolean released; + + // Tracks are complicated in HLS. See documentation of buildTracksFromSampleStreams for details. + // Indexed by track (as exposed by this source). + @MonotonicNonNull private TrackGroupArray trackGroups; + @MonotonicNonNull private Set<TrackGroup> optionalTrackGroups; + // Indexed by track group. + private int @MonotonicNonNull [] trackGroupToSampleQueueIndex; + private int primaryTrackGroupIndex; + private boolean haveAudioVideoSampleQueues; + private boolean[] sampleQueuesEnabledStates; + private boolean[] sampleQueueIsAudioVideoFlags; + + private long lastSeekPositionUs; + private long pendingResetPositionUs; + private boolean pendingResetUpstreamFormats; + private boolean seenFirstTrackSelection; + private boolean loadingFinished; + + // Accessed only by the loading thread. + private boolean tracksEnded; + private long sampleOffsetUs; + @Nullable private DrmInitData drmInitData; + private int chunkUid; + + /** + * @param trackType The type of the track. One of the {@link C} {@code TRACK_TYPE_*} constants. + * @param callback A callback for the wrapper. + * @param chunkSource A {@link HlsChunkSource} from which chunks to load are obtained. + * @param overridingDrmInitData Overriding {@link DrmInitData}, keyed by protection scheme type + * (i.e. {@link DrmInitData#schemeType}). If the stream has {@link DrmInitData} and uses a + * protection scheme type for which overriding {@link DrmInitData} is provided, then the + * stream's {@link DrmInitData} will be overridden. + * @param allocator An {@link Allocator} from which to obtain media buffer allocations. + * @param positionUs The position from which to start loading media. + * @param muxedAudioFormat Optional muxed audio {@link Format} as defined by the master playlist. + * @param drmSessionManager The {@link DrmSessionManager} to acquire {@link DrmSession + * DrmSessions} with. + * @param loadErrorHandlingPolicy A {@link LoadErrorHandlingPolicy}. + * @param eventDispatcher A dispatcher to notify of events. + */ + public HlsSampleStreamWrapper( + int trackType, + Callback callback, + HlsChunkSource chunkSource, + Map<String, DrmInitData> overridingDrmInitData, + Allocator allocator, + long positionUs, + @Nullable Format muxedAudioFormat, + DrmSessionManager<?> drmSessionManager, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + EventDispatcher eventDispatcher, + @HlsMediaSource.MetadataType int metadataType) { + this.trackType = trackType; + this.callback = callback; + this.chunkSource = chunkSource; + this.overridingDrmInitData = overridingDrmInitData; + this.allocator = allocator; + this.muxedAudioFormat = muxedAudioFormat; + this.drmSessionManager = drmSessionManager; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.eventDispatcher = eventDispatcher; + this.metadataType = metadataType; + loader = new Loader("Loader:HlsSampleStreamWrapper"); + nextChunkHolder = new HlsChunkSource.HlsChunkHolder(); + sampleQueueTrackIds = new int[0]; + sampleQueueMappingDoneByType = new HashSet<>(MAPPABLE_TYPES.size()); + sampleQueueIndicesByType = new SparseIntArray(MAPPABLE_TYPES.size()); + sampleQueues = new FormatAdjustingSampleQueue[0]; + sampleQueueIsAudioVideoFlags = new boolean[0]; + sampleQueuesEnabledStates = new boolean[0]; + mediaChunks = new ArrayList<>(); + readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks); + hlsSampleStreams = new ArrayList<>(); + // Suppressions are needed because `this` is not initialized here. + @SuppressWarnings("nullness:methodref.receiver.bound.invalid") + Runnable maybeFinishPrepareRunnable = this::maybeFinishPrepare; + this.maybeFinishPrepareRunnable = maybeFinishPrepareRunnable; + @SuppressWarnings("nullness:methodref.receiver.bound.invalid") + Runnable onTracksEndedRunnable = this::onTracksEnded; + this.onTracksEndedRunnable = onTracksEndedRunnable; + handler = new Handler(); + lastSeekPositionUs = positionUs; + pendingResetPositionUs = positionUs; + } + + public void continuePreparing() { + if (!prepared) { + continueLoading(lastSeekPositionUs); + } + } + + /** + * Prepares the sample stream wrapper with master playlist information. + * + * @param trackGroups The {@link TrackGroup TrackGroups} to expose through {@link + * #getTrackGroups()}. + * @param primaryTrackGroupIndex The index of the adaptive track group. + * @param optionalTrackGroupsIndices The indices of any {@code trackGroups} that should not + * trigger a failure if not found in the media playlist's segments. + */ + public void prepareWithMasterPlaylistInfo( + TrackGroup[] trackGroups, int primaryTrackGroupIndex, int... optionalTrackGroupsIndices) { + this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups); + optionalTrackGroups = new HashSet<>(); + for (int optionalTrackGroupIndex : optionalTrackGroupsIndices) { + optionalTrackGroups.add(this.trackGroups.get(optionalTrackGroupIndex)); + } + this.primaryTrackGroupIndex = primaryTrackGroupIndex; + handler.post(callback::onPrepared); + setIsPrepared(); + } + + public void maybeThrowPrepareError() throws IOException { + maybeThrowError(); + if (loadingFinished && !prepared) { + throw new ParserException("Loading finished before preparation is complete."); + } + } + + public TrackGroupArray getTrackGroups() { + assertIsPrepared(); + return trackGroups; + } + + public int getPrimaryTrackGroupIndex() { + return primaryTrackGroupIndex; + } + + public int bindSampleQueueToSampleStream(int trackGroupIndex) { + assertIsPrepared(); + Assertions.checkNotNull(trackGroupToSampleQueueIndex); + + int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex]; + if (sampleQueueIndex == C.INDEX_UNSET) { + return optionalTrackGroups.contains(trackGroups.get(trackGroupIndex)) + ? SAMPLE_QUEUE_INDEX_NO_MAPPING_NON_FATAL + : SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL; + } + if (sampleQueuesEnabledStates[sampleQueueIndex]) { + // This sample queue is already bound to a different sample stream. + return SAMPLE_QUEUE_INDEX_NO_MAPPING_FATAL; + } + sampleQueuesEnabledStates[sampleQueueIndex] = true; + return sampleQueueIndex; + } + + public void unbindSampleQueue(int trackGroupIndex) { + assertIsPrepared(); + Assertions.checkNotNull(trackGroupToSampleQueueIndex); + int sampleQueueIndex = trackGroupToSampleQueueIndex[trackGroupIndex]; + Assertions.checkState(sampleQueuesEnabledStates[sampleQueueIndex]); + sampleQueuesEnabledStates[sampleQueueIndex] = false; + } + + /** + * Called by the parent {@link HlsMediaPeriod} when a track selection occurs. + * + * @param selections The renderer track selections. + * @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained + * for each selection. A {@code true} value indicates that the selection is unchanged, and + * that the caller does not require that the sample stream be recreated. + * @param streams The existing sample streams, which will be updated to reflect the provided + * selections. + * @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that + * have been retained but with the requirement that the consuming renderer be reset. + * @param positionUs The current playback position in microseconds. + * @param forceReset If true then a reset is forced (i.e. a seek will be performed with in-buffer + * seeking disabled). + * @return Whether this wrapper requires the parent {@link HlsMediaPeriod} to perform a seek as + * part of the track selection. + */ + public boolean selectTracks( + @NullableType TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + @NullableType SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs, + boolean forceReset) { + assertIsPrepared(); + int oldEnabledTrackGroupCount = enabledTrackGroupCount; + // Deselect old tracks. + for (int i = 0; i < selections.length; i++) { + HlsSampleStream stream = (HlsSampleStream) streams[i]; + if (stream != null && (selections[i] == null || !mayRetainStreamFlags[i])) { + enabledTrackGroupCount--; + stream.unbindSampleQueue(); + streams[i] = null; + } + } + // We'll always need to seek if we're being forced to reset, or if this is a first selection to + // a position other than the one we started preparing with, or if we're making a selection + // having previously disabled all tracks. + boolean seekRequired = + forceReset + || (seenFirstTrackSelection + ? oldEnabledTrackGroupCount == 0 + : positionUs != lastSeekPositionUs); + // Get the old (i.e. current before the loop below executes) primary track selection. The new + // primary selection will equal the old one unless it's changed in the loop. + TrackSelection oldPrimaryTrackSelection = chunkSource.getTrackSelection(); + TrackSelection primaryTrackSelection = oldPrimaryTrackSelection; + // Select new tracks. + for (int i = 0; i < selections.length; i++) { + TrackSelection selection = selections[i]; + if (selection == null) { + continue; + } + int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup()); + if (trackGroupIndex == primaryTrackGroupIndex) { + primaryTrackSelection = selection; + chunkSource.setTrackSelection(selection); + } + if (streams[i] == null) { + enabledTrackGroupCount++; + streams[i] = new HlsSampleStream(this, trackGroupIndex); + streamResetFlags[i] = true; + if (trackGroupToSampleQueueIndex != null) { + ((HlsSampleStream) streams[i]).bindSampleQueue(); + // If there's still a chance of avoiding a seek, try and seek within the sample queue. + if (!seekRequired) { + SampleQueue sampleQueue = sampleQueues[trackGroupToSampleQueueIndex[trackGroupIndex]]; + // A seek can be avoided if we're able to seek to the current playback position in + // the sample queue, or if we haven't read anything from the queue since the previous + // seek (this case is common for sparse tracks such as metadata tracks). In all other + // cases a seek is required. + seekRequired = + !sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ true) + && sampleQueue.getReadIndex() != 0; + } + } + } + } + + if (enabledTrackGroupCount == 0) { + chunkSource.reset(); + downstreamTrackFormat = null; + pendingResetUpstreamFormats = true; + mediaChunks.clear(); + if (loader.isLoading()) { + if (sampleQueuesBuilt) { + // Discard as much as we can synchronously. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.discardToEnd(); + } + } + loader.cancelLoading(); + } else { + resetSampleQueues(); + } + } else { + if (!mediaChunks.isEmpty() + && !Util.areEqual(primaryTrackSelection, oldPrimaryTrackSelection)) { + // The primary track selection has changed and we have buffered media. The buffered media + // may need to be discarded. + boolean primarySampleQueueDirty = false; + if (!seenFirstTrackSelection) { + long bufferedDurationUs = positionUs < 0 ? -positionUs : 0; + HlsMediaChunk lastMediaChunk = getLastMediaChunk(); + MediaChunkIterator[] mediaChunkIterators = + chunkSource.createMediaChunkIterators(lastMediaChunk, positionUs); + primaryTrackSelection.updateSelectedTrack( + positionUs, + bufferedDurationUs, + C.TIME_UNSET, + readOnlyMediaChunks, + mediaChunkIterators); + int chunkIndex = chunkSource.getTrackGroup().indexOf(lastMediaChunk.trackFormat); + if (primaryTrackSelection.getSelectedIndexInTrackGroup() != chunkIndex) { + // This is the first selection and the chunk loaded during preparation does not match + // the initially selected format. + primarySampleQueueDirty = true; + } + } else { + // The primary sample queue contains media buffered for the old primary track selection. + primarySampleQueueDirty = true; + } + if (primarySampleQueueDirty) { + forceReset = true; + seekRequired = true; + pendingResetUpstreamFormats = true; + } + } + if (seekRequired) { + seekToUs(positionUs, forceReset); + // We'll need to reset renderers consuming from all streams due to the seek. + for (int i = 0; i < streams.length; i++) { + if (streams[i] != null) { + streamResetFlags[i] = true; + } + } + } + } + + updateSampleStreams(streams); + seenFirstTrackSelection = true; + return seekRequired; + } + + public void discardBuffer(long positionUs, boolean toKeyframe) { + if (!sampleQueuesBuilt || isPendingReset()) { + return; + } + int sampleQueueCount = sampleQueues.length; + for (int i = 0; i < sampleQueueCount; i++) { + sampleQueues[i].discardTo(positionUs, toKeyframe, sampleQueuesEnabledStates[i]); + } + } + + /** + * Attempts to seek to the specified position in microseconds. + * + * @param positionUs The seek position in microseconds. + * @param forceReset If true then a reset is forced (i.e. in-buffer seeking is disabled). + * @return Whether the wrapper was reset, meaning the wrapped sample queues were reset. If false, + * an in-buffer seek was performed. + */ + public boolean seekToUs(long positionUs, boolean forceReset) { + lastSeekPositionUs = positionUs; + if (isPendingReset()) { + // A reset is already pending. We only need to update its position. + pendingResetPositionUs = positionUs; + return true; + } + + // If we're not forced to reset, try and seek within the buffer. + if (sampleQueuesBuilt && !forceReset && seekInsideBufferUs(positionUs)) { + return false; + } + + // We can't seek inside the buffer, and so need to reset. + pendingResetPositionUs = positionUs; + loadingFinished = false; + mediaChunks.clear(); + if (loader.isLoading()) { + loader.cancelLoading(); + } else { + loader.clearFatalError(); + resetSampleQueues(); + } + return true; + } + + public void release() { + if (prepared) { + // Discard as much as we can synchronously. We only do this if we're prepared, since otherwise + // sampleQueues may still be being modified by the loading thread. + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.preRelease(); + } + } + loader.release(this); + handler.removeCallbacksAndMessages(null); + released = true; + hlsSampleStreams.clear(); + } + + @Override + public void onLoaderReleased() { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.release(); + } + } + + public void setIsTimestampMaster(boolean isTimestampMaster) { + chunkSource.setIsTimestampMaster(isTimestampMaster); + } + + public boolean onPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + return chunkSource.onPlaylistError(playlistUrl, blacklistDurationMs); + } + + // SampleStream implementation. + + public boolean isReady(int sampleQueueIndex) { + return !isPendingReset() && sampleQueues[sampleQueueIndex].isReady(loadingFinished); + } + + public void maybeThrowError(int sampleQueueIndex) throws IOException { + maybeThrowError(); + sampleQueues[sampleQueueIndex].maybeThrowError(); + } + + public void maybeThrowError() throws IOException { + loader.maybeThrowError(); + chunkSource.maybeThrowError(); + } + + public int readData(int sampleQueueIndex, FormatHolder formatHolder, DecoderInputBuffer buffer, + boolean requireFormat) { + if (isPendingReset()) { + return C.RESULT_NOTHING_READ; + } + + // TODO: Split into discard (in discardBuffer) and format change (here and in skipData) steps. + if (!mediaChunks.isEmpty()) { + int discardToMediaChunkIndex = 0; + while (discardToMediaChunkIndex < mediaChunks.size() - 1 + && finishedReadingChunk(mediaChunks.get(discardToMediaChunkIndex))) { + discardToMediaChunkIndex++; + } + Util.removeRange(mediaChunks, 0, discardToMediaChunkIndex); + HlsMediaChunk currentChunk = mediaChunks.get(0); + Format trackFormat = currentChunk.trackFormat; + if (!trackFormat.equals(downstreamTrackFormat)) { + eventDispatcher.downstreamFormatChanged(trackType, trackFormat, + currentChunk.trackSelectionReason, currentChunk.trackSelectionData, + currentChunk.startTimeUs); + } + downstreamTrackFormat = trackFormat; + } + + int result = + sampleQueues[sampleQueueIndex].read( + formatHolder, buffer, requireFormat, loadingFinished, lastSeekPositionUs); + if (result == C.RESULT_FORMAT_READ) { + Format format = Assertions.checkNotNull(formatHolder.format); + if (sampleQueueIndex == primarySampleQueueIndex) { + // Fill in primary sample format with information from the track format. + int chunkUid = sampleQueues[sampleQueueIndex].peekSourceId(); + int chunkIndex = 0; + while (chunkIndex < mediaChunks.size() && mediaChunks.get(chunkIndex).uid != chunkUid) { + chunkIndex++; + } + Format trackFormat = + chunkIndex < mediaChunks.size() + ? mediaChunks.get(chunkIndex).trackFormat + : Assertions.checkNotNull(upstreamTrackFormat); + format = format.copyWithManifestFormatInfo(trackFormat); + } + formatHolder.format = format; + } + return result; + } + + public int skipData(int sampleQueueIndex, long positionUs) { + if (isPendingReset()) { + return 0; + } + + SampleQueue sampleQueue = sampleQueues[sampleQueueIndex]; + if (loadingFinished && positionUs > sampleQueue.getLargestQueuedTimestampUs()) { + return sampleQueue.advanceToEnd(); + } else { + return sampleQueue.advanceTo(positionUs); + } + } + + // SequenceableLoader implementation + + @Override + public long getBufferedPositionUs() { + if (loadingFinished) { + return C.TIME_END_OF_SOURCE; + } else if (isPendingReset()) { + return pendingResetPositionUs; + } else { + long bufferedPositionUs = lastSeekPositionUs; + HlsMediaChunk lastMediaChunk = getLastMediaChunk(); + HlsMediaChunk lastCompletedMediaChunk = lastMediaChunk.isLoadCompleted() ? lastMediaChunk + : mediaChunks.size() > 1 ? mediaChunks.get(mediaChunks.size() - 2) : null; + if (lastCompletedMediaChunk != null) { + bufferedPositionUs = Math.max(bufferedPositionUs, lastCompletedMediaChunk.endTimeUs); + } + if (sampleQueuesBuilt) { + for (SampleQueue sampleQueue : sampleQueues) { + bufferedPositionUs = + Math.max(bufferedPositionUs, sampleQueue.getLargestQueuedTimestampUs()); + } + } + return bufferedPositionUs; + } + } + + @Override + public long getNextLoadPositionUs() { + if (isPendingReset()) { + return pendingResetPositionUs; + } else { + return loadingFinished ? C.TIME_END_OF_SOURCE : getLastMediaChunk().endTimeUs; + } + } + + @Override + public boolean continueLoading(long positionUs) { + if (loadingFinished || loader.isLoading() || loader.hasFatalError()) { + return false; + } + + List<HlsMediaChunk> chunkQueue; + long loadPositionUs; + if (isPendingReset()) { + chunkQueue = Collections.emptyList(); + loadPositionUs = pendingResetPositionUs; + } else { + chunkQueue = readOnlyMediaChunks; + HlsMediaChunk lastMediaChunk = getLastMediaChunk(); + loadPositionUs = + lastMediaChunk.isLoadCompleted() + ? lastMediaChunk.endTimeUs + : Math.max(lastSeekPositionUs, lastMediaChunk.startTimeUs); + } + chunkSource.getNextChunk( + positionUs, + loadPositionUs, + chunkQueue, + /* allowEndOfStream= */ prepared || !chunkQueue.isEmpty(), + nextChunkHolder); + boolean endOfStream = nextChunkHolder.endOfStream; + Chunk loadable = nextChunkHolder.chunk; + Uri playlistUrlToLoad = nextChunkHolder.playlistUrl; + nextChunkHolder.clear(); + + if (endOfStream) { + pendingResetPositionUs = C.TIME_UNSET; + loadingFinished = true; + return true; + } + + if (loadable == null) { + if (playlistUrlToLoad != null) { + callback.onPlaylistRefreshRequired(playlistUrlToLoad); + } + return false; + } + + if (isMediaChunk(loadable)) { + pendingResetPositionUs = C.TIME_UNSET; + HlsMediaChunk mediaChunk = (HlsMediaChunk) loadable; + mediaChunk.init(this); + mediaChunks.add(mediaChunk); + upstreamTrackFormat = mediaChunk.trackFormat; + } + long elapsedRealtimeMs = + loader.startLoading( + loadable, this, loadErrorHandlingPolicy.getMinimumLoadableRetryCount(loadable.type)); + eventDispatcher.loadStarted( + loadable.dataSpec, + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs); + return true; + } + + @Override + public boolean isLoading() { + return loader.isLoading(); + } + + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs) { + chunkSource.onChunkLoadCompleted(loadable); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + if (!prepared) { + continueLoading(lastSeekPositionUs); + } else { + callback.onContinueLoadingRequested(this); + } + } + + @Override + public void onLoadCanceled(Chunk loadable, long elapsedRealtimeMs, long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + if (!released) { + resetSampleQueues(); + if (enabledTrackGroupCount > 0) { + callback.onContinueLoadingRequested(this); + } + } + } + + @Override + public LoadErrorAction onLoadError( + Chunk loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long bytesLoaded = loadable.bytesLoaded(); + boolean isMediaChunk = isMediaChunk(loadable); + boolean blacklistSucceeded = false; + LoadErrorAction loadErrorAction; + + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount); + if (blacklistDurationMs != C.TIME_UNSET) { + blacklistSucceeded = chunkSource.maybeBlacklistTrack(loadable, blacklistDurationMs); + } + + if (blacklistSucceeded) { + if (isMediaChunk && bytesLoaded == 0) { + HlsMediaChunk removed = mediaChunks.remove(mediaChunks.size() - 1); + Assertions.checkState(removed == loadable); + if (mediaChunks.isEmpty()) { + pendingResetPositionUs = lastSeekPositionUs; + } + } + loadErrorAction = Loader.DONT_RETRY; + } else /* did not blacklist */ { + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelayMs != C.TIME_UNSET + ? Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs) + : Loader.DONT_RETRY_FATAL; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + loadable.type, + trackType, + loadable.trackFormat, + loadable.trackSelectionReason, + loadable.trackSelectionData, + loadable.startTimeUs, + loadable.endTimeUs, + elapsedRealtimeMs, + loadDurationMs, + bytesLoaded, + error, + /* wasCanceled= */ !loadErrorAction.isRetry()); + + if (blacklistSucceeded) { + if (!prepared) { + continueLoading(lastSeekPositionUs); + } else { + callback.onContinueLoadingRequested(this); + } + } + return loadErrorAction; + } + + // Called by the consuming thread, but only when there is no loading thread. + + /** + * Initializes the wrapper for loading a chunk. + * + * @param chunkUid The chunk's uid. + * @param shouldSpliceIn Whether the samples parsed from the chunk should be spliced into any + * samples already queued to the wrapper. + */ + public void init(int chunkUid, boolean shouldSpliceIn) { + this.chunkUid = chunkUid; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.sourceId(chunkUid); + } + if (shouldSpliceIn) { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.splice(); + } + } + } + + // ExtractorOutput implementation. Called by the loading thread. + + @Override + public TrackOutput track(int id, int type) { + @Nullable TrackOutput trackOutput = null; + if (MAPPABLE_TYPES.contains(type)) { + // Track types in MAPPABLE_TYPES are handled manually to ignore IDs. + trackOutput = getMappedTrackOutput(id, type); + } else /* non-mappable type track */ { + for (int i = 0; i < sampleQueues.length; i++) { + if (sampleQueueTrackIds[i] == id) { + trackOutput = sampleQueues[i]; + break; + } + } + } + + if (trackOutput == null) { + if (tracksEnded) { + return createDummyTrackOutput(id, type); + } else { + // The relevant SampleQueue hasn't been constructed yet - so construct it. + trackOutput = createSampleQueue(id, type); + } + } + + if (type == C.TRACK_TYPE_METADATA) { + if (emsgUnwrappingTrackOutput == null) { + emsgUnwrappingTrackOutput = new EmsgUnwrappingTrackOutput(trackOutput, metadataType); + } + return emsgUnwrappingTrackOutput; + } + return trackOutput; + } + + /** + * Returns the {@link TrackOutput} for the provided {@code type} and {@code id}, or null if none + * has been created yet. + * + * <p>If a {@link SampleQueue} for {@code type} has been created and is mapped, but it has a + * different ID, then return a {@link DummyTrackOutput} that does nothing. + * + * <p>If a {@link SampleQueue} for {@code type} has been created but is not mapped, then map it to + * this {@code id} and return it. This situation can happen after a call to {@link + * #onNewExtractor}. + * + * @param id The ID of the track. + * @param type The type of the track, must be one of {@link #MAPPABLE_TYPES}. + * @return The the mapped {@link TrackOutput}, or null if it's not been created yet. + */ + @Nullable + private TrackOutput getMappedTrackOutput(int id, int type) { + Assertions.checkArgument(MAPPABLE_TYPES.contains(type)); + int sampleQueueIndex = sampleQueueIndicesByType.get(type, C.INDEX_UNSET); + if (sampleQueueIndex == C.INDEX_UNSET) { + return null; + } + + if (sampleQueueMappingDoneByType.add(type)) { + sampleQueueTrackIds[sampleQueueIndex] = id; + } + return sampleQueueTrackIds[sampleQueueIndex] == id + ? sampleQueues[sampleQueueIndex] + : createDummyTrackOutput(id, type); + } + + private SampleQueue createSampleQueue(int id, int type) { + int trackCount = sampleQueues.length; + + boolean isAudioVideo = type == C.TRACK_TYPE_AUDIO || type == C.TRACK_TYPE_VIDEO; + FormatAdjustingSampleQueue trackOutput = + new FormatAdjustingSampleQueue(allocator, drmSessionManager, overridingDrmInitData); + if (isAudioVideo) { + trackOutput.setDrmInitData(drmInitData); + } + trackOutput.setSampleOffsetUs(sampleOffsetUs); + trackOutput.sourceId(chunkUid); + trackOutput.setUpstreamFormatChangeListener(this); + sampleQueueTrackIds = Arrays.copyOf(sampleQueueTrackIds, trackCount + 1); + sampleQueueTrackIds[trackCount] = id; + sampleQueues = Util.nullSafeArrayAppend(sampleQueues, trackOutput); + sampleQueueIsAudioVideoFlags = Arrays.copyOf(sampleQueueIsAudioVideoFlags, trackCount + 1); + sampleQueueIsAudioVideoFlags[trackCount] = isAudioVideo; + haveAudioVideoSampleQueues |= sampleQueueIsAudioVideoFlags[trackCount]; + sampleQueueMappingDoneByType.add(type); + sampleQueueIndicesByType.append(type, trackCount); + if (getTrackTypeScore(type) > getTrackTypeScore(primarySampleQueueType)) { + primarySampleQueueIndex = trackCount; + primarySampleQueueType = type; + } + sampleQueuesEnabledStates = Arrays.copyOf(sampleQueuesEnabledStates, trackCount + 1); + return trackOutput; + } + + @Override + public void endTracks() { + tracksEnded = true; + handler.post(onTracksEndedRunnable); + } + + @Override + public void seekMap(SeekMap seekMap) { + // Do nothing. + } + + // UpstreamFormatChangedListener implementation. Called by the loading thread. + + @Override + public void onUpstreamFormatChanged(Format format) { + handler.post(maybeFinishPrepareRunnable); + } + + // Called by the loading thread. + + /** Called when an {@link HlsMediaChunk} starts extracting media with a new {@link Extractor}. */ + public void onNewExtractor() { + sampleQueueMappingDoneByType.clear(); + } + + /** + * Sets an offset that will be added to the timestamps (and sub-sample timestamps) of samples that + * are subsequently loaded by this wrapper. + * + * @param sampleOffsetUs The timestamp offset in microseconds. + */ + public void setSampleOffsetUs(long sampleOffsetUs) { + if (this.sampleOffsetUs != sampleOffsetUs) { + this.sampleOffsetUs = sampleOffsetUs; + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.setSampleOffsetUs(sampleOffsetUs); + } + } + } + + /** + * Sets default {@link DrmInitData} for samples that are subsequently loaded by this wrapper. + * + * <p>This method should be called prior to loading each {@link HlsMediaChunk}. The {@link + * DrmInitData} passed should be that of an EXT-X-KEY tag that applies to the chunk, or {@code + * null} otherwise. + * + * <p>The final {@link DrmInitData} for subsequently queued samples is determined as followed: + * + * <ol> + * <li>It is initially set to {@code drmInitData}, unless {@code drmInitData} is null in which + * case it's set to {@link Format#drmInitData} of the upstream {@link Format}. + * <li>If the initial {@link DrmInitData} is non-null and {@link #overridingDrmInitData} + * contains an entry whose key matches the {@link DrmInitData#schemeType}, then the sample's + * {@link DrmInitData} is overridden to be this entry's value. + * </ol> + * + * <p> + * + * @param drmInitData The default {@link DrmInitData} for samples that are subsequently queued. If + * non-null then it takes precedence over {@link Format#drmInitData} of the upstream {@link + * Format}, but will still be overridden by a matching override in {@link + * #overridingDrmInitData}. + */ + public void setDrmInitData(@Nullable DrmInitData drmInitData) { + if (!Util.areEqual(this.drmInitData, drmInitData)) { + this.drmInitData = drmInitData; + for (int i = 0; i < sampleQueues.length; i++) { + if (sampleQueueIsAudioVideoFlags[i]) { + sampleQueues[i].setDrmInitData(drmInitData); + } + } + } + } + + // Internal methods. + + private void updateSampleStreams(@NullableType SampleStream[] streams) { + hlsSampleStreams.clear(); + for (SampleStream stream : streams) { + if (stream != null) { + hlsSampleStreams.add((HlsSampleStream) stream); + } + } + } + + private boolean finishedReadingChunk(HlsMediaChunk chunk) { + int chunkUid = chunk.uid; + int sampleQueueCount = sampleQueues.length; + for (int i = 0; i < sampleQueueCount; i++) { + if (sampleQueuesEnabledStates[i] && sampleQueues[i].peekSourceId() == chunkUid) { + return false; + } + } + return true; + } + + private void resetSampleQueues() { + for (SampleQueue sampleQueue : sampleQueues) { + sampleQueue.reset(pendingResetUpstreamFormats); + } + pendingResetUpstreamFormats = false; + } + + private void onTracksEnded() { + sampleQueuesBuilt = true; + maybeFinishPrepare(); + } + + private void maybeFinishPrepare() { + if (released || trackGroupToSampleQueueIndex != null || !sampleQueuesBuilt) { + return; + } + for (SampleQueue sampleQueue : sampleQueues) { + if (sampleQueue.getUpstreamFormat() == null) { + return; + } + } + if (trackGroups != null) { + // The track groups were created with master playlist information. They only need to be mapped + // to a sample queue. + mapSampleQueuesToMatchTrackGroups(); + } else { + // Tracks are created using media segment information. + buildTracksFromSampleStreams(); + setIsPrepared(); + callback.onPrepared(); + } + } + + @RequiresNonNull("trackGroups") + @EnsuresNonNull("trackGroupToSampleQueueIndex") + private void mapSampleQueuesToMatchTrackGroups() { + int trackGroupCount = trackGroups.length; + trackGroupToSampleQueueIndex = new int[trackGroupCount]; + Arrays.fill(trackGroupToSampleQueueIndex, C.INDEX_UNSET); + for (int i = 0; i < trackGroupCount; i++) { + for (int queueIndex = 0; queueIndex < sampleQueues.length; queueIndex++) { + SampleQueue sampleQueue = sampleQueues[queueIndex]; + if (formatsMatch(sampleQueue.getUpstreamFormat(), trackGroups.get(i).getFormat(0))) { + trackGroupToSampleQueueIndex[i] = queueIndex; + break; + } + } + } + for (HlsSampleStream sampleStream : hlsSampleStreams) { + sampleStream.bindSampleQueue(); + } + } + + /** + * Builds tracks that are exposed by this {@link HlsSampleStreamWrapper} instance, as well as + * internal data-structures required for operation. + * + * <p>Tracks in HLS are complicated. A HLS master playlist contains a number of "variants". Each + * variant stream typically contains muxed video, audio and (possibly) additional audio, metadata + * and caption tracks. We wish to allow the user to select between an adaptive track that spans + * all variants, as well as each individual variant. If multiple audio tracks are present within + * each variant then we wish to allow the user to select between those also. + * + * <p>To do this, tracks are constructed as follows. The {@link HlsChunkSource} exposes (N+1) + * tracks, where N is the number of variants defined in the HLS master playlist. These consist of + * one adaptive track defined to span all variants and a track for each individual variant. The + * adaptive track is initially selected. The extractor is then prepared to discover the tracks + * inside of each variant stream. The two sets of tracks are then combined by this method to + * create a third set, which is the set exposed by this {@link HlsSampleStreamWrapper}: + * + * <ul> + * <li>The extractor tracks are inspected to infer a "primary" track type. If a video track is + * present then it is always the primary type. If not, audio is the primary type if present. + * Else text is the primary type if present. Else there is no primary type. + * <li>If there is exactly one extractor track of the primary type, it's expanded into (N+1) + * exposed tracks, all of which correspond to the primary extractor track and each of which + * corresponds to a different chunk source track. Selecting one of these tracks has the + * effect of switching the selected track on the chunk source. + * <li>All other extractor tracks are exposed directly. Selecting one of these tracks has the + * effect of selecting an extractor track, leaving the selected track on the chunk source + * unchanged. + * </ul> + */ + @EnsuresNonNull({"trackGroups", "optionalTrackGroups", "trackGroupToSampleQueueIndex"}) + private void buildTracksFromSampleStreams() { + // Iterate through the extractor tracks to discover the "primary" track type, and the index + // of the single track of this type. + int primaryExtractorTrackType = C.TRACK_TYPE_NONE; + int primaryExtractorTrackIndex = C.INDEX_UNSET; + int extractorTrackCount = sampleQueues.length; + for (int i = 0; i < extractorTrackCount; i++) { + String sampleMimeType = sampleQueues[i].getUpstreamFormat().sampleMimeType; + int trackType; + if (MimeTypes.isVideo(sampleMimeType)) { + trackType = C.TRACK_TYPE_VIDEO; + } else if (MimeTypes.isAudio(sampleMimeType)) { + trackType = C.TRACK_TYPE_AUDIO; + } else if (MimeTypes.isText(sampleMimeType)) { + trackType = C.TRACK_TYPE_TEXT; + } else { + trackType = C.TRACK_TYPE_NONE; + } + if (getTrackTypeScore(trackType) > getTrackTypeScore(primaryExtractorTrackType)) { + primaryExtractorTrackType = trackType; + primaryExtractorTrackIndex = i; + } else if (trackType == primaryExtractorTrackType + && primaryExtractorTrackIndex != C.INDEX_UNSET) { + // We have multiple tracks of the primary type. We only want an index if there only exists a + // single track of the primary type, so unset the index again. + primaryExtractorTrackIndex = C.INDEX_UNSET; + } + } + + TrackGroup chunkSourceTrackGroup = chunkSource.getTrackGroup(); + int chunkSourceTrackCount = chunkSourceTrackGroup.length; + + // Instantiate the necessary internal data-structures. + primaryTrackGroupIndex = C.INDEX_UNSET; + trackGroupToSampleQueueIndex = new int[extractorTrackCount]; + for (int i = 0; i < extractorTrackCount; i++) { + trackGroupToSampleQueueIndex[i] = i; + } + + // Construct the set of exposed track groups. + TrackGroup[] trackGroups = new TrackGroup[extractorTrackCount]; + for (int i = 0; i < extractorTrackCount; i++) { + Format sampleFormat = sampleQueues[i].getUpstreamFormat(); + if (i == primaryExtractorTrackIndex) { + Format[] formats = new Format[chunkSourceTrackCount]; + if (chunkSourceTrackCount == 1) { + formats[0] = sampleFormat.copyWithManifestFormatInfo(chunkSourceTrackGroup.getFormat(0)); + } else { + for (int j = 0; j < chunkSourceTrackCount; j++) { + formats[j] = deriveFormat(chunkSourceTrackGroup.getFormat(j), sampleFormat, true); + } + } + trackGroups[i] = new TrackGroup(formats); + primaryTrackGroupIndex = i; + } else { + Format trackFormat = + primaryExtractorTrackType == C.TRACK_TYPE_VIDEO + && MimeTypes.isAudio(sampleFormat.sampleMimeType) + ? muxedAudioFormat + : null; + trackGroups[i] = new TrackGroup(deriveFormat(trackFormat, sampleFormat, false)); + } + } + this.trackGroups = createTrackGroupArrayWithDrmInfo(trackGroups); + Assertions.checkState(optionalTrackGroups == null); + optionalTrackGroups = Collections.emptySet(); + } + + private TrackGroupArray createTrackGroupArrayWithDrmInfo(TrackGroup[] trackGroups) { + for (int i = 0; i < trackGroups.length; i++) { + TrackGroup trackGroup = trackGroups[i]; + Format[] exposedFormats = new Format[trackGroup.length]; + for (int j = 0; j < trackGroup.length; j++) { + Format format = trackGroup.getFormat(j); + if (format.drmInitData != null) { + format = + format.copyWithExoMediaCryptoType( + drmSessionManager.getExoMediaCryptoType(format.drmInitData)); + } + exposedFormats[j] = format; + } + trackGroups[i] = new TrackGroup(exposedFormats); + } + return new TrackGroupArray(trackGroups); + } + + private HlsMediaChunk getLastMediaChunk() { + return mediaChunks.get(mediaChunks.size() - 1); + } + + private boolean isPendingReset() { + return pendingResetPositionUs != C.TIME_UNSET; + } + + /** + * Attempts to seek to the specified position within the sample queues. + * + * @param positionUs The seek position in microseconds. + * @return Whether the in-buffer seek was successful. + */ + private boolean seekInsideBufferUs(long positionUs) { + int sampleQueueCount = sampleQueues.length; + for (int i = 0; i < sampleQueueCount; i++) { + SampleQueue sampleQueue = sampleQueues[i]; + boolean seekInsideQueue = sampleQueue.seekTo(positionUs, /* allowTimeBeyondBuffer= */ false); + // If we have AV tracks then an in-queue seek is successful if the seek into every AV queue + // is successful. We ignore whether seeks within non-AV queues are successful in this case, as + // they may be sparse or poorly interleaved. If we only have non-AV tracks then a seek is + // successful only if the seek into every queue succeeds. + if (!seekInsideQueue && (sampleQueueIsAudioVideoFlags[i] || !haveAudioVideoSampleQueues)) { + return false; + } + } + return true; + } + + @RequiresNonNull({"trackGroups", "optionalTrackGroups"}) + private void setIsPrepared() { + prepared = true; + } + + @EnsuresNonNull({"trackGroups", "optionalTrackGroups"}) + private void assertIsPrepared() { + Assertions.checkState(prepared); + Assertions.checkNotNull(trackGroups); + Assertions.checkNotNull(optionalTrackGroups); + } + + /** + * Scores a track type. Where multiple tracks are muxed into a container, the track with the + * highest score is the primary track. + * + * @param trackType The track type. + * @return The score. + */ + private static int getTrackTypeScore(int trackType) { + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + return 3; + case C.TRACK_TYPE_AUDIO: + return 2; + case C.TRACK_TYPE_TEXT: + return 1; + default: + return 0; + } + } + + /** + * Derives a track sample format from the corresponding format in the master playlist, and a + * sample format that may have been obtained from a chunk belonging to a different track. + * + * @param playlistFormat The format information obtained from the master playlist. + * @param sampleFormat The format information obtained from the samples. + * @param propagateBitrate Whether the bitrate from the playlist format should be included in the + * derived format. + * @return The derived track format. + */ + private static Format deriveFormat( + @Nullable Format playlistFormat, Format sampleFormat, boolean propagateBitrate) { + if (playlistFormat == null) { + return sampleFormat; + } + int bitrate = propagateBitrate ? playlistFormat.bitrate : Format.NO_VALUE; + int channelCount = + playlistFormat.channelCount != Format.NO_VALUE + ? playlistFormat.channelCount + : sampleFormat.channelCount; + int sampleTrackType = MimeTypes.getTrackType(sampleFormat.sampleMimeType); + String codecs = Util.getCodecsOfType(playlistFormat.codecs, sampleTrackType); + String mimeType = MimeTypes.getMediaMimeType(codecs); + if (mimeType == null) { + mimeType = sampleFormat.sampleMimeType; + } + return sampleFormat.copyWithContainerInfo( + playlistFormat.id, + playlistFormat.label, + mimeType, + codecs, + playlistFormat.metadata, + bitrate, + playlistFormat.width, + playlistFormat.height, + channelCount, + playlistFormat.selectionFlags, + playlistFormat.language); + } + + private static boolean isMediaChunk(Chunk chunk) { + return chunk instanceof HlsMediaChunk; + } + + private static boolean formatsMatch(Format manifestFormat, Format sampleFormat) { + String manifestFormatMimeType = manifestFormat.sampleMimeType; + String sampleFormatMimeType = sampleFormat.sampleMimeType; + int manifestFormatTrackType = MimeTypes.getTrackType(manifestFormatMimeType); + if (manifestFormatTrackType != C.TRACK_TYPE_TEXT) { + return manifestFormatTrackType == MimeTypes.getTrackType(sampleFormatMimeType); + } else if (!Util.areEqual(manifestFormatMimeType, sampleFormatMimeType)) { + return false; + } + if (MimeTypes.APPLICATION_CEA608.equals(manifestFormatMimeType) + || MimeTypes.APPLICATION_CEA708.equals(manifestFormatMimeType)) { + return manifestFormat.accessibilityChannel == sampleFormat.accessibilityChannel; + } + return true; + } + + private static DummyTrackOutput createDummyTrackOutput(int id, int type) { + Log.w(TAG, "Unmapped track with id " + id + " of type " + type); + return new DummyTrackOutput(); + } + + private static final class FormatAdjustingSampleQueue extends SampleQueue { + + private final Map<String, DrmInitData> overridingDrmInitData; + @Nullable private DrmInitData drmInitData; + + public FormatAdjustingSampleQueue( + Allocator allocator, + DrmSessionManager<?> drmSessionManager, + Map<String, DrmInitData> overridingDrmInitData) { + super(allocator, drmSessionManager); + this.overridingDrmInitData = overridingDrmInitData; + } + + public void setDrmInitData(@Nullable DrmInitData drmInitData) { + this.drmInitData = drmInitData; + invalidateUpstreamFormatAdjustment(); + } + + @Override + public Format getAdjustedUpstreamFormat(Format format) { + @Nullable + DrmInitData drmInitData = this.drmInitData != null ? this.drmInitData : format.drmInitData; + if (drmInitData != null) { + @Nullable + DrmInitData overridingDrmInitData = this.overridingDrmInitData.get(drmInitData.schemeType); + if (overridingDrmInitData != null) { + drmInitData = overridingDrmInitData; + } + } + return super.getAdjustedUpstreamFormat( + format.copyWithAdjustments(drmInitData, getAdjustedMetadata(format.metadata))); + } + + /** + * Strips the private timestamp frame from metadata, if present. See: + * https://github.com/google/ExoPlayer/issues/5063 + */ + @Nullable + private Metadata getAdjustedMetadata(@Nullable Metadata metadata) { + if (metadata == null) { + return null; + } + int length = metadata.length(); + int transportStreamTimestampMetadataIndex = C.INDEX_UNSET; + for (int i = 0; i < length; i++) { + Metadata.Entry metadataEntry = metadata.get(i); + if (metadataEntry instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) metadataEntry; + if (HlsMediaChunk.PRIV_TIMESTAMP_FRAME_OWNER.equals(privFrame.owner)) { + transportStreamTimestampMetadataIndex = i; + break; + } + } + } + if (transportStreamTimestampMetadataIndex == C.INDEX_UNSET) { + return metadata; + } + if (length == 1) { + return null; + } + Metadata.Entry[] newMetadataEntries = new Metadata.Entry[length - 1]; + for (int i = 0; i < length; i++) { + if (i != transportStreamTimestampMetadataIndex) { + int newIndex = i < transportStreamTimestampMetadataIndex ? i : i - 1; + newMetadataEntries[newIndex] = metadata.get(i); + } + } + return new Metadata(newMetadataEntries); + } + } + + private static class EmsgUnwrappingTrackOutput implements TrackOutput { + + private static final String TAG = "EmsgUnwrappingTrackOutput"; + + // TODO(ibaker): Create a Formats util class with common constants like this. + private static final Format ID3_FORMAT = + Format.createSampleFormat( + /* id= */ null, MimeTypes.APPLICATION_ID3, Format.OFFSET_SAMPLE_RELATIVE); + private static final Format EMSG_FORMAT = + Format.createSampleFormat( + /* id= */ null, MimeTypes.APPLICATION_EMSG, Format.OFFSET_SAMPLE_RELATIVE); + + private final EventMessageDecoder emsgDecoder; + private final TrackOutput delegate; + private final Format delegateFormat; + @MonotonicNonNull private Format format; + + private byte[] buffer; + private int bufferPosition; + + public EmsgUnwrappingTrackOutput( + TrackOutput delegate, @HlsMediaSource.MetadataType int metadataType) { + this.emsgDecoder = new EventMessageDecoder(); + this.delegate = delegate; + switch (metadataType) { + case HlsMediaSource.METADATA_TYPE_ID3: + delegateFormat = ID3_FORMAT; + break; + case HlsMediaSource.METADATA_TYPE_EMSG: + delegateFormat = EMSG_FORMAT; + break; + default: + throw new IllegalArgumentException("Unknown metadataType: " + metadataType); + } + + this.buffer = new byte[0]; + this.bufferPosition = 0; + } + + @Override + public void format(Format format) { + this.format = format; + delegate.format(delegateFormat); + } + + @Override + public int sampleData(ExtractorInput input, int length, boolean allowEndOfInput) + throws IOException, InterruptedException { + ensureBufferCapacity(bufferPosition + length); + int numBytesRead = input.read(buffer, bufferPosition, length); + if (numBytesRead == C.RESULT_END_OF_INPUT) { + if (allowEndOfInput) { + return C.RESULT_END_OF_INPUT; + } else { + throw new EOFException(); + } + } + bufferPosition += numBytesRead; + return numBytesRead; + } + + @Override + public void sampleData(ParsableByteArray buffer, int length) { + ensureBufferCapacity(bufferPosition + length); + buffer.readBytes(this.buffer, bufferPosition, length); + bufferPosition += length; + } + + @Override + public void sampleMetadata( + long timeUs, + @C.BufferFlags int flags, + int size, + int offset, + @Nullable CryptoData cryptoData) { + Assertions.checkNotNull(format); + ParsableByteArray sample = getSampleAndTrimBuffer(size, offset); + ParsableByteArray sampleForDelegate; + if (Util.areEqual(format.sampleMimeType, delegateFormat.sampleMimeType)) { + // Incoming format matches delegate track's format, so pass straight through. + sampleForDelegate = sample; + } else if (MimeTypes.APPLICATION_EMSG.equals(format.sampleMimeType)) { + // Incoming sample is EMSG, and delegate track is not expecting EMSG, so try unwrapping. + EventMessage emsg = emsgDecoder.decode(sample); + if (!emsgContainsExpectedWrappedFormat(emsg)) { + Log.w( + TAG, + String.format( + "Ignoring EMSG. Expected it to contain wrapped %s but actual wrapped format: %s", + delegateFormat.sampleMimeType, emsg.getWrappedMetadataFormat())); + return; + } + sampleForDelegate = + new ParsableByteArray(Assertions.checkNotNull(emsg.getWrappedMetadataBytes())); + } else { + Log.w(TAG, "Ignoring sample for unsupported format: " + format.sampleMimeType); + return; + } + + int sampleSize = sampleForDelegate.bytesLeft(); + + delegate.sampleData(sampleForDelegate, sampleSize); + delegate.sampleMetadata(timeUs, flags, sampleSize, offset, cryptoData); + } + + private boolean emsgContainsExpectedWrappedFormat(EventMessage emsg) { + @Nullable Format wrappedMetadataFormat = emsg.getWrappedMetadataFormat(); + return wrappedMetadataFormat != null + && Util.areEqual(delegateFormat.sampleMimeType, wrappedMetadataFormat.sampleMimeType); + } + + private void ensureBufferCapacity(int requiredLength) { + if (buffer.length < requiredLength) { + buffer = Arrays.copyOf(buffer, requiredLength + requiredLength / 2); + } + } + + /** + * Removes a complete sample from the {@link #buffer} field & reshuffles the tail data skipped + * by {@code offset} to the head of the array. + * + * @param size see {@code size} param of {@link #sampleMetadata}. + * @param offset see {@code offset} param of {@link #sampleMetadata}. + * @return A {@link ParsableByteArray} containing the sample removed from {@link #buffer}. + */ + private ParsableByteArray getSampleAndTrimBuffer(int size, int offset) { + int sampleEnd = bufferPosition - offset; + int sampleStart = sampleEnd - size; + + byte[] sampleBytes = Arrays.copyOfRange(buffer, sampleStart, sampleEnd); + ParsableByteArray sample = new ParsableByteArray(sampleBytes); + + System.arraycopy(buffer, sampleEnd, buffer, 0, offset); + bufferPosition = offset; + return sample; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java new file mode 100644 index 0000000000..681fe57240 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/HlsTrackMetadataEntry.java @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** Holds metadata associated to an HLS media track. */ +public final class HlsTrackMetadataEntry implements Metadata.Entry { + + /** Holds attributes defined in an EXT-X-STREAM-INF tag. */ + public static final class VariantInfo implements Parcelable { + + /** The bitrate as declared by the EXT-X-STREAM-INF tag. */ + public final long bitrate; + + /** + * The VIDEO value as defined in the EXT-X-STREAM-INF tag, or null if the VIDEO attribute is not + * present. + */ + @Nullable public final String videoGroupId; + + /** + * The AUDIO value as defined in the EXT-X-STREAM-INF tag, or null if the AUDIO attribute is not + * present. + */ + @Nullable public final String audioGroupId; + + /** + * The SUBTITLES value as defined in the EXT-X-STREAM-INF tag, or null if the SUBTITLES + * attribute is not present. + */ + @Nullable public final String subtitleGroupId; + + /** + * The CLOSED-CAPTIONS value as defined in the EXT-X-STREAM-INF tag, or null if the + * CLOSED-CAPTIONS attribute is not present. + */ + @Nullable public final String captionGroupId; + + /** + * Creates an instance. + * + * @param bitrate See {@link #bitrate}. + * @param videoGroupId See {@link #videoGroupId}. + * @param audioGroupId See {@link #audioGroupId}. + * @param subtitleGroupId See {@link #subtitleGroupId}. + * @param captionGroupId See {@link #captionGroupId}. + */ + public VariantInfo( + long bitrate, + @Nullable String videoGroupId, + @Nullable String audioGroupId, + @Nullable String subtitleGroupId, + @Nullable String captionGroupId) { + this.bitrate = bitrate; + this.videoGroupId = videoGroupId; + this.audioGroupId = audioGroupId; + this.subtitleGroupId = subtitleGroupId; + this.captionGroupId = captionGroupId; + } + + /* package */ VariantInfo(Parcel in) { + bitrate = in.readLong(); + videoGroupId = in.readString(); + audioGroupId = in.readString(); + subtitleGroupId = in.readString(); + captionGroupId = in.readString(); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + VariantInfo that = (VariantInfo) other; + return bitrate == that.bitrate + && TextUtils.equals(videoGroupId, that.videoGroupId) + && TextUtils.equals(audioGroupId, that.audioGroupId) + && TextUtils.equals(subtitleGroupId, that.subtitleGroupId) + && TextUtils.equals(captionGroupId, that.captionGroupId); + } + + @Override + public int hashCode() { + int result = (int) (bitrate ^ (bitrate >>> 32)); + result = 31 * result + (videoGroupId != null ? videoGroupId.hashCode() : 0); + result = 31 * result + (audioGroupId != null ? audioGroupId.hashCode() : 0); + result = 31 * result + (subtitleGroupId != null ? subtitleGroupId.hashCode() : 0); + result = 31 * result + (captionGroupId != null ? captionGroupId.hashCode() : 0); + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(bitrate); + dest.writeString(videoGroupId); + dest.writeString(audioGroupId); + dest.writeString(subtitleGroupId); + dest.writeString(captionGroupId); + } + + public static final Parcelable.Creator<VariantInfo> CREATOR = + new Parcelable.Creator<VariantInfo>() { + @Override + public VariantInfo createFromParcel(Parcel in) { + return new VariantInfo(in); + } + + @Override + public VariantInfo[] newArray(int size) { + return new VariantInfo[size]; + } + }; + } + + /** + * The GROUP-ID value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the + * track is not derived from an EXT-X-MEDIA TAG. + */ + @Nullable public final String groupId; + /** + * The NAME value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the + * track is not derived from an EXT-X-MEDIA TAG. + */ + @Nullable public final String name; + /** + * The EXT-X-STREAM-INF tags attributes associated with this track. This field is non-applicable + * (and therefore empty) if this track is derived from an EXT-X-MEDIA tag. + */ + public final List<VariantInfo> variantInfos; + + /** + * Creates an instance. + * + * @param groupId See {@link #groupId}. + * @param name See {@link #name}. + * @param variantInfos See {@link #variantInfos}. + */ + public HlsTrackMetadataEntry( + @Nullable String groupId, @Nullable String name, List<VariantInfo> variantInfos) { + this.groupId = groupId; + this.name = name; + this.variantInfos = Collections.unmodifiableList(new ArrayList<>(variantInfos)); + } + + /* package */ HlsTrackMetadataEntry(Parcel in) { + groupId = in.readString(); + name = in.readString(); + int variantInfoSize = in.readInt(); + ArrayList<VariantInfo> variantInfos = new ArrayList<>(variantInfoSize); + for (int i = 0; i < variantInfoSize; i++) { + variantInfos.add(in.readParcelable(VariantInfo.class.getClassLoader())); + } + this.variantInfos = Collections.unmodifiableList(variantInfos); + } + + @Override + public String toString() { + return "HlsTrackMetadataEntry" + (groupId != null ? (" [" + groupId + ", " + name + "]") : ""); + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + + HlsTrackMetadataEntry that = (HlsTrackMetadataEntry) other; + return TextUtils.equals(groupId, that.groupId) + && TextUtils.equals(name, that.name) + && variantInfos.equals(that.variantInfos); + } + + @Override + public int hashCode() { + int result = groupId != null ? groupId.hashCode() : 0; + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + variantInfos.hashCode(); + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(groupId); + dest.writeString(name); + int variantInfosSize = variantInfos.size(); + dest.writeInt(variantInfosSize); + for (int i = 0; i < variantInfosSize; i++) { + dest.writeParcelable(variantInfos.get(i), /* parcelableFlags= */ 0); + } + } + + public static final Parcelable.Creator<HlsTrackMetadataEntry> CREATOR = + new Parcelable.Creator<HlsTrackMetadataEntry>() { + @Override + public HlsTrackMetadataEntry createFromParcel(Parcel in) { + return new HlsTrackMetadataEntry(in); + } + + @Override + public HlsTrackMetadataEntry[] newArray(int size) { + return new HlsTrackMetadataEntry[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java new file mode 100644 index 0000000000..a67a92b4b7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/SampleQueueMappingException.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.SampleQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import java.io.IOException; + +/** Thrown when it is not possible to map a {@link TrackGroup} to a {@link SampleQueue}. */ +public final class SampleQueueMappingException extends IOException { + + /** @param mimeType The mime type of the track group whose mapping failed. */ + public SampleQueueMappingException(@Nullable String mimeType) { + super("Unable to bind a sample queue to TrackGroup with mime type " + mimeType + "."); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java new file mode 100644 index 0000000000..e2a652d05c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/TimestampAdjusterProvider.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.util.SparseArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; + +/** + * Provides {@link TimestampAdjuster} instances for use during HLS playbacks. + */ +public final class TimestampAdjusterProvider { + + // TODO: Prevent this array from growing indefinitely large by removing adjusters that are no + // longer required. + private final SparseArray<TimestampAdjuster> timestampAdjusters; + + public TimestampAdjusterProvider() { + timestampAdjusters = new SparseArray<>(); + } + + /** + * Returns a {@link TimestampAdjuster} suitable for adjusting the pts timestamps contained in + * a chunk with a given discontinuity sequence. + * + * @param discontinuitySequence The chunk's discontinuity sequence. + * @return A {@link TimestampAdjuster}. + */ + public TimestampAdjuster getAdjuster(int discontinuitySequence) { + TimestampAdjuster adjuster = timestampAdjusters.get(discontinuitySequence); + if (adjuster == null) { + adjuster = new TimestampAdjuster(TimestampAdjuster.DO_NOT_OFFSET); + timestampAdjusters.put(discontinuitySequence, adjuster); + } + return adjuster; + } + + /** + * Resets the provider. + */ + public void reset() { + timestampAdjusters.clear(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java new file mode 100644 index 0000000000..1d5e669a03 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/WebvttExtractor.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.Extractor; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorInput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ExtractorOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.PositionHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.SeekMap; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.WebvttParserUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimestampAdjuster; +import java.io.IOException; +import java.util.Arrays; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; + +/** + * A special purpose extractor for WebVTT content in HLS. + * + * <p>This extractor passes through non-empty WebVTT files untouched, however derives the correct + * sample timestamp for each by sniffing the X-TIMESTAMP-MAP header along with the start timestamp + * of the first cue header. Empty WebVTT files are not passed through, since it's not possible to + * derive a sample timestamp in this case. + */ +public final class WebvttExtractor implements Extractor { + + private static final Pattern LOCAL_TIMESTAMP = Pattern.compile("LOCAL:([^,]+)"); + private static final Pattern MEDIA_TIMESTAMP = Pattern.compile("MPEGTS:(-?\\d+)"); + private static final int HEADER_MIN_LENGTH = 6 /* "WEBVTT" */; + private static final int HEADER_MAX_LENGTH = 3 /* optional Byte Order Mark */ + HEADER_MIN_LENGTH; + + @Nullable private final String language; + private final TimestampAdjuster timestampAdjuster; + private final ParsableByteArray sampleDataWrapper; + + private @MonotonicNonNull ExtractorOutput output; + + private byte[] sampleData; + private int sampleSize; + + public WebvttExtractor(@Nullable String language, TimestampAdjuster timestampAdjuster) { + this.language = language; + this.timestampAdjuster = timestampAdjuster; + this.sampleDataWrapper = new ParsableByteArray(); + sampleData = new byte[1024]; + } + + // Extractor implementation. + + @Override + public boolean sniff(ExtractorInput input) throws IOException, InterruptedException { + // Check whether there is a header without BOM. + input.peekFully( + sampleData, /* offset= */ 0, /* length= */ HEADER_MIN_LENGTH, /* allowEndOfInput= */ false); + sampleDataWrapper.reset(sampleData, HEADER_MIN_LENGTH); + if (WebvttParserUtil.isWebvttHeaderLine(sampleDataWrapper)) { + return true; + } + // The header did not match, try including the BOM. + input.peekFully( + sampleData, + /* offset= */ HEADER_MIN_LENGTH, + HEADER_MAX_LENGTH - HEADER_MIN_LENGTH, + /* allowEndOfInput= */ false); + sampleDataWrapper.reset(sampleData, HEADER_MAX_LENGTH); + return WebvttParserUtil.isWebvttHeaderLine(sampleDataWrapper); + } + + @Override + public void init(ExtractorOutput output) { + this.output = output; + output.seekMap(new SeekMap.Unseekable(C.TIME_UNSET)); + } + + @Override + public void seek(long position, long timeUs) { + // This extractor is only used for the HLS use case, which should not call this method. + throw new IllegalStateException(); + } + + @Override + public void release() { + // Do nothing + } + + @Override + public int read(ExtractorInput input, PositionHolder seekPosition) + throws IOException, InterruptedException { + // output == null suggests init() hasn't been called + Assertions.checkNotNull(output); + int currentFileSize = (int) input.getLength(); + + // Increase the size of sampleData if necessary. + if (sampleSize == sampleData.length) { + sampleData = Arrays.copyOf(sampleData, + (currentFileSize != C.LENGTH_UNSET ? currentFileSize : sampleData.length) * 3 / 2); + } + + // Consume to the input. + int bytesRead = input.read(sampleData, sampleSize, sampleData.length - sampleSize); + if (bytesRead != C.RESULT_END_OF_INPUT) { + sampleSize += bytesRead; + if (currentFileSize == C.LENGTH_UNSET || sampleSize != currentFileSize) { + return Extractor.RESULT_CONTINUE; + } + } + + // We've reached the end of the input, which corresponds to the end of the current file. + processSample(); + return Extractor.RESULT_END_OF_INPUT; + } + + @RequiresNonNull("output") + private void processSample() throws ParserException { + ParsableByteArray webvttData = new ParsableByteArray(sampleData); + + // Validate the first line of the header. + WebvttParserUtil.validateWebvttHeaderLine(webvttData); + + // Defaults to use if the header doesn't contain an X-TIMESTAMP-MAP header. + long vttTimestampUs = 0; + long tsTimestampUs = 0; + + // Parse the remainder of the header looking for X-TIMESTAMP-MAP. + for (String line = webvttData.readLine(); + !TextUtils.isEmpty(line); + line = webvttData.readLine()) { + if (line.startsWith("X-TIMESTAMP-MAP")) { + Matcher localTimestampMatcher = LOCAL_TIMESTAMP.matcher(line); + if (!localTimestampMatcher.find()) { + throw new ParserException("X-TIMESTAMP-MAP doesn't contain local timestamp: " + line); + } + Matcher mediaTimestampMatcher = MEDIA_TIMESTAMP.matcher(line); + if (!mediaTimestampMatcher.find()) { + throw new ParserException("X-TIMESTAMP-MAP doesn't contain media timestamp: " + line); + } + vttTimestampUs = WebvttParserUtil.parseTimestampUs(localTimestampMatcher.group(1)); + tsTimestampUs = TimestampAdjuster.ptsToUs(Long.parseLong(mediaTimestampMatcher.group(1))); + } + } + + // Find the first cue header and parse the start time. + Matcher cueHeaderMatcher = WebvttParserUtil.findNextCueHeader(webvttData); + if (cueHeaderMatcher == null) { + // No cues found. Don't output a sample, but still output a corresponding track. + buildTrackOutput(0); + return; + } + + long firstCueTimeUs = WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1)); + long sampleTimeUs = timestampAdjuster.adjustTsTimestamp( + TimestampAdjuster.usToPts(firstCueTimeUs + tsTimestampUs - vttTimestampUs)); + long subsampleOffsetUs = sampleTimeUs - firstCueTimeUs; + // Output the track. + TrackOutput trackOutput = buildTrackOutput(subsampleOffsetUs); + // Output the sample. + sampleDataWrapper.reset(sampleData, sampleSize); + trackOutput.sampleData(sampleDataWrapper, sampleSize); + trackOutput.sampleMetadata(sampleTimeUs, C.BUFFER_FLAG_KEY_FRAME, sampleSize, 0, null); + } + + @RequiresNonNull("output") + private TrackOutput buildTrackOutput(long subsampleOffsetUs) { + TrackOutput trackOutput = output.track(0, C.TRACK_TYPE_TEXT); + trackOutput.format(Format.createTextSampleFormat(null, MimeTypes.TEXT_VTT, null, + Format.NO_VALUE, 0, language, null, subsampleOffsetUs)); + output.endTracks(); + return trackOutput; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java new file mode 100644 index 0000000000..636100a8a9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/HlsDownloader.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.offline; + +import android.net.Uri; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.DownloaderConstructorHelper; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.SegmentDownloader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylist; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; + +/** + * A downloader for HLS streams. + * + * <p>Example usage: + * + * <pre>{@code + * SimpleCache cache = new SimpleCache(downloadFolder, new NoOpCacheEvictor(), databaseProvider); + * DefaultHttpDataSourceFactory factory = new DefaultHttpDataSourceFactory("ExoPlayer", null); + * DownloaderConstructorHelper constructorHelper = + * new DownloaderConstructorHelper(cache, factory); + * // Create a downloader for the first variant in a master playlist. + * HlsDownloader hlsDownloader = + * new HlsDownloader( + * playlistUri, + * Collections.singletonList(new StreamKey(HlsMasterPlaylist.GROUP_INDEX_VARIANT, 0)), + * constructorHelper); + * // Perform the download. + * hlsDownloader.download(progressListener); + * // Access downloaded data using CacheDataSource + * CacheDataSource cacheDataSource = + * new CacheDataSource(cache, factory.createDataSource(), CacheDataSource.FLAG_BLOCK_ON_CACHE); + * }</pre> + */ +public final class HlsDownloader extends SegmentDownloader<HlsPlaylist> { + + /** + * @param playlistUri The {@link Uri} of the playlist to be downloaded. + * @param streamKeys Keys defining which renditions in the playlist should be selected for + * download. If empty, all renditions are downloaded. + * @param constructorHelper A {@link DownloaderConstructorHelper} instance. + */ + public HlsDownloader( + Uri playlistUri, List<StreamKey> streamKeys, DownloaderConstructorHelper constructorHelper) { + super(playlistUri, streamKeys, constructorHelper); + } + + @Override + protected HlsPlaylist getManifest(DataSource dataSource, DataSpec dataSpec) throws IOException { + return loadManifest(dataSource, dataSpec); + } + + @Override + protected List<Segment> getSegments( + DataSource dataSource, HlsPlaylist playlist, boolean allowIncompleteList) throws IOException { + ArrayList<DataSpec> mediaPlaylistDataSpecs = new ArrayList<>(); + if (playlist instanceof HlsMasterPlaylist) { + HlsMasterPlaylist masterPlaylist = (HlsMasterPlaylist) playlist; + addMediaPlaylistDataSpecs(masterPlaylist.mediaPlaylistUrls, mediaPlaylistDataSpecs); + } else { + mediaPlaylistDataSpecs.add( + SegmentDownloader.getCompressibleDataSpec(Uri.parse(playlist.baseUri))); + } + + ArrayList<Segment> segments = new ArrayList<>(); + HashSet<Uri> seenEncryptionKeyUris = new HashSet<>(); + for (DataSpec mediaPlaylistDataSpec : mediaPlaylistDataSpecs) { + segments.add(new Segment(/* startTimeUs= */ 0, mediaPlaylistDataSpec)); + HlsMediaPlaylist mediaPlaylist; + try { + mediaPlaylist = (HlsMediaPlaylist) loadManifest(dataSource, mediaPlaylistDataSpec); + } catch (IOException e) { + if (!allowIncompleteList) { + throw e; + } + // Generating an incomplete segment list is allowed. Advance to the next media playlist. + continue; + } + HlsMediaPlaylist.Segment lastInitSegment = null; + List<HlsMediaPlaylist.Segment> hlsSegments = mediaPlaylist.segments; + for (int i = 0; i < hlsSegments.size(); i++) { + HlsMediaPlaylist.Segment segment = hlsSegments.get(i); + HlsMediaPlaylist.Segment initSegment = segment.initializationSegment; + if (initSegment != null && initSegment != lastInitSegment) { + lastInitSegment = initSegment; + addSegment(mediaPlaylist, initSegment, seenEncryptionKeyUris, segments); + } + addSegment(mediaPlaylist, segment, seenEncryptionKeyUris, segments); + } + } + return segments; + } + + private void addMediaPlaylistDataSpecs(List<Uri> mediaPlaylistUrls, List<DataSpec> out) { + for (int i = 0; i < mediaPlaylistUrls.size(); i++) { + out.add(SegmentDownloader.getCompressibleDataSpec(mediaPlaylistUrls.get(i))); + } + } + + private static HlsPlaylist loadManifest(DataSource dataSource, DataSpec dataSpec) + throws IOException { + return ParsingLoadable.load( + dataSource, new HlsPlaylistParser(), dataSpec, C.DATA_TYPE_MANIFEST); + } + + private void addSegment( + HlsMediaPlaylist mediaPlaylist, + HlsMediaPlaylist.Segment segment, + HashSet<Uri> seenEncryptionKeyUris, + ArrayList<Segment> out) { + String baseUri = mediaPlaylist.baseUri; + long startTimeUs = mediaPlaylist.startTimeUs + segment.relativeStartTimeUs; + if (segment.fullSegmentEncryptionKeyUri != null) { + Uri keyUri = UriUtil.resolveToUri(baseUri, segment.fullSegmentEncryptionKeyUri); + if (seenEncryptionKeyUris.add(keyUri)) { + out.add(new Segment(startTimeUs, SegmentDownloader.getCompressibleDataSpec(keyUri))); + } + } + Uri segmentUri = UriUtil.resolveToUri(baseUri, segment.url); + DataSpec dataSpec = + new DataSpec(segmentUri, segment.byterangeOffset, segment.byterangeLength, /* key= */ null); + out.add(new Segment(startTimeUs, dataSpec)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java new file mode 100644 index 0000000000..669bd44c89 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/offline/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.offline; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java new file mode 100644 index 0000000000..89882bb596 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java new file mode 100644 index 0000000000..394a97a56a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistParserFactory.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; + +/** Default implementation for {@link HlsPlaylistParserFactory}. */ +public final class DefaultHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() { + return new HlsPlaylistParser(); + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser( + HlsMasterPlaylist masterPlaylist) { + return new HlsPlaylistParser(masterPlaylist); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java new file mode 100644 index 0000000000..b7f6a06975 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/DefaultHlsPlaylistTracker.java @@ -0,0 +1,678 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.os.Handler; +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.LoadErrorAction; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** Default implementation for {@link HlsPlaylistTracker}. */ +public final class DefaultHlsPlaylistTracker + implements HlsPlaylistTracker, Loader.Callback<ParsingLoadable<HlsPlaylist>> { + + /** Factory for {@link DefaultHlsPlaylistTracker} instances. */ + public static final Factory FACTORY = DefaultHlsPlaylistTracker::new; + + /** + * Default coefficient applied on the target duration of a playlist to determine the amount of + * time after which an unchanging playlist is considered stuck. + */ + public static final double DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 3.5; + + private final HlsDataSourceFactory dataSourceFactory; + private final HlsPlaylistParserFactory playlistParserFactory; + private final LoadErrorHandlingPolicy loadErrorHandlingPolicy; + private final HashMap<Uri, MediaPlaylistBundle> playlistBundles; + private final List<PlaylistEventListener> listeners; + private final double playlistStuckTargetDurationCoefficient; + + @Nullable private ParsingLoadable.Parser<HlsPlaylist> mediaPlaylistParser; + @Nullable private EventDispatcher eventDispatcher; + @Nullable private Loader initialPlaylistLoader; + @Nullable private Handler playlistRefreshHandler; + @Nullable private PrimaryPlaylistListener primaryPlaylistListener; + @Nullable private HlsMasterPlaylist masterPlaylist; + @Nullable private Uri primaryMediaPlaylistUrl; + @Nullable private HlsMediaPlaylist primaryMediaPlaylistSnapshot; + private boolean isLive; + private long initialStartTimeUs; + + /** + * Creates an instance. + * + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + */ + public DefaultHlsPlaylistTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory) { + this( + dataSourceFactory, + loadErrorHandlingPolicy, + playlistParserFactory, + DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT); + } + + /** + * Creates an instance. + * + * @param dataSourceFactory A factory for {@link DataSource} instances. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy}. + * @param playlistParserFactory An {@link HlsPlaylistParserFactory}. + * @param playlistStuckTargetDurationCoefficient A coefficient to apply to the target duration of + * media playlists in order to determine that a non-changing playlist is stuck. Once a + * playlist is deemed stuck, a {@link PlaylistStuckException} is thrown via {@link + * #maybeThrowPlaylistRefreshError(Uri)}. + */ + public DefaultHlsPlaylistTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory, + double playlistStuckTargetDurationCoefficient) { + this.dataSourceFactory = dataSourceFactory; + this.playlistParserFactory = playlistParserFactory; + this.loadErrorHandlingPolicy = loadErrorHandlingPolicy; + this.playlistStuckTargetDurationCoefficient = playlistStuckTargetDurationCoefficient; + listeners = new ArrayList<>(); + playlistBundles = new HashMap<>(); + initialStartTimeUs = C.TIME_UNSET; + } + + // HlsPlaylistTracker implementation. + + @Override + public void start( + Uri initialPlaylistUri, + EventDispatcher eventDispatcher, + PrimaryPlaylistListener primaryPlaylistListener) { + this.playlistRefreshHandler = new Handler(); + this.eventDispatcher = eventDispatcher; + this.primaryPlaylistListener = primaryPlaylistListener; + ParsingLoadable<HlsPlaylist> masterPlaylistLoadable = + new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + initialPlaylistUri, + C.DATA_TYPE_MANIFEST, + playlistParserFactory.createPlaylistParser()); + Assertions.checkState(initialPlaylistLoader == null); + initialPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MasterPlaylist"); + long elapsedRealtime = + initialPlaylistLoader.startLoading( + masterPlaylistLoadable, + this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(masterPlaylistLoadable.type)); + eventDispatcher.loadStarted( + masterPlaylistLoadable.dataSpec, + masterPlaylistLoadable.type, + elapsedRealtime); + } + + @Override + public void stop() { + primaryMediaPlaylistUrl = null; + primaryMediaPlaylistSnapshot = null; + masterPlaylist = null; + initialStartTimeUs = C.TIME_UNSET; + initialPlaylistLoader.release(); + initialPlaylistLoader = null; + for (MediaPlaylistBundle bundle : playlistBundles.values()) { + bundle.release(); + } + playlistRefreshHandler.removeCallbacksAndMessages(null); + playlistRefreshHandler = null; + playlistBundles.clear(); + } + + @Override + public void addListener(PlaylistEventListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(PlaylistEventListener listener) { + listeners.remove(listener); + } + + @Override + @Nullable + public HlsMasterPlaylist getMasterPlaylist() { + return masterPlaylist; + } + + @Override + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback) { + HlsMediaPlaylist snapshot = playlistBundles.get(url).getPlaylistSnapshot(); + if (snapshot != null && isForPlayback) { + maybeSetPrimaryUrl(url); + } + return snapshot; + } + + @Override + public long getInitialStartTimeUs() { + return initialStartTimeUs; + } + + @Override + public boolean isSnapshotValid(Uri url) { + return playlistBundles.get(url).isSnapshotValid(); + } + + @Override + public void maybeThrowPrimaryPlaylistRefreshError() throws IOException { + if (initialPlaylistLoader != null) { + initialPlaylistLoader.maybeThrowError(); + } + if (primaryMediaPlaylistUrl != null) { + maybeThrowPlaylistRefreshError(primaryMediaPlaylistUrl); + } + } + + @Override + public void maybeThrowPlaylistRefreshError(Uri url) throws IOException { + playlistBundles.get(url).maybeThrowPlaylistRefreshError(); + } + + @Override + public void refreshPlaylist(Uri url) { + playlistBundles.get(url).loadPlaylist(); + } + + @Override + public boolean isLive() { + return isLive; + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted( + ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + HlsMasterPlaylist masterPlaylist; + boolean isMediaPlaylist = result instanceof HlsMediaPlaylist; + if (isMediaPlaylist) { + masterPlaylist = HlsMasterPlaylist.createSingleVariantMasterPlaylist(result.baseUri); + } else /* result instanceof HlsMasterPlaylist */ { + masterPlaylist = (HlsMasterPlaylist) result; + } + this.masterPlaylist = masterPlaylist; + mediaPlaylistParser = playlistParserFactory.createPlaylistParser(masterPlaylist); + primaryMediaPlaylistUrl = masterPlaylist.variants.get(0).url; + createBundles(masterPlaylist.mediaPlaylistUrls); + MediaPlaylistBundle primaryBundle = playlistBundles.get(primaryMediaPlaylistUrl); + if (isMediaPlaylist) { + // We don't need to load the playlist again. We can use the same result. + primaryBundle.processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + } else { + primaryBundle.loadPlaylist(); + } + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public void onLoadCanceled( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public LoadErrorAction onLoadError( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + long retryDelayMs = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + boolean isFatal = retryDelayMs == C.TIME_UNSET; + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded(), + error, + isFatal); + return isFatal + ? Loader.DONT_RETRY_FATAL + : Loader.createRetryAction(/* resetErrorCount= */ false, retryDelayMs); + } + + // Internal methods. + + private boolean maybeSelectNewPrimaryUrl() { + List<Variant> variants = masterPlaylist.variants; + int variantsSize = variants.size(); + long currentTimeMs = SystemClock.elapsedRealtime(); + for (int i = 0; i < variantsSize; i++) { + MediaPlaylistBundle bundle = playlistBundles.get(variants.get(i).url); + if (currentTimeMs > bundle.blacklistUntilMs) { + primaryMediaPlaylistUrl = bundle.playlistUrl; + bundle.loadPlaylist(); + return true; + } + } + return false; + } + + private void maybeSetPrimaryUrl(Uri url) { + if (url.equals(primaryMediaPlaylistUrl) + || !isVariantUrl(url) + || (primaryMediaPlaylistSnapshot != null && primaryMediaPlaylistSnapshot.hasEndTag)) { + // Ignore if the primary media playlist URL is unchanged, if the media playlist is not + // referenced directly by a variant, or it the last primary snapshot contains an end tag. + return; + } + primaryMediaPlaylistUrl = url; + playlistBundles.get(primaryMediaPlaylistUrl).loadPlaylist(); + } + + /** Returns whether any of the variants in the master playlist have the specified playlist URL. */ + private boolean isVariantUrl(Uri playlistUrl) { + List<Variant> variants = masterPlaylist.variants; + for (int i = 0; i < variants.size(); i++) { + if (playlistUrl.equals(variants.get(i).url)) { + return true; + } + } + return false; + } + + private void createBundles(List<Uri> urls) { + int listSize = urls.size(); + for (int i = 0; i < listSize; i++) { + Uri url = urls.get(i); + MediaPlaylistBundle bundle = new MediaPlaylistBundle(url); + playlistBundles.put(url, bundle); + } + } + + /** + * Called by the bundles when a snapshot changes. + * + * @param url The url of the playlist. + * @param newSnapshot The new snapshot. + */ + private void onPlaylistUpdated(Uri url, HlsMediaPlaylist newSnapshot) { + if (url.equals(primaryMediaPlaylistUrl)) { + if (primaryMediaPlaylistSnapshot == null) { + // This is the first primary url snapshot. + isLive = !newSnapshot.hasEndTag; + initialStartTimeUs = newSnapshot.startTimeUs; + } + primaryMediaPlaylistSnapshot = newSnapshot; + primaryPlaylistListener.onPrimaryPlaylistRefreshed(newSnapshot); + } + int listenersSize = listeners.size(); + for (int i = 0; i < listenersSize; i++) { + listeners.get(i).onPlaylistChanged(); + } + } + + private boolean notifyPlaylistError(Uri playlistUrl, long blacklistDurationMs) { + int listenersSize = listeners.size(); + boolean anyBlacklistingFailed = false; + for (int i = 0; i < listenersSize; i++) { + anyBlacklistingFailed |= !listeners.get(i).onPlaylistError(playlistUrl, blacklistDurationMs); + } + return anyBlacklistingFailed; + } + + private HlsMediaPlaylist getLatestPlaylistSnapshot( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (!loadedPlaylist.isNewerThan(oldPlaylist)) { + if (loadedPlaylist.hasEndTag) { + // If the loaded playlist has an end tag but is not newer than the old playlist then we have + // an inconsistent state. This is typically caused by the server incorrectly resetting the + // media sequence when appending the end tag. We resolve this case as best we can by + // returning the old playlist with the end tag appended. + return oldPlaylist.copyWithEndTag(); + } else { + return oldPlaylist; + } + } + long startTimeUs = getLoadedPlaylistStartTimeUs(oldPlaylist, loadedPlaylist); + int discontinuitySequence = getLoadedPlaylistDiscontinuitySequence(oldPlaylist, loadedPlaylist); + return loadedPlaylist.copyWith(startTimeUs, discontinuitySequence); + } + + private long getLoadedPlaylistStartTimeUs( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasProgramDateTime) { + return loadedPlaylist.startTimeUs; + } + long primarySnapshotStartTimeUs = + primaryMediaPlaylistSnapshot != null ? primaryMediaPlaylistSnapshot.startTimeUs : 0; + if (oldPlaylist == null) { + return primarySnapshotStartTimeUs; + } + int oldPlaylistSize = oldPlaylist.segments.size(); + Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.startTimeUs + firstOldOverlappingSegment.relativeStartTimeUs; + } else if (oldPlaylistSize == loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence) { + return oldPlaylist.getEndTimeUs(); + } else { + // No segments overlap, we assume the new playlist start coincides with the primary playlist. + return primarySnapshotStartTimeUs; + } + } + + private int getLoadedPlaylistDiscontinuitySequence( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + if (loadedPlaylist.hasDiscontinuitySequence) { + return loadedPlaylist.discontinuitySequence; + } + // TODO: Improve cross-playlist discontinuity adjustment. + int primaryUrlDiscontinuitySequence = + primaryMediaPlaylistSnapshot != null + ? primaryMediaPlaylistSnapshot.discontinuitySequence + : 0; + if (oldPlaylist == null) { + return primaryUrlDiscontinuitySequence; + } + Segment firstOldOverlappingSegment = getFirstOldOverlappingSegment(oldPlaylist, loadedPlaylist); + if (firstOldOverlappingSegment != null) { + return oldPlaylist.discontinuitySequence + + firstOldOverlappingSegment.relativeDiscontinuitySequence + - loadedPlaylist.segments.get(0).relativeDiscontinuitySequence; + } + return primaryUrlDiscontinuitySequence; + } + + private static Segment getFirstOldOverlappingSegment( + HlsMediaPlaylist oldPlaylist, HlsMediaPlaylist loadedPlaylist) { + int mediaSequenceOffset = (int) (loadedPlaylist.mediaSequence - oldPlaylist.mediaSequence); + List<Segment> oldSegments = oldPlaylist.segments; + return mediaSequenceOffset < oldSegments.size() ? oldSegments.get(mediaSequenceOffset) : null; + } + + /** Holds all information related to a specific Media Playlist. */ + private final class MediaPlaylistBundle + implements Loader.Callback<ParsingLoadable<HlsPlaylist>>, Runnable { + + private final Uri playlistUrl; + private final Loader mediaPlaylistLoader; + private final ParsingLoadable<HlsPlaylist> mediaPlaylistLoadable; + + @Nullable private HlsMediaPlaylist playlistSnapshot; + private long lastSnapshotLoadMs; + private long lastSnapshotChangeMs; + private long earliestNextLoadTimeMs; + private long blacklistUntilMs; + private boolean loadPending; + private IOException playlistError; + + public MediaPlaylistBundle(Uri playlistUrl) { + this.playlistUrl = playlistUrl; + mediaPlaylistLoader = new Loader("DefaultHlsPlaylistTracker:MediaPlaylist"); + mediaPlaylistLoadable = + new ParsingLoadable<>( + dataSourceFactory.createDataSource(C.DATA_TYPE_MANIFEST), + playlistUrl, + C.DATA_TYPE_MANIFEST, + mediaPlaylistParser); + } + + @Nullable + public HlsMediaPlaylist getPlaylistSnapshot() { + return playlistSnapshot; + } + + public boolean isSnapshotValid() { + if (playlistSnapshot == null) { + return false; + } + long currentTimeMs = SystemClock.elapsedRealtime(); + long snapshotValidityDurationMs = Math.max(30000, C.usToMs(playlistSnapshot.durationUs)); + return playlistSnapshot.hasEndTag + || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_EVENT + || playlistSnapshot.playlistType == HlsMediaPlaylist.PLAYLIST_TYPE_VOD + || lastSnapshotLoadMs + snapshotValidityDurationMs > currentTimeMs; + } + + public void release() { + mediaPlaylistLoader.release(); + } + + public void loadPlaylist() { + blacklistUntilMs = 0; + if (loadPending || mediaPlaylistLoader.isLoading() || mediaPlaylistLoader.hasFatalError()) { + // Load already pending, in progress, or a fatal error has been encountered. Do nothing. + return; + } + long currentTimeMs = SystemClock.elapsedRealtime(); + if (currentTimeMs < earliestNextLoadTimeMs) { + loadPending = true; + playlistRefreshHandler.postDelayed(this, earliestNextLoadTimeMs - currentTimeMs); + } else { + loadPlaylistImmediately(); + } + } + + public void maybeThrowPlaylistRefreshError() throws IOException { + mediaPlaylistLoader.maybeThrowError(); + if (playlistError != null) { + throw playlistError; + } + } + + // Loader.Callback implementation. + + @Override + public void onLoadCompleted( + ParsingLoadable<HlsPlaylist> loadable, long elapsedRealtimeMs, long loadDurationMs) { + HlsPlaylist result = loadable.getResult(); + if (result instanceof HlsMediaPlaylist) { + processLoadedPlaylist((HlsMediaPlaylist) result, loadDurationMs); + eventDispatcher.loadCompleted( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } else { + playlistError = new ParserException("Loaded playlist has unexpected type."); + } + } + + @Override + public void onLoadCanceled( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + boolean released) { + eventDispatcher.loadCanceled( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded()); + } + + @Override + public LoadErrorAction onLoadError( + ParsingLoadable<HlsPlaylist> loadable, + long elapsedRealtimeMs, + long loadDurationMs, + IOException error, + int errorCount) { + LoadErrorAction loadErrorAction; + + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + loadable.type, loadDurationMs, error, errorCount); + boolean shouldBlacklist = blacklistDurationMs != C.TIME_UNSET; + + boolean blacklistingFailed = + notifyPlaylistError(playlistUrl, blacklistDurationMs) || !shouldBlacklist; + if (shouldBlacklist) { + blacklistingFailed |= blacklistPlaylist(blacklistDurationMs); + } + + if (blacklistingFailed) { + long retryDelay = + loadErrorHandlingPolicy.getRetryDelayMsFor( + loadable.type, loadDurationMs, error, errorCount); + loadErrorAction = + retryDelay != C.TIME_UNSET + ? Loader.createRetryAction(false, retryDelay) + : Loader.DONT_RETRY_FATAL; + } else { + loadErrorAction = Loader.DONT_RETRY; + } + + eventDispatcher.loadError( + loadable.dataSpec, + loadable.getUri(), + loadable.getResponseHeaders(), + C.DATA_TYPE_MANIFEST, + elapsedRealtimeMs, + loadDurationMs, + loadable.bytesLoaded(), + error, + /* wasCanceled= */ !loadErrorAction.isRetry()); + + return loadErrorAction; + } + + // Runnable implementation. + + @Override + public void run() { + loadPending = false; + loadPlaylistImmediately(); + } + + // Internal methods. + + private void loadPlaylistImmediately() { + long elapsedRealtime = + mediaPlaylistLoader.startLoading( + mediaPlaylistLoadable, + this, + loadErrorHandlingPolicy.getMinimumLoadableRetryCount(mediaPlaylistLoadable.type)); + eventDispatcher.loadStarted( + mediaPlaylistLoadable.dataSpec, + mediaPlaylistLoadable.type, + elapsedRealtime); + } + + private void processLoadedPlaylist(HlsMediaPlaylist loadedPlaylist, long loadDurationMs) { + HlsMediaPlaylist oldPlaylist = playlistSnapshot; + long currentTimeMs = SystemClock.elapsedRealtime(); + lastSnapshotLoadMs = currentTimeMs; + playlistSnapshot = getLatestPlaylistSnapshot(oldPlaylist, loadedPlaylist); + if (playlistSnapshot != oldPlaylist) { + playlistError = null; + lastSnapshotChangeMs = currentTimeMs; + onPlaylistUpdated(playlistUrl, playlistSnapshot); + } else if (!playlistSnapshot.hasEndTag) { + if (loadedPlaylist.mediaSequence + loadedPlaylist.segments.size() + < playlistSnapshot.mediaSequence) { + // TODO: Allow customization of playlist resets handling. + // The media sequence jumped backwards. The server has probably reset. We do not try + // blacklisting in this case. + playlistError = new PlaylistResetException(playlistUrl); + notifyPlaylistError(playlistUrl, C.TIME_UNSET); + } else if (currentTimeMs - lastSnapshotChangeMs + > C.usToMs(playlistSnapshot.targetDurationUs) + * playlistStuckTargetDurationCoefficient) { + // TODO: Allow customization of stuck playlists handling. + playlistError = new PlaylistStuckException(playlistUrl); + long blacklistDurationMs = + loadErrorHandlingPolicy.getBlacklistDurationMsFor( + C.DATA_TYPE_MANIFEST, loadDurationMs, playlistError, /* errorCount= */ 1); + notifyPlaylistError(playlistUrl, blacklistDurationMs); + if (blacklistDurationMs != C.TIME_UNSET) { + blacklistPlaylist(blacklistDurationMs); + } + } + } + // Do not allow the playlist to load again within the target duration if we obtained a new + // snapshot, or half the target duration otherwise. + earliestNextLoadTimeMs = + currentTimeMs + + C.usToMs( + playlistSnapshot != oldPlaylist + ? playlistSnapshot.targetDurationUs + : (playlistSnapshot.targetDurationUs / 2)); + // Schedule a load if this is the primary playlist and it doesn't have an end tag. Else the + // next load will be scheduled when refreshPlaylist is called, or when this playlist becomes + // the primary. + if (playlistUrl.equals(primaryMediaPlaylistUrl) && !playlistSnapshot.hasEndTag) { + loadPlaylist(); + } + } + + /** + * Blacklists the playlist. + * + * @param blacklistDurationMs The number of milliseconds for which the playlist should be + * blacklisted. + * @return Whether the playlist is the primary, despite being blacklisted. + */ + private boolean blacklistPlaylist(long blacklistDurationMs) { + blacklistUntilMs = SystemClock.elapsedRealtime() + blacklistDurationMs; + return playlistUrl.equals(primaryMediaPlaylistUrl) && !maybeSelectNewPrimaryUrl(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java new file mode 100644 index 0000000000..a8c9ea1756 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/FilteringHlsPlaylistParserFactory.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilteringManifestParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import java.util.List; + +/** + * A {@link HlsPlaylistParserFactory} that includes only the streams identified by the given stream + * keys. + */ +public final class FilteringHlsPlaylistParserFactory implements HlsPlaylistParserFactory { + + private final HlsPlaylistParserFactory hlsPlaylistParserFactory; + private final List<StreamKey> streamKeys; + + /** + * @param hlsPlaylistParserFactory A factory for the parsers of the playlists which will be + * filtered. + * @param streamKeys The stream keys. If null or empty then filtering will not occur. + */ + public FilteringHlsPlaylistParserFactory( + HlsPlaylistParserFactory hlsPlaylistParserFactory, List<StreamKey> streamKeys) { + this.hlsPlaylistParserFactory = hlsPlaylistParserFactory; + this.streamKeys = streamKeys; + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser() { + return new FilteringManifestParser<>( + hlsPlaylistParserFactory.createPlaylistParser(), streamKeys); + } + + @Override + public ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser( + HlsMasterPlaylist masterPlaylist) { + return new FilteringManifestParser<>( + hlsPlaylistParserFactory.createPlaylistParser(masterPlaylist), streamKeys); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java new file mode 100644 index 0000000000..376f2b4301 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMasterPlaylist.java @@ -0,0 +1,330 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** Represents an HLS master playlist. */ +public final class HlsMasterPlaylist extends HlsPlaylist { + + /** Represents an empty master playlist, from which no attributes can be inherited. */ + public static final HlsMasterPlaylist EMPTY = + new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + /* variants= */ Collections.emptyList(), + /* videos= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* closedCaptions= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ Collections.emptyList(), + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap(), + /* sessionKeyDrmInitData= */ Collections.emptyList()); + + // These constants must not be changed because they are persisted in offline stream keys. + public static final int GROUP_INDEX_VARIANT = 0; + public static final int GROUP_INDEX_AUDIO = 1; + public static final int GROUP_INDEX_SUBTITLE = 2; + + /** A variant (i.e. an #EXT-X-STREAM-INF tag) in a master playlist. */ + public static final class Variant { + + /** The variant's url. */ + public final Uri url; + + /** Format information associated with this variant. */ + public final Format format; + + /** The video rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String videoGroupId; + + /** The audio rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String audioGroupId; + + /** The subtitle rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String subtitleGroupId; + + /** The caption rendition group referenced by this variant, or {@code null}. */ + @Nullable public final String captionGroupId; + + /** + * @param url See {@link #url}. + * @param format See {@link #format}. + * @param videoGroupId See {@link #videoGroupId}. + * @param audioGroupId See {@link #audioGroupId}. + * @param subtitleGroupId See {@link #subtitleGroupId}. + * @param captionGroupId See {@link #captionGroupId}. + */ + public Variant( + Uri url, + Format format, + @Nullable String videoGroupId, + @Nullable String audioGroupId, + @Nullable String subtitleGroupId, + @Nullable String captionGroupId) { + this.url = url; + this.format = format; + this.videoGroupId = videoGroupId; + this.audioGroupId = audioGroupId; + this.subtitleGroupId = subtitleGroupId; + this.captionGroupId = captionGroupId; + } + + /** + * Creates a variant for a given media playlist url. + * + * @param url The media playlist url. + * @return The variant instance. + */ + public static Variant createMediaPlaylistVariantUrl(Uri url) { + Format format = + Format.createContainerFormat( + "0", + /* label= */ null, + MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + /* selectionFlags= */ 0, + /* roleFlags= */ 0, + /* language= */ null); + return new Variant( + url, + format, + /* videoGroupId= */ null, + /* audioGroupId= */ null, + /* subtitleGroupId= */ null, + /* captionGroupId= */ null); + } + + /** Returns a copy of this instance with the given {@link Format}. */ + public Variant copyWithFormat(Format format) { + return new Variant(url, format, videoGroupId, audioGroupId, subtitleGroupId, captionGroupId); + } + } + + /** A rendition (i.e. an #EXT-X-MEDIA tag) in a master playlist. */ + public static final class Rendition { + + /** The rendition's url, or null if the tag does not have a URI attribute. */ + @Nullable public final Uri url; + + /** Format information associated with this rendition. */ + public final Format format; + + /** The group to which this rendition belongs. */ + public final String groupId; + + /** The name of the rendition. */ + public final String name; + + /** + * @param url See {@link #url}. + * @param format See {@link #format}. + * @param groupId See {@link #groupId}. + * @param name See {@link #name}. + */ + public Rendition(@Nullable Uri url, Format format, String groupId, String name) { + this.url = url; + this.format = format; + this.groupId = groupId; + this.name = name; + } + + } + + /** All of the media playlist URLs referenced by the playlist. */ + public final List<Uri> mediaPlaylistUrls; + /** The variants declared by the playlist. */ + public final List<Variant> variants; + /** The video renditions declared by the playlist. */ + public final List<Rendition> videos; + /** The audio renditions declared by the playlist. */ + public final List<Rendition> audios; + /** The subtitle renditions declared by the playlist. */ + public final List<Rendition> subtitles; + /** The closed caption renditions declared by the playlist. */ + public final List<Rendition> closedCaptions; + + /** + * The format of the audio muxed in the variants. May be null if the playlist does not declare any + * muxed audio. + */ + @Nullable public final Format muxedAudioFormat; + /** + * The format of the closed captions declared by the playlist. May be empty if the playlist + * explicitly declares no captions are available, or null if the playlist does not declare any + * captions information. + */ + @Nullable public final List<Format> muxedCaptionFormats; + /** Contains variable definitions, as defined by the #EXT-X-DEFINE tag. */ + public final Map<String, String> variableDefinitions; + /** DRM initialization data derived from #EXT-X-SESSION-KEY tags. */ + public final List<DrmInitData> sessionKeyDrmInitData; + + /** + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param variants See {@link #variants}. + * @param videos See {@link #videos}. + * @param audios See {@link #audios}. + * @param subtitles See {@link #subtitles}. + * @param closedCaptions See {@link #closedCaptions}. + * @param muxedAudioFormat See {@link #muxedAudioFormat}. + * @param muxedCaptionFormats See {@link #muxedCaptionFormats}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param variableDefinitions See {@link #variableDefinitions}. + * @param sessionKeyDrmInitData See {@link #sessionKeyDrmInitData}. + */ + public HlsMasterPlaylist( + String baseUri, + List<String> tags, + List<Variant> variants, + List<Rendition> videos, + List<Rendition> audios, + List<Rendition> subtitles, + List<Rendition> closedCaptions, + @Nullable Format muxedAudioFormat, + @Nullable List<Format> muxedCaptionFormats, + boolean hasIndependentSegments, + Map<String, String> variableDefinitions, + List<DrmInitData> sessionKeyDrmInitData) { + super(baseUri, tags, hasIndependentSegments); + this.mediaPlaylistUrls = + Collections.unmodifiableList( + getMediaPlaylistUrls(variants, videos, audios, subtitles, closedCaptions)); + this.variants = Collections.unmodifiableList(variants); + this.videos = Collections.unmodifiableList(videos); + this.audios = Collections.unmodifiableList(audios); + this.subtitles = Collections.unmodifiableList(subtitles); + this.closedCaptions = Collections.unmodifiableList(closedCaptions); + this.muxedAudioFormat = muxedAudioFormat; + this.muxedCaptionFormats = muxedCaptionFormats != null + ? Collections.unmodifiableList(muxedCaptionFormats) : null; + this.variableDefinitions = Collections.unmodifiableMap(variableDefinitions); + this.sessionKeyDrmInitData = Collections.unmodifiableList(sessionKeyDrmInitData); + } + + @Override + public HlsMasterPlaylist copy(List<StreamKey> streamKeys) { + return new HlsMasterPlaylist( + baseUri, + tags, + copyStreams(variants, GROUP_INDEX_VARIANT, streamKeys), + // TODO: Allow stream keys to specify video renditions to be retained. + /* videos= */ Collections.emptyList(), + copyStreams(audios, GROUP_INDEX_AUDIO, streamKeys), + copyStreams(subtitles, GROUP_INDEX_SUBTITLE, streamKeys), + // TODO: Update to retain all closed captions. + /* closedCaptions= */ Collections.emptyList(), + muxedAudioFormat, + muxedCaptionFormats, + hasIndependentSegments, + variableDefinitions, + sessionKeyDrmInitData); + } + + /** + * Creates a playlist with a single variant. + * + * @param variantUrl The url of the single variant. + * @return A master playlist with a single variant for the provided url. + */ + public static HlsMasterPlaylist createSingleVariantMasterPlaylist(String variantUrl) { + List<Variant> variant = + Collections.singletonList(Variant.createMediaPlaylistVariantUrl(Uri.parse(variantUrl))); + return new HlsMasterPlaylist( + /* baseUri= */ "", + /* tags= */ Collections.emptyList(), + variant, + /* videos= */ Collections.emptyList(), + /* audios= */ Collections.emptyList(), + /* subtitles= */ Collections.emptyList(), + /* closedCaptions= */ Collections.emptyList(), + /* muxedAudioFormat= */ null, + /* muxedCaptionFormats= */ null, + /* hasIndependentSegments= */ false, + /* variableDefinitions= */ Collections.emptyMap(), + /* sessionKeyDrmInitData= */ Collections.emptyList()); + } + + private static List<Uri> getMediaPlaylistUrls( + List<Variant> variants, + List<Rendition> videos, + List<Rendition> audios, + List<Rendition> subtitles, + List<Rendition> closedCaptions) { + ArrayList<Uri> mediaPlaylistUrls = new ArrayList<>(); + for (int i = 0; i < variants.size(); i++) { + Uri uri = variants.get(i).url; + if (!mediaPlaylistUrls.contains(uri)) { + mediaPlaylistUrls.add(uri); + } + } + addMediaPlaylistUrls(videos, mediaPlaylistUrls); + addMediaPlaylistUrls(audios, mediaPlaylistUrls); + addMediaPlaylistUrls(subtitles, mediaPlaylistUrls); + addMediaPlaylistUrls(closedCaptions, mediaPlaylistUrls); + return mediaPlaylistUrls; + } + + private static void addMediaPlaylistUrls(List<Rendition> renditions, List<Uri> out) { + for (int i = 0; i < renditions.size(); i++) { + Uri uri = renditions.get(i).url; + if (uri != null && !out.contains(uri)) { + out.add(uri); + } + } + } + + private static <T> List<T> copyStreams( + List<T> streams, int groupIndex, List<StreamKey> streamKeys) { + List<T> copiedStreams = new ArrayList<>(streamKeys.size()); + // TODO: + // 1. When variants with the same URL are not de-duplicated, duplicates must not increment + // trackIndex so as to avoid breaking stream keys that have been persisted for offline. All + // duplicates should be copied if the first variant is copied, or discarded otherwise. + // 2. When renditions with null URLs are permitted, they must not increment trackIndex so as to + // avoid breaking stream keys that have been persisted for offline. All renitions with null + // URLs should be copied. They may become unreachable if all variants that reference them are + // removed, but this is OK. + // 3. Renditions with URLs matching copied variants should always themselves be copied, even if + // the corresponding stream key is omitted. Else we're throwing away information for no gain. + for (int i = 0; i < streams.size(); i++) { + T stream = streams.get(i); + for (int j = 0; j < streamKeys.size(); j++) { + StreamKey streamKey = streamKeys.get(j); + if (streamKey.groupIndex == groupIndex && streamKey.trackIndex == i) { + copiedStreams.add(stream); + break; + } + } + } + return copiedStreams; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java new file mode 100644 index 0000000000..c3250a5cc0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsMediaPlaylist.java @@ -0,0 +1,375 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.StreamKey; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; + +/** Represents an HLS media playlist. */ +public final class HlsMediaPlaylist extends HlsPlaylist { + + /** Media segment reference. */ + @SuppressWarnings("ComparableType") + public static final class Segment implements Comparable<Long> { + + /** + * The url of the segment. + */ + public final String url; + /** + * The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if + * the media playlist does not define a media section for this segment. The same instance is + * used for all segments that share an EXT-X-MAP tag. + */ + @Nullable public final Segment initializationSegment; + /** The duration of the segment in microseconds, as defined by #EXTINF. */ + public final long durationUs; + /** The human readable title of the segment. */ + public final String title; + /** + * The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment. + */ + public final int relativeDiscontinuitySequence; + /** + * The start time of the segment in microseconds, relative to the start of the playlist. + */ + public final long relativeStartTimeUs; + /** + * DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM + * protection. + */ + @Nullable public final DrmInitData drmInitData; + /** + * The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use + * full segment encryption with identity key. + */ + @Nullable public final String fullSegmentEncryptionKeyUri; + /** + * The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not + * encrypted. + */ + @Nullable public final String encryptionIV; + /** + * The segment's byte range offset, as defined by #EXT-X-BYTERANGE. + */ + public final long byterangeOffset; + /** + * The segment's byte range length, as defined by #EXT-X-BYTERANGE, or {@link C#LENGTH_UNSET} if + * no byte range is specified. + */ + public final long byterangeLength; + + /** Whether the segment is tagged with #EXT-X-GAP. */ + public final boolean hasGapTag; + + /** + * @param uri See {@link #url}. + * @param byterangeOffset See {@link #byterangeOffset}. + * @param byterangeLength See {@link #byterangeLength}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + */ + public Segment( + String uri, + long byterangeOffset, + long byterangeLength, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV) { + this( + uri, + /* initializationSegment= */ null, + /* title= */ "", + /* durationUs= */ 0, + /* relativeDiscontinuitySequence= */ -1, + /* relativeStartTimeUs= */ C.TIME_UNSET, + /* drmInitData= */ null, + fullSegmentEncryptionKeyUri, + encryptionIV, + byterangeOffset, + byterangeLength, + /* hasGapTag= */ false); + } + + /** + * @param url See {@link #url}. + * @param initializationSegment See {@link #initializationSegment}. + * @param title See {@link #title}. + * @param durationUs See {@link #durationUs}. + * @param relativeDiscontinuitySequence See {@link #relativeDiscontinuitySequence}. + * @param relativeStartTimeUs See {@link #relativeStartTimeUs}. + * @param drmInitData See {@link #drmInitData}. + * @param fullSegmentEncryptionKeyUri See {@link #fullSegmentEncryptionKeyUri}. + * @param encryptionIV See {@link #encryptionIV}. + * @param byterangeOffset See {@link #byterangeOffset}. + * @param byterangeLength See {@link #byterangeLength}. + * @param hasGapTag See {@link #hasGapTag}. + */ + public Segment( + String url, + @Nullable Segment initializationSegment, + String title, + long durationUs, + int relativeDiscontinuitySequence, + long relativeStartTimeUs, + @Nullable DrmInitData drmInitData, + @Nullable String fullSegmentEncryptionKeyUri, + @Nullable String encryptionIV, + long byterangeOffset, + long byterangeLength, + boolean hasGapTag) { + this.url = url; + this.initializationSegment = initializationSegment; + this.title = title; + this.durationUs = durationUs; + this.relativeDiscontinuitySequence = relativeDiscontinuitySequence; + this.relativeStartTimeUs = relativeStartTimeUs; + this.drmInitData = drmInitData; + this.fullSegmentEncryptionKeyUri = fullSegmentEncryptionKeyUri; + this.encryptionIV = encryptionIV; + this.byterangeOffset = byterangeOffset; + this.byterangeLength = byterangeLength; + this.hasGapTag = hasGapTag; + } + + @Override + public int compareTo(Long relativeStartTimeUs) { + return this.relativeStartTimeUs > relativeStartTimeUs + ? 1 : (this.relativeStartTimeUs < relativeStartTimeUs ? -1 : 0); + } + + } + + /** + * Type of the playlist, as defined by #EXT-X-PLAYLIST-TYPE. One of {@link + * #PLAYLIST_TYPE_UNKNOWN}, {@link #PLAYLIST_TYPE_VOD} or {@link #PLAYLIST_TYPE_EVENT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({PLAYLIST_TYPE_UNKNOWN, PLAYLIST_TYPE_VOD, PLAYLIST_TYPE_EVENT}) + public @interface PlaylistType {} + + public static final int PLAYLIST_TYPE_UNKNOWN = 0; + public static final int PLAYLIST_TYPE_VOD = 1; + public static final int PLAYLIST_TYPE_EVENT = 2; + + /** + * The type of the playlist. See {@link PlaylistType}. + */ + @PlaylistType public final int playlistType; + /** + * The start offset in microseconds, as defined by #EXT-X-START. + */ + public final long startOffsetUs; + /** + * If {@link #hasProgramDateTime} is true, contains the datetime as microseconds since epoch. + * Otherwise, contains the aggregated duration of removed segments up to this snapshot of the + * playlist. + */ + public final long startTimeUs; + /** + * Whether the playlist contains the #EXT-X-DISCONTINUITY-SEQUENCE tag. + */ + public final boolean hasDiscontinuitySequence; + /** + * The discontinuity sequence number of the first media segment in the playlist, as defined by + * #EXT-X-DISCONTINUITY-SEQUENCE. + */ + public final int discontinuitySequence; + /** + * The media sequence number of the first media segment in the playlist, as defined by + * #EXT-X-MEDIA-SEQUENCE. + */ + public final long mediaSequence; + /** + * The compatibility version, as defined by #EXT-X-VERSION. + */ + public final int version; + /** + * The target duration in microseconds, as defined by #EXT-X-TARGETDURATION. + */ + public final long targetDurationUs; + /** + * Whether the playlist contains the #EXT-X-ENDLIST tag. + */ + public final boolean hasEndTag; + /** + * Whether the playlist contains a #EXT-X-PROGRAM-DATE-TIME tag. + */ + public final boolean hasProgramDateTime; + /** + * Contains the CDM protection schemes used by segments in this playlist. Does not contain any key + * acquisition data. Null if none of the segments in the playlist is CDM-encrypted. + */ + @Nullable public final DrmInitData protectionSchemes; + /** + * The list of segments in the playlist. + */ + public final List<Segment> segments; + /** + * The total duration of the playlist in microseconds. + */ + public final long durationUs; + + /** + * @param playlistType See {@link #playlistType}. + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param startOffsetUs See {@link #startOffsetUs}. + * @param startTimeUs See {@link #startTimeUs}. + * @param hasDiscontinuitySequence See {@link #hasDiscontinuitySequence}. + * @param discontinuitySequence See {@link #discontinuitySequence}. + * @param mediaSequence See {@link #mediaSequence}. + * @param version See {@link #version}. + * @param targetDurationUs See {@link #targetDurationUs}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + * @param hasEndTag See {@link #hasEndTag}. + * @param protectionSchemes See {@link #protectionSchemes}. + * @param hasProgramDateTime See {@link #hasProgramDateTime}. + * @param segments See {@link #segments}. + */ + public HlsMediaPlaylist( + @PlaylistType int playlistType, + String baseUri, + List<String> tags, + long startOffsetUs, + long startTimeUs, + boolean hasDiscontinuitySequence, + int discontinuitySequence, + long mediaSequence, + int version, + long targetDurationUs, + boolean hasIndependentSegments, + boolean hasEndTag, + boolean hasProgramDateTime, + @Nullable DrmInitData protectionSchemes, + List<Segment> segments) { + super(baseUri, tags, hasIndependentSegments); + this.playlistType = playlistType; + this.startTimeUs = startTimeUs; + this.hasDiscontinuitySequence = hasDiscontinuitySequence; + this.discontinuitySequence = discontinuitySequence; + this.mediaSequence = mediaSequence; + this.version = version; + this.targetDurationUs = targetDurationUs; + this.hasEndTag = hasEndTag; + this.hasProgramDateTime = hasProgramDateTime; + this.protectionSchemes = protectionSchemes; + this.segments = Collections.unmodifiableList(segments); + if (!segments.isEmpty()) { + Segment last = segments.get(segments.size() - 1); + durationUs = last.relativeStartTimeUs + last.durationUs; + } else { + durationUs = 0; + } + this.startOffsetUs = startOffsetUs == C.TIME_UNSET ? C.TIME_UNSET + : startOffsetUs >= 0 ? startOffsetUs : durationUs + startOffsetUs; + } + + @Override + public HlsMediaPlaylist copy(List<StreamKey> streamKeys) { + return this; + } + + /** + * Returns whether this playlist is newer than {@code other}. + * + * @param other The playlist to compare. + * @return Whether this playlist is newer than {@code other}. + */ + public boolean isNewerThan(HlsMediaPlaylist other) { + if (other == null || mediaSequence > other.mediaSequence) { + return true; + } + if (mediaSequence < other.mediaSequence) { + return false; + } + // The media sequences are equal. + int segmentCount = segments.size(); + int otherSegmentCount = other.segments.size(); + return segmentCount > otherSegmentCount + || (segmentCount == otherSegmentCount && hasEndTag && !other.hasEndTag); + } + + /** + * Returns the result of adding the duration of the playlist to its start time. + */ + public long getEndTimeUs() { + return startTimeUs + durationUs; + } + + /** + * Returns a playlist identical to this one except for the start time, the discontinuity sequence + * and {@code hasDiscontinuitySequence} values. The first two are set to the specified values, + * {@code hasDiscontinuitySequence} is set to true. + * + * @param startTimeUs The start time for the returned playlist. + * @param discontinuitySequence The discontinuity sequence for the returned playlist. + * @return An identical playlist including the provided discontinuity and timing information. + */ + public HlsMediaPlaylist copyWith(long startTimeUs, int discontinuitySequence) { + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + /* hasDiscontinuitySequence= */ true, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegments, + hasEndTag, + hasProgramDateTime, + protectionSchemes, + segments); + } + + /** + * Returns a playlist identical to this one except that an end tag is added. If an end tag is + * already present then the playlist will return itself. + */ + public HlsMediaPlaylist copyWithEndTag() { + if (this.hasEndTag) { + return this; + } + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + startTimeUs, + hasDiscontinuitySequence, + discontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegments, + /* hasEndTag= */ true, + hasProgramDateTime, + protectionSchemes, + segments); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java new file mode 100644 index 0000000000..28f9b0eeb0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylist.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.offline.FilterableManifest; +import java.util.Collections; +import java.util.List; + +/** Represents an HLS playlist. */ +public abstract class HlsPlaylist implements FilterableManifest<HlsPlaylist> { + + /** + * The base uri. Used to resolve relative paths. + */ + public final String baseUri; + /** + * The list of tags in the playlist. + */ + public final List<String> tags; + /** + * Whether the media is formed of independent segments, as defined by the + * #EXT-X-INDEPENDENT-SEGMENTS tag. + */ + public final boolean hasIndependentSegments; + + /** + * @param baseUri See {@link #baseUri}. + * @param tags See {@link #tags}. + * @param hasIndependentSegments See {@link #hasIndependentSegments}. + */ + protected HlsPlaylist(String baseUri, List<String> tags, boolean hasIndependentSegments) { + this.baseUri = baseUri; + this.tags = Collections.unmodifiableList(tags); + this.hasIndependentSegments = hasIndependentSegments; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java new file mode 100644 index 0000000000..5495d28520 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParser.java @@ -0,0 +1,1007 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import android.text.TextUtils; +import android.util.Base64; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmInitData.SchemeData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.mp4.PsshAtomUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.UnrecognizedInputFormatException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsTrackMetadataEntry.VariantInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Rendition; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMasterPlaylist.Variant; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist.HlsMediaPlaylist.Segment; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.UriUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Queue; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.EnsuresNonNullIf; +import org.checkerframework.checker.nullness.qual.PolyNull; + +/** + * HLS playlists parsing logic. + */ +public final class HlsPlaylistParser implements ParsingLoadable.Parser<HlsPlaylist> { + + private static final String PLAYLIST_HEADER = "#EXTM3U"; + + private static final String TAG_PREFIX = "#EXT"; + + private static final String TAG_VERSION = "#EXT-X-VERSION"; + private static final String TAG_PLAYLIST_TYPE = "#EXT-X-PLAYLIST-TYPE"; + private static final String TAG_DEFINE = "#EXT-X-DEFINE"; + private static final String TAG_STREAM_INF = "#EXT-X-STREAM-INF"; + private static final String TAG_MEDIA = "#EXT-X-MEDIA"; + private static final String TAG_TARGET_DURATION = "#EXT-X-TARGETDURATION"; + private static final String TAG_DISCONTINUITY = "#EXT-X-DISCONTINUITY"; + private static final String TAG_DISCONTINUITY_SEQUENCE = "#EXT-X-DISCONTINUITY-SEQUENCE"; + private static final String TAG_PROGRAM_DATE_TIME = "#EXT-X-PROGRAM-DATE-TIME"; + private static final String TAG_INIT_SEGMENT = "#EXT-X-MAP"; + private static final String TAG_INDEPENDENT_SEGMENTS = "#EXT-X-INDEPENDENT-SEGMENTS"; + private static final String TAG_MEDIA_DURATION = "#EXTINF"; + private static final String TAG_MEDIA_SEQUENCE = "#EXT-X-MEDIA-SEQUENCE"; + private static final String TAG_START = "#EXT-X-START"; + private static final String TAG_ENDLIST = "#EXT-X-ENDLIST"; + private static final String TAG_KEY = "#EXT-X-KEY"; + private static final String TAG_SESSION_KEY = "#EXT-X-SESSION-KEY"; + private static final String TAG_BYTERANGE = "#EXT-X-BYTERANGE"; + private static final String TAG_GAP = "#EXT-X-GAP"; + + private static final String TYPE_AUDIO = "AUDIO"; + private static final String TYPE_VIDEO = "VIDEO"; + private static final String TYPE_SUBTITLES = "SUBTITLES"; + private static final String TYPE_CLOSED_CAPTIONS = "CLOSED-CAPTIONS"; + + private static final String METHOD_NONE = "NONE"; + private static final String METHOD_AES_128 = "AES-128"; + private static final String METHOD_SAMPLE_AES = "SAMPLE-AES"; + // Replaced by METHOD_SAMPLE_AES_CTR. Keep for backward compatibility. + private static final String METHOD_SAMPLE_AES_CENC = "SAMPLE-AES-CENC"; + private static final String METHOD_SAMPLE_AES_CTR = "SAMPLE-AES-CTR"; + private static final String KEYFORMAT_PLAYREADY = "com.microsoft.playready"; + private static final String KEYFORMAT_IDENTITY = "identity"; + private static final String KEYFORMAT_WIDEVINE_PSSH_BINARY = + "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"; + private static final String KEYFORMAT_WIDEVINE_PSSH_JSON = "com.widevine"; + + private static final String BOOLEAN_TRUE = "YES"; + private static final String BOOLEAN_FALSE = "NO"; + + private static final String ATTR_CLOSED_CAPTIONS_NONE = "CLOSED-CAPTIONS=NONE"; + + private static final Pattern REGEX_AVERAGE_BANDWIDTH = + Pattern.compile("AVERAGE-BANDWIDTH=(\\d+)\\b"); + private static final Pattern REGEX_VIDEO = Pattern.compile("VIDEO=\"(.+?)\""); + private static final Pattern REGEX_AUDIO = Pattern.compile("AUDIO=\"(.+?)\""); + private static final Pattern REGEX_SUBTITLES = Pattern.compile("SUBTITLES=\"(.+?)\""); + private static final Pattern REGEX_CLOSED_CAPTIONS = Pattern.compile("CLOSED-CAPTIONS=\"(.+?)\""); + private static final Pattern REGEX_BANDWIDTH = Pattern.compile("[^-]BANDWIDTH=(\\d+)\\b"); + private static final Pattern REGEX_CHANNELS = Pattern.compile("CHANNELS=\"(.+?)\""); + private static final Pattern REGEX_CODECS = Pattern.compile("CODECS=\"(.+?)\""); + private static final Pattern REGEX_RESOLUTION = Pattern.compile("RESOLUTION=(\\d+x\\d+)"); + private static final Pattern REGEX_FRAME_RATE = Pattern.compile("FRAME-RATE=([\\d\\.]+)\\b"); + private static final Pattern REGEX_TARGET_DURATION = Pattern.compile(TAG_TARGET_DURATION + + ":(\\d+)\\b"); + private static final Pattern REGEX_VERSION = Pattern.compile(TAG_VERSION + ":(\\d+)\\b"); + private static final Pattern REGEX_PLAYLIST_TYPE = Pattern.compile(TAG_PLAYLIST_TYPE + + ":(.+)\\b"); + private static final Pattern REGEX_MEDIA_SEQUENCE = Pattern.compile(TAG_MEDIA_SEQUENCE + + ":(\\d+)\\b"); + private static final Pattern REGEX_MEDIA_DURATION = Pattern.compile(TAG_MEDIA_DURATION + + ":([\\d\\.]+)\\b"); + private static final Pattern REGEX_MEDIA_TITLE = + Pattern.compile(TAG_MEDIA_DURATION + ":[\\d\\.]+\\b,(.+)"); + private static final Pattern REGEX_TIME_OFFSET = Pattern.compile("TIME-OFFSET=(-?[\\d\\.]+)\\b"); + private static final Pattern REGEX_BYTERANGE = Pattern.compile(TAG_BYTERANGE + + ":(\\d+(?:@\\d+)?)\\b"); + private static final Pattern REGEX_ATTR_BYTERANGE = + Pattern.compile("BYTERANGE=\"(\\d+(?:@\\d+)?)\\b\""); + private static final Pattern REGEX_METHOD = + Pattern.compile( + "METHOD=(" + + METHOD_NONE + + "|" + + METHOD_AES_128 + + "|" + + METHOD_SAMPLE_AES + + "|" + + METHOD_SAMPLE_AES_CENC + + "|" + + METHOD_SAMPLE_AES_CTR + + ")" + + "\\s*(?:,|$)"); + private static final Pattern REGEX_KEYFORMAT = Pattern.compile("KEYFORMAT=\"(.+?)\""); + private static final Pattern REGEX_KEYFORMATVERSIONS = + Pattern.compile("KEYFORMATVERSIONS=\"(.+?)\""); + private static final Pattern REGEX_URI = Pattern.compile("URI=\"(.+?)\""); + private static final Pattern REGEX_IV = Pattern.compile("IV=([^,.*]+)"); + private static final Pattern REGEX_TYPE = Pattern.compile("TYPE=(" + TYPE_AUDIO + "|" + TYPE_VIDEO + + "|" + TYPE_SUBTITLES + "|" + TYPE_CLOSED_CAPTIONS + ")"); + private static final Pattern REGEX_LANGUAGE = Pattern.compile("LANGUAGE=\"(.+?)\""); + private static final Pattern REGEX_NAME = Pattern.compile("NAME=\"(.+?)\""); + private static final Pattern REGEX_GROUP_ID = Pattern.compile("GROUP-ID=\"(.+?)\""); + private static final Pattern REGEX_CHARACTERISTICS = Pattern.compile("CHARACTERISTICS=\"(.+?)\""); + private static final Pattern REGEX_INSTREAM_ID = + Pattern.compile("INSTREAM-ID=\"((?:CC|SERVICE)\\d+)\""); + private static final Pattern REGEX_AUTOSELECT = compileBooleanAttrPattern("AUTOSELECT"); + private static final Pattern REGEX_DEFAULT = compileBooleanAttrPattern("DEFAULT"); + private static final Pattern REGEX_FORCED = compileBooleanAttrPattern("FORCED"); + private static final Pattern REGEX_VALUE = Pattern.compile("VALUE=\"(.+?)\""); + private static final Pattern REGEX_IMPORT = Pattern.compile("IMPORT=\"(.+?)\""); + private static final Pattern REGEX_VARIABLE_REFERENCE = + Pattern.compile("\\{\\$([a-zA-Z0-9\\-_]+)\\}"); + + private final HlsMasterPlaylist masterPlaylist; + + /** + * Creates an instance where media playlists are parsed without inheriting attributes from a + * master playlist. + */ + public HlsPlaylistParser() { + this(HlsMasterPlaylist.EMPTY); + } + + /** + * Creates an instance where parsed media playlists inherit attributes from the given master + * playlist. + * + * @param masterPlaylist The master playlist from which media playlists will inherit attributes. + */ + public HlsPlaylistParser(HlsMasterPlaylist masterPlaylist) { + this.masterPlaylist = masterPlaylist; + } + + @Override + public HlsPlaylist parse(Uri uri, InputStream inputStream) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + Queue<String> extraLines = new ArrayDeque<>(); + String line; + try { + if (!checkPlaylistHeader(reader)) { + throw new UnrecognizedInputFormatException("Input does not start with the #EXTM3U header.", + uri); + } + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + // Do nothing. + } else if (line.startsWith(TAG_STREAM_INF)) { + extraLines.add(line); + return parseMasterPlaylist(new LineIterator(extraLines, reader), uri.toString()); + } else if (line.startsWith(TAG_TARGET_DURATION) + || line.startsWith(TAG_MEDIA_SEQUENCE) + || line.startsWith(TAG_MEDIA_DURATION) + || line.startsWith(TAG_KEY) + || line.startsWith(TAG_BYTERANGE) + || line.equals(TAG_DISCONTINUITY) + || line.equals(TAG_DISCONTINUITY_SEQUENCE) + || line.equals(TAG_ENDLIST)) { + extraLines.add(line); + return parseMediaPlaylist( + masterPlaylist, new LineIterator(extraLines, reader), uri.toString()); + } else { + extraLines.add(line); + } + } + } finally { + Util.closeQuietly(reader); + } + throw new ParserException("Failed to parse the playlist, could not identify any tags."); + } + + private static boolean checkPlaylistHeader(BufferedReader reader) throws IOException { + int last = reader.read(); + if (last == 0xEF) { + if (reader.read() != 0xBB || reader.read() != 0xBF) { + return false; + } + // The playlist contains a Byte Order Mark, which gets discarded. + last = reader.read(); + } + last = skipIgnorableWhitespace(reader, true, last); + int playlistHeaderLength = PLAYLIST_HEADER.length(); + for (int i = 0; i < playlistHeaderLength; i++) { + if (last != PLAYLIST_HEADER.charAt(i)) { + return false; + } + last = reader.read(); + } + last = skipIgnorableWhitespace(reader, false, last); + return Util.isLinebreak(last); + } + + private static int skipIgnorableWhitespace(BufferedReader reader, boolean skipLinebreaks, int c) + throws IOException { + while (c != -1 && Character.isWhitespace(c) && (skipLinebreaks || !Util.isLinebreak(c))) { + c = reader.read(); + } + return c; + } + + private static HlsMasterPlaylist parseMasterPlaylist(LineIterator iterator, String baseUri) + throws IOException { + HashMap<Uri, ArrayList<VariantInfo>> urlToVariantInfos = new HashMap<>(); + HashMap<String, String> variableDefinitions = new HashMap<>(); + ArrayList<Variant> variants = new ArrayList<>(); + ArrayList<Rendition> videos = new ArrayList<>(); + ArrayList<Rendition> audios = new ArrayList<>(); + ArrayList<Rendition> subtitles = new ArrayList<>(); + ArrayList<Rendition> closedCaptions = new ArrayList<>(); + ArrayList<String> mediaTags = new ArrayList<>(); + ArrayList<DrmInitData> sessionKeyDrmInitData = new ArrayList<>(); + ArrayList<String> tags = new ArrayList<>(); + Format muxedAudioFormat = null; + List<Format> muxedCaptionFormats = null; + boolean noClosedCaptions = false; + boolean hasIndependentSegmentsTag = false; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + + if (line.startsWith(TAG_PREFIX)) { + // We expose all tags through the playlist. + tags.add(line); + } + + if (line.startsWith(TAG_DEFINE)) { + variableDefinitions.put( + /* key= */ parseStringAttr(line, REGEX_NAME, variableDefinitions), + /* value= */ parseStringAttr(line, REGEX_VALUE, variableDefinitions)); + } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) { + hasIndependentSegmentsTag = true; + } else if (line.startsWith(TAG_MEDIA)) { + // Media tags are parsed at the end to include codec information from #EXT-X-STREAM-INF + // tags. + mediaTags.add(line); + } else if (line.startsWith(TAG_SESSION_KEY)) { + String keyFormat = + parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions); + SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions); + if (schemeData != null) { + String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions); + String scheme = parseEncryptionScheme(method); + sessionKeyDrmInitData.add(new DrmInitData(scheme, schemeData)); + } + } else if (line.startsWith(TAG_STREAM_INF)) { + noClosedCaptions |= line.contains(ATTR_CLOSED_CAPTIONS_NONE); + int bitrate = parseIntAttr(line, REGEX_BANDWIDTH); + // TODO: Plumb this into Format. + int averageBitrate = parseOptionalIntAttr(line, REGEX_AVERAGE_BANDWIDTH, -1); + String codecs = parseOptionalStringAttr(line, REGEX_CODECS, variableDefinitions); + String resolutionString = + parseOptionalStringAttr(line, REGEX_RESOLUTION, variableDefinitions); + int width; + int height; + if (resolutionString != null) { + String[] widthAndHeight = resolutionString.split("x"); + width = Integer.parseInt(widthAndHeight[0]); + height = Integer.parseInt(widthAndHeight[1]); + if (width <= 0 || height <= 0) { + // Resolution string is invalid. + width = Format.NO_VALUE; + height = Format.NO_VALUE; + } + } else { + width = Format.NO_VALUE; + height = Format.NO_VALUE; + } + float frameRate = Format.NO_VALUE; + String frameRateString = + parseOptionalStringAttr(line, REGEX_FRAME_RATE, variableDefinitions); + if (frameRateString != null) { + frameRate = Float.parseFloat(frameRateString); + } + String videoGroupId = parseOptionalStringAttr(line, REGEX_VIDEO, variableDefinitions); + String audioGroupId = parseOptionalStringAttr(line, REGEX_AUDIO, variableDefinitions); + String subtitlesGroupId = + parseOptionalStringAttr(line, REGEX_SUBTITLES, variableDefinitions); + String closedCaptionsGroupId = + parseOptionalStringAttr(line, REGEX_CLOSED_CAPTIONS, variableDefinitions); + if (!iterator.hasNext()) { + throw new ParserException("#EXT-X-STREAM-INF tag must be followed by another line"); + } + line = + replaceVariableReferences( + iterator.next(), variableDefinitions); // #EXT-X-STREAM-INF's URI. + Uri uri = UriUtil.resolveToUri(baseUri, line); + Format format = + Format.createVideoContainerFormat( + /* id= */ Integer.toString(variants.size()), + /* label= */ null, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + /* sampleMimeType= */ null, + codecs, + /* metadata= */ null, + bitrate, + width, + height, + frameRate, + /* initializationData= */ null, + /* selectionFlags= */ 0, + /* roleFlags= */ 0); + Variant variant = + new Variant( + uri, format, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId); + variants.add(variant); + ArrayList<VariantInfo> variantInfosForUrl = urlToVariantInfos.get(uri); + if (variantInfosForUrl == null) { + variantInfosForUrl = new ArrayList<>(); + urlToVariantInfos.put(uri, variantInfosForUrl); + } + variantInfosForUrl.add( + new VariantInfo( + bitrate, videoGroupId, audioGroupId, subtitlesGroupId, closedCaptionsGroupId)); + } + } + + // TODO: Don't deduplicate variants by URL. + ArrayList<Variant> deduplicatedVariants = new ArrayList<>(); + HashSet<Uri> urlsInDeduplicatedVariants = new HashSet<>(); + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (urlsInDeduplicatedVariants.add(variant.url)) { + Assertions.checkState(variant.format.metadata == null); + HlsTrackMetadataEntry hlsMetadataEntry = + new HlsTrackMetadataEntry( + /* groupId= */ null, + /* name= */ null, + Assertions.checkNotNull(urlToVariantInfos.get(variant.url))); + deduplicatedVariants.add( + variant.copyWithFormat( + variant.format.copyWithMetadata(new Metadata(hlsMetadataEntry)))); + } + } + + for (int i = 0; i < mediaTags.size(); i++) { + line = mediaTags.get(i); + String groupId = parseStringAttr(line, REGEX_GROUP_ID, variableDefinitions); + String name = parseStringAttr(line, REGEX_NAME, variableDefinitions); + String referenceUri = parseOptionalStringAttr(line, REGEX_URI, variableDefinitions); + Uri uri = referenceUri == null ? null : UriUtil.resolveToUri(baseUri, referenceUri); + String language = parseOptionalStringAttr(line, REGEX_LANGUAGE, variableDefinitions); + @C.SelectionFlags int selectionFlags = parseSelectionFlags(line); + @C.RoleFlags int roleFlags = parseRoleFlags(line, variableDefinitions); + String formatId = groupId + ":" + name; + Format format; + Metadata metadata = + new Metadata(new HlsTrackMetadataEntry(groupId, name, Collections.emptyList())); + switch (parseStringAttr(line, REGEX_TYPE, variableDefinitions)) { + case TYPE_VIDEO: + Variant variant = getVariantWithVideoGroup(variants, groupId); + String codecs = null; + int width = Format.NO_VALUE; + int height = Format.NO_VALUE; + float frameRate = Format.NO_VALUE; + if (variant != null) { + Format variantFormat = variant.format; + codecs = Util.getCodecsOfType(variantFormat.codecs, C.TRACK_TYPE_VIDEO); + width = variantFormat.width; + height = variantFormat.height; + frameRate = variantFormat.frameRate; + } + String sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; + format = + Format.createVideoContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* metadata= */ null, + /* bitrate= */ Format.NO_VALUE, + width, + height, + frameRate, + /* initializationData= */ null, + selectionFlags, + roleFlags) + .copyWithMetadata(metadata); + if (uri == null) { + // TODO: Remove this case and add a Rendition with a null uri to videos. + } else { + videos.add(new Rendition(uri, format, groupId, name)); + } + break; + case TYPE_AUDIO: + variant = getVariantWithAudioGroup(variants, groupId); + codecs = + variant != null + ? Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_AUDIO) + : null; + sampleMimeType = codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; + String channelsString = + parseOptionalStringAttr(line, REGEX_CHANNELS, variableDefinitions); + int channelCount = Format.NO_VALUE; + if (channelsString != null) { + channelCount = Integer.parseInt(Util.splitAtFirst(channelsString, "/")[0]); + if (MimeTypes.AUDIO_E_AC3.equals(sampleMimeType) && channelsString.endsWith("/JOC")) { + sampleMimeType = MimeTypes.AUDIO_E_AC3_JOC; + } + } + format = + Format.createAudioContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* metadata= */ null, + /* bitrate= */ Format.NO_VALUE, + channelCount, + /* sampleRate= */ Format.NO_VALUE, + /* initializationData= */ null, + selectionFlags, + roleFlags, + language); + if (uri == null) { + // TODO: Remove muxedAudioFormat and add a Rendition with a null uri to audios. + muxedAudioFormat = format; + } else { + audios.add(new Rendition(uri, format.copyWithMetadata(metadata), groupId, name)); + } + break; + case TYPE_SUBTITLES: + codecs = null; + sampleMimeType = null; + variant = getVariantWithSubtitleGroup(variants, groupId); + if (variant != null) { + codecs = Util.getCodecsOfType(variant.format.codecs, C.TRACK_TYPE_TEXT); + sampleMimeType = MimeTypes.getMediaMimeType(codecs); + } + if (sampleMimeType == null) { + sampleMimeType = MimeTypes.TEXT_VTT; + } + format = + Format.createTextContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ MimeTypes.APPLICATION_M3U8, + sampleMimeType, + codecs, + /* bitrate= */ Format.NO_VALUE, + selectionFlags, + roleFlags, + language) + .copyWithMetadata(metadata); + subtitles.add(new Rendition(uri, format, groupId, name)); + break; + case TYPE_CLOSED_CAPTIONS: + String instreamId = parseStringAttr(line, REGEX_INSTREAM_ID, variableDefinitions); + String mimeType; + int accessibilityChannel; + if (instreamId.startsWith("CC")) { + mimeType = MimeTypes.APPLICATION_CEA608; + accessibilityChannel = Integer.parseInt(instreamId.substring(2)); + } else /* starts with SERVICE */ { + mimeType = MimeTypes.APPLICATION_CEA708; + accessibilityChannel = Integer.parseInt(instreamId.substring(7)); + } + if (muxedCaptionFormats == null) { + muxedCaptionFormats = new ArrayList<>(); + } + muxedCaptionFormats.add( + Format.createTextContainerFormat( + /* id= */ formatId, + /* label= */ name, + /* containerMimeType= */ null, + /* sampleMimeType= */ mimeType, + /* codecs= */ null, + /* bitrate= */ Format.NO_VALUE, + selectionFlags, + roleFlags, + language, + accessibilityChannel)); + // TODO: Remove muxedCaptionFormats and add a Rendition with a null uri to closedCaptions. + break; + default: + // Do nothing. + break; + } + } + + if (noClosedCaptions) { + muxedCaptionFormats = Collections.emptyList(); + } + + return new HlsMasterPlaylist( + baseUri, + tags, + deduplicatedVariants, + videos, + audios, + subtitles, + closedCaptions, + muxedAudioFormat, + muxedCaptionFormats, + hasIndependentSegmentsTag, + variableDefinitions, + sessionKeyDrmInitData); + } + + @Nullable + private static Variant getVariantWithAudioGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.audioGroupId)) { + return variant; + } + } + return null; + } + + @Nullable + private static Variant getVariantWithVideoGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.videoGroupId)) { + return variant; + } + } + return null; + } + + @Nullable + private static Variant getVariantWithSubtitleGroup(ArrayList<Variant> variants, String groupId) { + for (int i = 0; i < variants.size(); i++) { + Variant variant = variants.get(i); + if (groupId.equals(variant.subtitleGroupId)) { + return variant; + } + } + return null; + } + + private static HlsMediaPlaylist parseMediaPlaylist( + HlsMasterPlaylist masterPlaylist, LineIterator iterator, String baseUri) throws IOException { + @HlsMediaPlaylist.PlaylistType int playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN; + long startOffsetUs = C.TIME_UNSET; + long mediaSequence = 0; + int version = 1; // Default version == 1. + long targetDurationUs = C.TIME_UNSET; + boolean hasIndependentSegmentsTag = masterPlaylist.hasIndependentSegments; + boolean hasEndTag = false; + Segment initializationSegment = null; + HashMap<String, String> variableDefinitions = new HashMap<>(); + List<Segment> segments = new ArrayList<>(); + List<String> tags = new ArrayList<>(); + + long segmentDurationUs = 0; + String segmentTitle = ""; + boolean hasDiscontinuitySequence = false; + int playlistDiscontinuitySequence = 0; + int relativeDiscontinuitySequence = 0; + long playlistStartTimeUs = 0; + long segmentStartTimeUs = 0; + long segmentByteRangeOffset = 0; + long segmentByteRangeLength = C.LENGTH_UNSET; + long segmentMediaSequence = 0; + boolean hasGapTag = false; + + DrmInitData playlistProtectionSchemes = null; + String fullSegmentEncryptionKeyUri = null; + String fullSegmentEncryptionIV = null; + TreeMap<String, SchemeData> currentSchemeDatas = new TreeMap<>(); + String encryptionScheme = null; + DrmInitData cachedDrmInitData = null; + + String line; + while (iterator.hasNext()) { + line = iterator.next(); + + if (line.startsWith(TAG_PREFIX)) { + // We expose all tags through the playlist. + tags.add(line); + } + + if (line.startsWith(TAG_PLAYLIST_TYPE)) { + String playlistTypeString = parseStringAttr(line, REGEX_PLAYLIST_TYPE, variableDefinitions); + if ("VOD".equals(playlistTypeString)) { + playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_VOD; + } else if ("EVENT".equals(playlistTypeString)) { + playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_EVENT; + } + } else if (line.startsWith(TAG_START)) { + startOffsetUs = (long) (parseDoubleAttr(line, REGEX_TIME_OFFSET) * C.MICROS_PER_SECOND); + } else if (line.startsWith(TAG_INIT_SEGMENT)) { + String uri = parseStringAttr(line, REGEX_URI, variableDefinitions); + String byteRange = parseOptionalStringAttr(line, REGEX_ATTR_BYTERANGE, variableDefinitions); + if (byteRange != null) { + String[] splitByteRange = byteRange.split("@"); + segmentByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); + } + } + if (fullSegmentEncryptionKeyUri != null && fullSegmentEncryptionIV == null) { + // See RFC 8216, Section 4.3.2.5. + throw new ParserException( + "The encryption IV attribute must be present when an initialization segment is " + + "encrypted with METHOD=AES-128."); + } + initializationSegment = + new Segment( + uri, + segmentByteRangeOffset, + segmentByteRangeLength, + fullSegmentEncryptionKeyUri, + fullSegmentEncryptionIV); + segmentByteRangeOffset = 0; + segmentByteRangeLength = C.LENGTH_UNSET; + } else if (line.startsWith(TAG_TARGET_DURATION)) { + targetDurationUs = parseIntAttr(line, REGEX_TARGET_DURATION) * C.MICROS_PER_SECOND; + } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) { + mediaSequence = parseLongAttr(line, REGEX_MEDIA_SEQUENCE); + segmentMediaSequence = mediaSequence; + } else if (line.startsWith(TAG_VERSION)) { + version = parseIntAttr(line, REGEX_VERSION); + } else if (line.startsWith(TAG_DEFINE)) { + String importName = parseOptionalStringAttr(line, REGEX_IMPORT, variableDefinitions); + if (importName != null) { + String value = masterPlaylist.variableDefinitions.get(importName); + if (value != null) { + variableDefinitions.put(importName, value); + } else { + // The master playlist does not declare the imported variable. Ignore. + } + } else { + variableDefinitions.put( + parseStringAttr(line, REGEX_NAME, variableDefinitions), + parseStringAttr(line, REGEX_VALUE, variableDefinitions)); + } + } else if (line.startsWith(TAG_MEDIA_DURATION)) { + segmentDurationUs = + (long) (parseDoubleAttr(line, REGEX_MEDIA_DURATION) * C.MICROS_PER_SECOND); + segmentTitle = parseOptionalStringAttr(line, REGEX_MEDIA_TITLE, "", variableDefinitions); + } else if (line.startsWith(TAG_KEY)) { + String method = parseStringAttr(line, REGEX_METHOD, variableDefinitions); + String keyFormat = + parseOptionalStringAttr(line, REGEX_KEYFORMAT, KEYFORMAT_IDENTITY, variableDefinitions); + fullSegmentEncryptionKeyUri = null; + fullSegmentEncryptionIV = null; + if (METHOD_NONE.equals(method)) { + currentSchemeDatas.clear(); + cachedDrmInitData = null; + } else /* !METHOD_NONE.equals(method) */ { + fullSegmentEncryptionIV = parseOptionalStringAttr(line, REGEX_IV, variableDefinitions); + if (KEYFORMAT_IDENTITY.equals(keyFormat)) { + if (METHOD_AES_128.equals(method)) { + // The segment is fully encrypted using an identity key. + fullSegmentEncryptionKeyUri = parseStringAttr(line, REGEX_URI, variableDefinitions); + } else { + // Do nothing. Samples are encrypted using an identity key, but this is not supported. + // Hopefully, a traditional DRM alternative is also provided. + } + } else { + if (encryptionScheme == null) { + encryptionScheme = parseEncryptionScheme(method); + } + SchemeData schemeData = parseDrmSchemeData(line, keyFormat, variableDefinitions); + if (schemeData != null) { + cachedDrmInitData = null; + currentSchemeDatas.put(keyFormat, schemeData); + } + } + } + } else if (line.startsWith(TAG_BYTERANGE)) { + String byteRange = parseStringAttr(line, REGEX_BYTERANGE, variableDefinitions); + String[] splitByteRange = byteRange.split("@"); + segmentByteRangeLength = Long.parseLong(splitByteRange[0]); + if (splitByteRange.length > 1) { + segmentByteRangeOffset = Long.parseLong(splitByteRange[1]); + } + } else if (line.startsWith(TAG_DISCONTINUITY_SEQUENCE)) { + hasDiscontinuitySequence = true; + playlistDiscontinuitySequence = Integer.parseInt(line.substring(line.indexOf(':') + 1)); + } else if (line.equals(TAG_DISCONTINUITY)) { + relativeDiscontinuitySequence++; + } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) { + if (playlistStartTimeUs == 0) { + long programDatetimeUs = + C.msToUs(Util.parseXsDateTime(line.substring(line.indexOf(':') + 1))); + playlistStartTimeUs = programDatetimeUs - segmentStartTimeUs; + } + } else if (line.equals(TAG_GAP)) { + hasGapTag = true; + } else if (line.equals(TAG_INDEPENDENT_SEGMENTS)) { + hasIndependentSegmentsTag = true; + } else if (line.equals(TAG_ENDLIST)) { + hasEndTag = true; + } else if (!line.startsWith("#")) { + String segmentEncryptionIV; + if (fullSegmentEncryptionKeyUri == null) { + segmentEncryptionIV = null; + } else if (fullSegmentEncryptionIV != null) { + segmentEncryptionIV = fullSegmentEncryptionIV; + } else { + segmentEncryptionIV = Long.toHexString(segmentMediaSequence); + } + + segmentMediaSequence++; + if (segmentByteRangeLength == C.LENGTH_UNSET) { + segmentByteRangeOffset = 0; + } + + if (cachedDrmInitData == null && !currentSchemeDatas.isEmpty()) { + SchemeData[] schemeDatas = currentSchemeDatas.values().toArray(new SchemeData[0]); + cachedDrmInitData = new DrmInitData(encryptionScheme, schemeDatas); + if (playlistProtectionSchemes == null) { + SchemeData[] playlistSchemeDatas = new SchemeData[schemeDatas.length]; + for (int i = 0; i < schemeDatas.length; i++) { + playlistSchemeDatas[i] = schemeDatas[i].copyWithData(null); + } + playlistProtectionSchemes = new DrmInitData(encryptionScheme, playlistSchemeDatas); + } + } + + segments.add( + new Segment( + replaceVariableReferences(line, variableDefinitions), + initializationSegment, + segmentTitle, + segmentDurationUs, + relativeDiscontinuitySequence, + segmentStartTimeUs, + cachedDrmInitData, + fullSegmentEncryptionKeyUri, + segmentEncryptionIV, + segmentByteRangeOffset, + segmentByteRangeLength, + hasGapTag)); + segmentStartTimeUs += segmentDurationUs; + segmentDurationUs = 0; + segmentTitle = ""; + if (segmentByteRangeLength != C.LENGTH_UNSET) { + segmentByteRangeOffset += segmentByteRangeLength; + } + segmentByteRangeLength = C.LENGTH_UNSET; + hasGapTag = false; + } + } + return new HlsMediaPlaylist( + playlistType, + baseUri, + tags, + startOffsetUs, + playlistStartTimeUs, + hasDiscontinuitySequence, + playlistDiscontinuitySequence, + mediaSequence, + version, + targetDurationUs, + hasIndependentSegmentsTag, + hasEndTag, + /* hasProgramDateTime= */ playlistStartTimeUs != 0, + playlistProtectionSchemes, + segments); + } + + @C.SelectionFlags + private static int parseSelectionFlags(String line) { + int flags = 0; + if (parseOptionalBooleanAttribute(line, REGEX_DEFAULT, false)) { + flags |= C.SELECTION_FLAG_DEFAULT; + } + if (parseOptionalBooleanAttribute(line, REGEX_FORCED, false)) { + flags |= C.SELECTION_FLAG_FORCED; + } + if (parseOptionalBooleanAttribute(line, REGEX_AUTOSELECT, false)) { + flags |= C.SELECTION_FLAG_AUTOSELECT; + } + return flags; + } + + @C.RoleFlags + private static int parseRoleFlags(String line, Map<String, String> variableDefinitions) { + String concatenatedCharacteristics = + parseOptionalStringAttr(line, REGEX_CHARACTERISTICS, variableDefinitions); + if (TextUtils.isEmpty(concatenatedCharacteristics)) { + return 0; + } + String[] characteristics = Util.split(concatenatedCharacteristics, ","); + @C.RoleFlags int roleFlags = 0; + if (Util.contains(characteristics, "public.accessibility.describes-video")) { + roleFlags |= C.ROLE_FLAG_DESCRIBES_VIDEO; + } + if (Util.contains(characteristics, "public.accessibility.transcribes-spoken-dialog")) { + roleFlags |= C.ROLE_FLAG_TRANSCRIBES_DIALOG; + } + if (Util.contains(characteristics, "public.accessibility.describes-music-and-sound")) { + roleFlags |= C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; + } + if (Util.contains(characteristics, "public.easy-to-read")) { + roleFlags |= C.ROLE_FLAG_EASY_TO_READ; + } + return roleFlags; + } + + @Nullable + private static SchemeData parseDrmSchemeData( + String line, String keyFormat, Map<String, String> variableDefinitions) + throws ParserException { + String keyFormatVersions = + parseOptionalStringAttr(line, REGEX_KEYFORMATVERSIONS, "1", variableDefinitions); + if (KEYFORMAT_WIDEVINE_PSSH_BINARY.equals(keyFormat)) { + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + return new SchemeData( + C.WIDEVINE_UUID, + MimeTypes.VIDEO_MP4, + Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT)); + } else if (KEYFORMAT_WIDEVINE_PSSH_JSON.equals(keyFormat)) { + return new SchemeData(C.WIDEVINE_UUID, "hls", Util.getUtf8Bytes(line)); + } else if (KEYFORMAT_PLAYREADY.equals(keyFormat) && "1".equals(keyFormatVersions)) { + String uriString = parseStringAttr(line, REGEX_URI, variableDefinitions); + byte[] data = Base64.decode(uriString.substring(uriString.indexOf(',')), Base64.DEFAULT); + byte[] psshData = PsshAtomUtil.buildPsshAtom(C.PLAYREADY_UUID, data); + return new SchemeData(C.PLAYREADY_UUID, MimeTypes.VIDEO_MP4, psshData); + } + return null; + } + + private static String parseEncryptionScheme(String method) { + return METHOD_SAMPLE_AES_CENC.equals(method) || METHOD_SAMPLE_AES_CTR.equals(method) + ? C.CENC_TYPE_cenc + : C.CENC_TYPE_cbcs; + } + + private static int parseIntAttr(String line, Pattern pattern) throws ParserException { + return Integer.parseInt(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static int parseOptionalIntAttr(String line, Pattern pattern, int defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return Integer.parseInt(matcher.group(1)); + } + return defaultValue; + } + + private static long parseLongAttr(String line, Pattern pattern) throws ParserException { + return Long.parseLong(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static double parseDoubleAttr(String line, Pattern pattern) throws ParserException { + return Double.parseDouble(parseStringAttr(line, pattern, Collections.emptyMap())); + } + + private static String parseStringAttr( + String line, Pattern pattern, Map<String, String> variableDefinitions) + throws ParserException { + String value = parseOptionalStringAttr(line, pattern, variableDefinitions); + if (value != null) { + return value; + } else { + throw new ParserException("Couldn't match " + pattern.pattern() + " in " + line); + } + } + + private static @Nullable String parseOptionalStringAttr( + String line, Pattern pattern, Map<String, String> variableDefinitions) { + return parseOptionalStringAttr(line, pattern, null, variableDefinitions); + } + + private static @PolyNull String parseOptionalStringAttr( + String line, + Pattern pattern, + @PolyNull String defaultValue, + Map<String, String> variableDefinitions) { + Matcher matcher = pattern.matcher(line); + String value = matcher.find() ? matcher.group(1) : defaultValue; + return variableDefinitions.isEmpty() || value == null + ? value + : replaceVariableReferences(value, variableDefinitions); + } + + private static String replaceVariableReferences( + String string, Map<String, String> variableDefinitions) { + Matcher matcher = REGEX_VARIABLE_REFERENCE.matcher(string); + // TODO: Replace StringBuffer with StringBuilder once Java 9 is available. + StringBuffer stringWithReplacements = new StringBuffer(); + while (matcher.find()) { + String groupName = matcher.group(1); + if (variableDefinitions.containsKey(groupName)) { + matcher.appendReplacement( + stringWithReplacements, Matcher.quoteReplacement(variableDefinitions.get(groupName))); + } else { + // The variable is not defined. The value is ignored. + } + } + matcher.appendTail(stringWithReplacements); + return stringWithReplacements.toString(); + } + + private static boolean parseOptionalBooleanAttribute( + String line, Pattern pattern, boolean defaultValue) { + Matcher matcher = pattern.matcher(line); + if (matcher.find()) { + return matcher.group(1).equals(BOOLEAN_TRUE); + } + return defaultValue; + } + + private static Pattern compileBooleanAttrPattern(String attribute) { + return Pattern.compile(attribute + "=(" + BOOLEAN_FALSE + "|" + BOOLEAN_TRUE + ")"); + } + + private static class LineIterator { + + private final BufferedReader reader; + private final Queue<String> extraLines; + + @Nullable private String next; + + public LineIterator(Queue<String> extraLines, BufferedReader reader) { + this.extraLines = extraLines; + this.reader = reader; + } + + @EnsuresNonNullIf(expression = "next", result = true) + public boolean hasNext() throws IOException { + if (next != null) { + return true; + } + if (!extraLines.isEmpty()) { + next = Assertions.checkNotNull(extraLines.poll()); + return true; + } + while ((next = reader.readLine()) != null) { + next = next.trim(); + if (!next.isEmpty()) { + return true; + } + } + return false; + } + + /** Return the next line, or throw {@link NoSuchElementException} if none. */ + public String next() throws IOException { + if (hasNext()) { + String result = next; + next = null; + return result; + } else { + throw new NoSuchElementException(); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java new file mode 100644 index 0000000000..deb1daf8a7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistParserFactory.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.ParsingLoadable; + +/** Factory for {@link HlsPlaylist} parsers. */ +public interface HlsPlaylistParserFactory { + + /** + * Returns a stand-alone playlist parser. Playlists parsed by the returned parser do not inherit + * any attributes from other playlists. + */ + ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(); + + /** + * Returns a playlist parser for playlists that were referenced by the given {@link + * HlsMasterPlaylist}. Returned {@link HlsMediaPlaylist} instances may inherit attributes from + * {@code masterPlaylist}. + * + * @param masterPlaylist The master playlist that referenced any parsed media playlists. + * @return A parser for HLS playlists. + */ + ParsingLoadable.Parser<HlsPlaylist> createPlaylistParser(HlsMasterPlaylist masterPlaylist); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java new file mode 100644 index 0000000000..69f8cb02c9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/HlsPlaylistTracker.java @@ -0,0 +1,226 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy; +import java.io.IOException; + +/** + * Tracks playlists associated to an HLS stream and provides snapshots. + * + * <p>The playlist tracker is responsible for exposing the seeking window, which is defined by the + * segments that one of the playlists exposes. This playlist is called primary and needs to be + * periodically refreshed in the case of live streams. Note that the primary playlist is one of the + * media playlists while the master playlist is an optional kind of playlist defined by the HLS + * specification (RFC 8216). + * + * <p>Playlist loads might encounter errors. The tracker may choose to blacklist them to ensure a + * primary playlist is always available. + */ +public interface HlsPlaylistTracker { + + /** Factory for {@link HlsPlaylistTracker} instances. */ + interface Factory { + + /** + * Creates a new tracker instance. + * + * @param dataSourceFactory The {@link HlsDataSourceFactory} to use for playlist loading. + * @param loadErrorHandlingPolicy The {@link LoadErrorHandlingPolicy} for playlist load errors. + * @param playlistParserFactory The {@link HlsPlaylistParserFactory} for playlist parsing. + */ + HlsPlaylistTracker createTracker( + HlsDataSourceFactory dataSourceFactory, + LoadErrorHandlingPolicy loadErrorHandlingPolicy, + HlsPlaylistParserFactory playlistParserFactory); + } + + /** Listener for primary playlist changes. */ + interface PrimaryPlaylistListener { + + /** + * Called when the primary playlist changes. + * + * @param mediaPlaylist The primary playlist new snapshot. + */ + void onPrimaryPlaylistRefreshed(HlsMediaPlaylist mediaPlaylist); + } + + /** Called on playlist loading events. */ + interface PlaylistEventListener { + + /** + * Called a playlist changes. + */ + void onPlaylistChanged(); + + /** + * Called if an error is encountered while loading a playlist. + * + * @param url The loaded url that caused the error. + * @param blacklistDurationMs The duration for which the playlist should be blacklisted. Or + * {@link C#TIME_UNSET} if the playlist should not be blacklisted. + * @return True if blacklisting did not encounter errors. False otherwise. + */ + boolean onPlaylistError(Uri url, long blacklistDurationMs); + } + + /** Thrown when a playlist is considered to be stuck due to a server side error. */ + final class PlaylistStuckException extends IOException { + + /** The url of the stuck playlist. */ + public final Uri url; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + */ + public PlaylistStuckException(Uri url) { + this.url = url; + } + } + + /** Thrown when the media sequence of a new snapshot indicates the server has reset. */ + final class PlaylistResetException extends IOException { + + /** The url of the reset playlist. */ + public final Uri url; + + /** + * Creates an instance. + * + * @param url See {@link #url}. + */ + public PlaylistResetException(Uri url) { + this.url = url; + } + } + + /** + * Starts the playlist tracker. + * + * <p>Must be called from the playback thread. A tracker may be restarted after a {@link #stop()} + * call. + * + * @param initialPlaylistUri Uri of the HLS stream. Can point to a media playlist or a master + * playlist. + * @param eventDispatcher A dispatcher to notify of events. + * @param listener A callback for the primary playlist change events. + */ + void start( + Uri initialPlaylistUri, EventDispatcher eventDispatcher, PrimaryPlaylistListener listener); + + /** + * Stops the playlist tracker and releases any acquired resources. + * + * <p>Must be called once per {@link #start} call. + */ + void stop(); + + /** + * Registers a listener to receive events from the playlist tracker. + * + * @param listener The listener. + */ + void addListener(PlaylistEventListener listener); + + /** + * Unregisters a listener. + * + * @param listener The listener to unregister. + */ + void removeListener(PlaylistEventListener listener); + + /** + * Returns the master playlist. + * + * <p>If the uri passed to {@link #start} points to a media playlist, an {@link HlsMasterPlaylist} + * with a single variant for said media playlist is returned. + * + * @return The master playlist. Null if the initial playlist has yet to be loaded. + */ + @Nullable + HlsMasterPlaylist getMasterPlaylist(); + + /** + * Returns the most recent snapshot available of the playlist referenced by the provided {@link + * Uri}. + * + * @param url The {@link Uri} corresponding to the requested media playlist. + * @param isForPlayback Whether the caller might use the snapshot to request media segments for + * playback. If true, the primary playlist may be updated to the one requested. + * @return The most recent snapshot of the playlist referenced by the provided {@link Uri}. May be + * null if no snapshot has been loaded yet. + */ + @Nullable + HlsMediaPlaylist getPlaylistSnapshot(Uri url, boolean isForPlayback); + + /** + * Returns the start time of the first loaded primary playlist, or {@link C#TIME_UNSET} if no + * media playlist has been loaded. + */ + long getInitialStartTimeUs(); + + /** + * Returns whether the snapshot of the playlist referenced by the provided {@link Uri} is valid, + * meaning all the segments referenced by the playlist are expected to be available. If the + * playlist is not valid then some of the segments may no longer be available. + * + * @param url The {@link Uri}. + * @return Whether the snapshot of the playlist referenced by the provided {@link Uri} is valid. + */ + boolean isSnapshotValid(Uri url); + + /** + * If the tracker is having trouble refreshing the master playlist or the primary playlist, this + * method throws the underlying error. Otherwise, does nothing. + * + * @throws IOException The underlying error. + */ + void maybeThrowPrimaryPlaylistRefreshError() throws IOException; + + /** + * If the playlist is having trouble refreshing the playlist referenced by the given {@link Uri}, + * this method throws the underlying error. + * + * @param url The {@link Uri}. + * @throws IOException The underyling error. + */ + void maybeThrowPlaylistRefreshError(Uri url) throws IOException; + + /** + * Requests a playlist refresh and whitelists it. + * + * <p>The playlist tracker may choose the delay the playlist refresh. The request is discarded if + * a refresh was already pending. + * + * @param url The {@link Uri} of the playlist to be refreshed. + */ + void refreshPlaylist(Uri url); + + /** + * Returns whether the tracked playlists describe a live stream. + * + * @return True if the content is live. False otherwise. + */ + boolean isLive(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java new file mode 100644 index 0000000000..be9f862644 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/source/hls/playlist/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.playlist; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java new file mode 100644 index 0000000000..c9acc1c8f5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/CaptionStyleCompat.java @@ -0,0 +1,184 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import android.annotation.TargetApi; +import android.graphics.Color; +import android.graphics.Typeface; +import android.view.accessibility.CaptioningManager; +import android.view.accessibility.CaptioningManager.CaptionStyle; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * A compatibility wrapper for {@link CaptionStyle}. + */ +public final class CaptionStyleCompat { + + /** + * The type of edge, which may be none. One of {@link #EDGE_TYPE_NONE}, {@link + * #EDGE_TYPE_OUTLINE}, {@link #EDGE_TYPE_DROP_SHADOW}, {@link #EDGE_TYPE_RAISED} or {@link + * #EDGE_TYPE_DEPRESSED}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + EDGE_TYPE_NONE, + EDGE_TYPE_OUTLINE, + EDGE_TYPE_DROP_SHADOW, + EDGE_TYPE_RAISED, + EDGE_TYPE_DEPRESSED + }) + public @interface EdgeType {} + /** + * Edge type value specifying no character edges. + */ + public static final int EDGE_TYPE_NONE = 0; + /** + * Edge type value specifying uniformly outlined character edges. + */ + public static final int EDGE_TYPE_OUTLINE = 1; + /** + * Edge type value specifying drop-shadowed character edges. + */ + public static final int EDGE_TYPE_DROP_SHADOW = 2; + /** + * Edge type value specifying raised bevel character edges. + */ + public static final int EDGE_TYPE_RAISED = 3; + /** + * Edge type value specifying depressed bevel character edges. + */ + public static final int EDGE_TYPE_DEPRESSED = 4; + + /** + * Use color setting specified by the track and fallback to default caption style. + */ + public static final int USE_TRACK_COLOR_SETTINGS = 1; + + /** Default caption style. */ + public static final CaptionStyleCompat DEFAULT = + new CaptionStyleCompat( + Color.WHITE, + Color.BLACK, + Color.TRANSPARENT, + EDGE_TYPE_NONE, + Color.WHITE, + /* typeface= */ null); + + /** + * The preferred foreground color. + */ + public final int foregroundColor; + + /** + * The preferred background color. + */ + public final int backgroundColor; + + /** + * The preferred window color. + */ + public final int windowColor; + + /** + * The preferred edge type. One of: + * <ul> + * <li>{@link #EDGE_TYPE_NONE} + * <li>{@link #EDGE_TYPE_OUTLINE} + * <li>{@link #EDGE_TYPE_DROP_SHADOW} + * <li>{@link #EDGE_TYPE_RAISED} + * <li>{@link #EDGE_TYPE_DEPRESSED} + * </ul> + */ + @EdgeType public final int edgeType; + + /** + * The preferred edge color, if using an edge type other than {@link #EDGE_TYPE_NONE}. + */ + public final int edgeColor; + + /** The preferred typeface, or {@code null} if unspecified. */ + @Nullable public final Typeface typeface; + + /** + * Creates a {@link CaptionStyleCompat} equivalent to a provided {@link CaptionStyle}. + * + * @param captionStyle A {@link CaptionStyle}. + * @return The equivalent {@link CaptionStyleCompat}. + */ + @TargetApi(19) + public static CaptionStyleCompat createFromCaptionStyle( + CaptioningManager.CaptionStyle captionStyle) { + if (Util.SDK_INT >= 21) { + return createFromCaptionStyleV21(captionStyle); + } else { + // Note - Any caller must be on at least API level 19 or greater (because CaptionStyle did + // not exist in earlier API levels). + return createFromCaptionStyleV19(captionStyle); + } + } + + /** + * @param foregroundColor See {@link #foregroundColor}. + * @param backgroundColor See {@link #backgroundColor}. + * @param windowColor See {@link #windowColor}. + * @param edgeType See {@link #edgeType}. + * @param edgeColor See {@link #edgeColor}. + * @param typeface See {@link #typeface}. + */ + public CaptionStyleCompat( + int foregroundColor, + int backgroundColor, + int windowColor, + @EdgeType int edgeType, + int edgeColor, + @Nullable Typeface typeface) { + this.foregroundColor = foregroundColor; + this.backgroundColor = backgroundColor; + this.windowColor = windowColor; + this.edgeType = edgeType; + this.edgeColor = edgeColor; + this.typeface = typeface; + } + + @TargetApi(19) + @SuppressWarnings("ResourceType") + private static CaptionStyleCompat createFromCaptionStyleV19( + CaptioningManager.CaptionStyle captionStyle) { + return new CaptionStyleCompat( + captionStyle.foregroundColor, captionStyle.backgroundColor, Color.TRANSPARENT, + captionStyle.edgeType, captionStyle.edgeColor, captionStyle.getTypeface()); + } + + @TargetApi(21) + @SuppressWarnings("ResourceType") + private static CaptionStyleCompat createFromCaptionStyleV21( + CaptioningManager.CaptionStyle captionStyle) { + return new CaptionStyleCompat( + captionStyle.hasForegroundColor() ? captionStyle.foregroundColor : DEFAULT.foregroundColor, + captionStyle.hasBackgroundColor() ? captionStyle.backgroundColor : DEFAULT.backgroundColor, + captionStyle.hasWindowColor() ? captionStyle.windowColor : DEFAULT.windowColor, + captionStyle.hasEdgeType() ? captionStyle.edgeType : DEFAULT.edgeType, + captionStyle.hasEdgeColor() ? captionStyle.edgeColor : DEFAULT.edgeColor, + captionStyle.getTypeface()); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java new file mode 100644 index 0000000000..71627781c1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Cue.java @@ -0,0 +1,435 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import android.graphics.Bitmap; +import android.graphics.Color; +import android.text.Layout.Alignment; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Contains information about a specific cue, including textual content and formatting data. + */ +public class Cue { + + /** The empty cue. */ + public static final Cue EMPTY = new Cue(""); + + /** An unset position, width or size. */ + // Note: We deliberately don't use Float.MIN_VALUE because it's positive & very close to zero. + public static final float DIMEN_UNSET = -Float.MAX_VALUE; + + /** + * The type of anchor, which may be unset. One of {@link #TYPE_UNSET}, {@link #ANCHOR_TYPE_START}, + * {@link #ANCHOR_TYPE_MIDDLE} or {@link #ANCHOR_TYPE_END}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_UNSET, ANCHOR_TYPE_START, ANCHOR_TYPE_MIDDLE, ANCHOR_TYPE_END}) + public @interface AnchorType {} + + /** + * An unset anchor or line type value. + */ + public static final int TYPE_UNSET = Integer.MIN_VALUE; + + /** + * Anchors the left (for horizontal positions) or top (for vertical positions) edge of the cue + * box. + */ + public static final int ANCHOR_TYPE_START = 0; + + /** + * Anchors the middle of the cue box. + */ + public static final int ANCHOR_TYPE_MIDDLE = 1; + + /** + * Anchors the right (for horizontal positions) or bottom (for vertical positions) edge of the cue + * box. + */ + public static final int ANCHOR_TYPE_END = 2; + + /** + * The type of line, which may be unset. One of {@link #TYPE_UNSET}, {@link #LINE_TYPE_FRACTION} + * or {@link #LINE_TYPE_NUMBER}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_UNSET, LINE_TYPE_FRACTION, LINE_TYPE_NUMBER}) + public @interface LineType {} + + /** + * Value for {@link #lineType} when {@link #line} is a fractional position. + */ + public static final int LINE_TYPE_FRACTION = 0; + + /** + * Value for {@link #lineType} when {@link #line} is a line number. + */ + public static final int LINE_TYPE_NUMBER = 1; + + /** + * The type of default text size for this cue, which may be unset. One of {@link #TYPE_UNSET}, + * {@link #TEXT_SIZE_TYPE_FRACTIONAL}, {@link #TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING} or {@link + * #TEXT_SIZE_TYPE_ABSOLUTE}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TYPE_UNSET, + TEXT_SIZE_TYPE_FRACTIONAL, + TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING, + TEXT_SIZE_TYPE_ABSOLUTE + }) + public @interface TextSizeType {} + + /** Text size is measured as a fraction of the viewport size minus the view padding. */ + public static final int TEXT_SIZE_TYPE_FRACTIONAL = 0; + + /** Text size is measured as a fraction of the viewport size, ignoring the view padding */ + public static final int TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING = 1; + + /** Text size is measured in number of pixels. */ + public static final int TEXT_SIZE_TYPE_ABSOLUTE = 2; + + /** + * The cue text, or null if this is an image cue. Note the {@link CharSequence} may be decorated + * with styling spans. + */ + @Nullable public final CharSequence text; + + /** The alignment of the cue text within the cue box, or null if the alignment is undefined. */ + @Nullable public final Alignment textAlignment; + + /** The cue image, or null if this is a text cue. */ + @Nullable public final Bitmap bitmap; + + /** + * The position of the {@link #lineAnchor} of the cue box within the viewport in the direction + * orthogonal to the writing direction, or {@link #DIMEN_UNSET}. When set, the interpretation of + * the value depends on the value of {@link #lineType}. + * <p> + * For horizontal text and {@link #lineType} equal to {@link #LINE_TYPE_FRACTION}, this is the + * fractional vertical position relative to the top of the viewport. + */ + public final float line; + + /** + * The type of the {@link #line} value. + * + * <p>{@link #LINE_TYPE_FRACTION} indicates that {@link #line} is a fractional position within the + * viewport. + * + * <p>{@link #LINE_TYPE_NUMBER} indicates that {@link #line} is a line number, where the size of + * each line is taken to be the size of the first line of the cue. When {@link #line} is greater + * than or equal to 0 lines count from the start of the viewport, with 0 indicating zero offset + * from the start edge. When {@link #line} is negative lines count from the end of the viewport, + * with -1 indicating zero offset from the end edge. For horizontal text the line spacing is the + * height of the first line of the cue, and the start and end of the viewport are the top and + * bottom respectively. + * + * <p>Note that it's particularly important to consider the effect of {@link #lineAnchor} when + * using {@link #LINE_TYPE_NUMBER}. {@code (line == 0 && lineAnchor == ANCHOR_TYPE_START)} + * positions a (potentially multi-line) cue at the very top of the viewport. {@code (line == -1 && + * lineAnchor == ANCHOR_TYPE_END)} positions a (potentially multi-line) cue at the very bottom of + * the viewport. {@code (line == 0 && lineAnchor == ANCHOR_TYPE_END)} and {@code (line == -1 && + * lineAnchor == ANCHOR_TYPE_START)} position cues entirely outside of the viewport. {@code (line + * == 1 && lineAnchor == ANCHOR_TYPE_END)} positions a cue so that only the last line is visible + * at the top of the viewport. {@code (line == -2 && lineAnchor == ANCHOR_TYPE_START)} position a + * cue so that only its first line is visible at the bottom of the viewport. + */ + public final @LineType int lineType; + + /** + * The cue box anchor positioned by {@link #line}. One of {@link #ANCHOR_TYPE_START}, {@link + * #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + * + * <p>For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link + * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the top, middle and bottom of + * the cue box respectively. + */ + public final @AnchorType int lineAnchor; + + /** + * The fractional position of the {@link #positionAnchor} of the cue box within the viewport in + * the direction orthogonal to {@link #line}, or {@link #DIMEN_UNSET}. + * <p> + * For horizontal text, this is the horizontal position relative to the left of the viewport. Note + * that positioning is relative to the left of the viewport even in the case of right-to-left + * text. + */ + public final float position; + + /** + * The cue box anchor positioned by {@link #position}. One of {@link #ANCHOR_TYPE_START}, {@link + * #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + * + * <p>For the normal case of horizontal text, {@link #ANCHOR_TYPE_START}, {@link + * #ANCHOR_TYPE_MIDDLE} and {@link #ANCHOR_TYPE_END} correspond to the left, middle and right of + * the cue box respectively. + */ + public final @AnchorType int positionAnchor; + + /** + * The size of the cue box in the writing direction specified as a fraction of the viewport size + * in that direction, or {@link #DIMEN_UNSET}. + */ + public final float size; + + /** + * The bitmap height as a fraction of the of the viewport size, or {@link #DIMEN_UNSET} if the + * bitmap should be displayed at its natural height given the bitmap dimensions and the specified + * {@link #size}. + */ + public final float bitmapHeight; + + /** + * Specifies whether or not the {@link #windowColor} property is set. + */ + public final boolean windowColorSet; + + /** + * The fill color of the window. + */ + public final int windowColor; + + /** + * The default text size type for this cue's text, or {@link #TYPE_UNSET} if this cue has no + * default text size. + */ + public final @TextSizeType int textSizeType; + + /** + * The default text size for this cue's text, or {@link #DIMEN_UNSET} if this cue has no default + * text size. + */ + public final float textSize; + + /** + * Creates an image cue. + * + * @param bitmap See {@link #bitmap}. + * @param horizontalPosition The position of the horizontal anchor within the viewport, expressed + * as a fraction of the viewport width. + * @param horizontalPositionAnchor The horizontal anchor. One of {@link #ANCHOR_TYPE_START}, + * {@link #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + * @param verticalPosition The position of the vertical anchor within the viewport, expressed as a + * fraction of the viewport height. + * @param verticalPositionAnchor The vertical anchor. One of {@link #ANCHOR_TYPE_START}, {@link + * #ANCHOR_TYPE_MIDDLE}, {@link #ANCHOR_TYPE_END} and {@link #TYPE_UNSET}. + * @param width The width of the cue as a fraction of the viewport width. + * @param height The height of the cue as a fraction of the viewport height, or {@link + * #DIMEN_UNSET} if the bitmap should be displayed at its natural height for the specified + * {@code width}. + */ + public Cue( + Bitmap bitmap, + float horizontalPosition, + @AnchorType int horizontalPositionAnchor, + float verticalPosition, + @AnchorType int verticalPositionAnchor, + float width, + float height) { + this( + /* text= */ null, + /* textAlignment= */ null, + bitmap, + verticalPosition, + /* lineType= */ LINE_TYPE_FRACTION, + verticalPositionAnchor, + horizontalPosition, + horizontalPositionAnchor, + /* textSizeType= */ TYPE_UNSET, + /* textSize= */ DIMEN_UNSET, + width, + height, + /* windowColorSet= */ false, + /* windowColor= */ Color.BLACK); + } + + /** + * Creates a text cue whose {@link #textAlignment} is null, whose type parameters are set to + * {@link #TYPE_UNSET} and whose dimension parameters are set to {@link #DIMEN_UNSET}. + * + * @param text See {@link #text}. + */ + public Cue(CharSequence text) { + this( + text, + /* textAlignment= */ null, + /* line= */ DIMEN_UNSET, + /* lineType= */ TYPE_UNSET, + /* lineAnchor= */ TYPE_UNSET, + /* position= */ DIMEN_UNSET, + /* positionAnchor= */ TYPE_UNSET, + /* size= */ DIMEN_UNSET); + } + + /** + * Creates a text cue. + * + * @param text See {@link #text}. + * @param textAlignment See {@link #textAlignment}. + * @param line See {@link #line}. + * @param lineType See {@link #lineType}. + * @param lineAnchor See {@link #lineAnchor}. + * @param position See {@link #position}. + * @param positionAnchor See {@link #positionAnchor}. + * @param size See {@link #size}. + */ + public Cue( + CharSequence text, + @Nullable Alignment textAlignment, + float line, + @LineType int lineType, + @AnchorType int lineAnchor, + float position, + @AnchorType int positionAnchor, + float size) { + this( + text, + textAlignment, + line, + lineType, + lineAnchor, + position, + positionAnchor, + size, + /* windowColorSet= */ false, + /* windowColor= */ Color.BLACK); + } + + /** + * Creates a text cue. + * + * @param text See {@link #text}. + * @param textAlignment See {@link #textAlignment}. + * @param line See {@link #line}. + * @param lineType See {@link #lineType}. + * @param lineAnchor See {@link #lineAnchor}. + * @param position See {@link #position}. + * @param positionAnchor See {@link #positionAnchor}. + * @param size See {@link #size}. + * @param textSizeType See {@link #textSizeType}. + * @param textSize See {@link #textSize}. + */ + public Cue( + CharSequence text, + @Nullable Alignment textAlignment, + float line, + @LineType int lineType, + @AnchorType int lineAnchor, + float position, + @AnchorType int positionAnchor, + float size, + @TextSizeType int textSizeType, + float textSize) { + this( + text, + textAlignment, + /* bitmap= */ null, + line, + lineType, + lineAnchor, + position, + positionAnchor, + textSizeType, + textSize, + size, + /* bitmapHeight= */ DIMEN_UNSET, + /* windowColorSet= */ false, + /* windowColor= */ Color.BLACK); + } + + /** + * Creates a text cue. + * + * @param text See {@link #text}. + * @param textAlignment See {@link #textAlignment}. + * @param line See {@link #line}. + * @param lineType See {@link #lineType}. + * @param lineAnchor See {@link #lineAnchor}. + * @param position See {@link #position}. + * @param positionAnchor See {@link #positionAnchor}. + * @param size See {@link #size}. + * @param windowColorSet See {@link #windowColorSet}. + * @param windowColor See {@link #windowColor}. + */ + public Cue( + CharSequence text, + @Nullable Alignment textAlignment, + float line, + @LineType int lineType, + @AnchorType int lineAnchor, + float position, + @AnchorType int positionAnchor, + float size, + boolean windowColorSet, + int windowColor) { + this( + text, + textAlignment, + /* bitmap= */ null, + line, + lineType, + lineAnchor, + position, + positionAnchor, + /* textSizeType= */ TYPE_UNSET, + /* textSize= */ DIMEN_UNSET, + size, + /* bitmapHeight= */ DIMEN_UNSET, + windowColorSet, + windowColor); + } + + private Cue( + @Nullable CharSequence text, + @Nullable Alignment textAlignment, + @Nullable Bitmap bitmap, + float line, + @LineType int lineType, + @AnchorType int lineAnchor, + float position, + @AnchorType int positionAnchor, + @TextSizeType int textSizeType, + float textSize, + float size, + float bitmapHeight, + boolean windowColorSet, + int windowColor) { + this.text = text; + this.textAlignment = textAlignment; + this.bitmap = bitmap; + this.line = line; + this.lineType = lineType; + this.lineAnchor = lineAnchor; + this.position = position; + this.positionAnchor = positionAnchor; + this.size = size; + this.bitmapHeight = bitmapHeight; + this.windowColorSet = windowColorSet; + this.windowColor = windowColor; + this.textSizeType = textSizeType; + this.textSize = textSize; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java new file mode 100644 index 0000000000..b58bb1daea --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleDecoder.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.SimpleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.nio.ByteBuffer; + +/** + * Base class for subtitle parsers that use their own decode thread. + */ +public abstract class SimpleSubtitleDecoder extends + SimpleDecoder<SubtitleInputBuffer, SubtitleOutputBuffer, SubtitleDecoderException> implements + SubtitleDecoder { + + private final String name; + + /** @param name The name of the decoder. */ + @SuppressWarnings("initialization:method.invocation.invalid") + protected SimpleSubtitleDecoder(String name) { + super(new SubtitleInputBuffer[2], new SubtitleOutputBuffer[2]); + this.name = name; + setInitialInputBufferSize(1024); + } + + @Override + public final String getName() { + return name; + } + + @Override + public void setPositionUs(long timeUs) { + // Do nothing + } + + @Override + protected final SubtitleInputBuffer createInputBuffer() { + return new SubtitleInputBuffer(); + } + + @Override + protected final SubtitleOutputBuffer createOutputBuffer() { + return new SimpleSubtitleOutputBuffer(this); + } + + @Override + protected final SubtitleDecoderException createUnexpectedDecodeException(Throwable error) { + return new SubtitleDecoderException("Unexpected decode error", error); + } + + @Override + protected final void releaseOutputBuffer(SubtitleOutputBuffer buffer) { + super.releaseOutputBuffer(buffer); + } + + @SuppressWarnings("ByteBufferBackingArray") + @Override + @Nullable + protected final SubtitleDecoderException decode( + SubtitleInputBuffer inputBuffer, SubtitleOutputBuffer outputBuffer, boolean reset) { + try { + ByteBuffer inputData = Assertions.checkNotNull(inputBuffer.data); + Subtitle subtitle = decode(inputData.array(), inputData.limit(), reset); + outputBuffer.setContent(inputBuffer.timeUs, subtitle, inputBuffer.subsampleOffsetUs); + // Clear BUFFER_FLAG_DECODE_ONLY (see [Internal: b/27893809]). + outputBuffer.clearFlag(C.BUFFER_FLAG_DECODE_ONLY); + return null; + } catch (SubtitleDecoderException e) { + return e; + } + } + + /** + * Decodes data into a {@link Subtitle}. + * + * @param data An array holding the data to be decoded, starting at position 0. + * @param size The size of the data to be decoded. + * @param reset Whether the decoder must be reset before decoding. + * @return The decoded {@link Subtitle}. + * @throws SubtitleDecoderException If a decoding error occurs. + */ + protected abstract Subtitle decode(byte[] data, int size, boolean reset) + throws SubtitleDecoderException; + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java new file mode 100644 index 0000000000..794b6c72f4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SimpleSubtitleOutputBuffer.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +/** + * A {@link SubtitleOutputBuffer} for decoders that extend {@link SimpleSubtitleDecoder}. + */ +/* package */ final class SimpleSubtitleOutputBuffer extends SubtitleOutputBuffer { + + private final SimpleSubtitleDecoder owner; + + /** + * @param owner The decoder that owns this buffer. + */ + public SimpleSubtitleOutputBuffer(SimpleSubtitleDecoder owner) { + super(); + this.owner = owner; + } + + @Override + public final void release() { + owner.releaseOutputBuffer(this); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java new file mode 100644 index 0000000000..0c2a259f37 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/Subtitle.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.List; + +/** + * A subtitle consisting of timed {@link Cue}s. + */ +public interface Subtitle { + + /** + * Returns the index of the first event that occurs after a given time (exclusive). + * + * @param timeUs The time in microseconds. + * @return The index of the next event, or {@link C#INDEX_UNSET} if there are no events after the + * specified time. + */ + int getNextEventTimeIndex(long timeUs); + + /** + * Returns the number of event times, where events are defined as points in time at which the cues + * returned by {@link #getCues(long)} changes. + * + * @return The number of event times. + */ + int getEventTimeCount(); + + /** + * Returns the event time at a specified index. + * + * @param index The index of the event time to obtain. + * @return The event time in microseconds. + */ + long getEventTime(int index); + + /** + * Retrieve the cues that should be displayed at a given time. + * + * @param timeUs The time in microseconds. + * @return A list of cues that should be displayed, possibly empty. + */ + List<Cue> getCues(long timeUs); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java new file mode 100644 index 0000000000..dcf1a0c254 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoder.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.Decoder; + +/** + * Decodes {@link Subtitle}s from {@link SubtitleInputBuffer}s. + */ +public interface SubtitleDecoder extends + Decoder<SubtitleInputBuffer, SubtitleOutputBuffer, SubtitleDecoderException> { + + /** + * Informs the decoder of the current playback position. + * <p> + * Must be called prior to each attempt to dequeue output buffers from the decoder. + * + * @param positionUs The current playback position in microseconds. + */ + void setPositionUs(long positionUs); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java new file mode 100644 index 0000000000..9ee15188b0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderException.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +/** + * Thrown when an error occurs decoding subtitle data. + */ +public class SubtitleDecoderException extends Exception { + + /** + * @param message The detail message for this exception. + */ + public SubtitleDecoderException(String message) { + super(message); + } + + /** @param cause The cause of this exception. */ + public SubtitleDecoderException(Exception cause) { + super(cause); + } + + /** + * @param message The detail message for this exception. + * @param cause The cause of this exception. + */ + public SubtitleDecoderException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java new file mode 100644 index 0000000000..2fb0200f0d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.Cea608Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea.Cea708Decoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb.DvbDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs.PgsDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip.SubripDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml.TtmlDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g.Tx3gDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.Mp4WebvttDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt.WebvttDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +/** + * A factory for {@link SubtitleDecoder} instances. + */ +public interface SubtitleDecoderFactory { + + /** + * Returns whether the factory is able to instantiate a {@link SubtitleDecoder} for the given + * {@link Format}. + * + * @param format The {@link Format}. + * @return Whether the factory can instantiate a suitable {@link SubtitleDecoder}. + */ + boolean supportsFormat(Format format); + + /** + * Creates a {@link SubtitleDecoder} for the given {@link Format}. + * + * @param format The {@link Format}. + * @return A new {@link SubtitleDecoder}. + * @throws IllegalArgumentException If the {@link Format} is not supported. + */ + SubtitleDecoder createDecoder(Format format); + + /** + * Default {@link SubtitleDecoderFactory} implementation. + * + * <p>The formats supported by this factory are: + * + * <ul> + * <li>WebVTT ({@link WebvttDecoder}) + * <li>WebVTT (MP4) ({@link Mp4WebvttDecoder}) + * <li>TTML ({@link TtmlDecoder}) + * <li>SubRip ({@link SubripDecoder}) + * <li>SSA/ASS ({@link SsaDecoder}) + * <li>TX3G ({@link Tx3gDecoder}) + * <li>Cea608 ({@link Cea608Decoder}) + * <li>Cea708 ({@link Cea708Decoder}) + * <li>DVB ({@link DvbDecoder}) + * <li>PGS ({@link PgsDecoder}) + * </ul> + */ + SubtitleDecoderFactory DEFAULT = + new SubtitleDecoderFactory() { + + @Override + public boolean supportsFormat(Format format) { + @Nullable String mimeType = format.sampleMimeType; + return MimeTypes.TEXT_VTT.equals(mimeType) + || MimeTypes.TEXT_SSA.equals(mimeType) + || MimeTypes.APPLICATION_TTML.equals(mimeType) + || MimeTypes.APPLICATION_MP4VTT.equals(mimeType) + || MimeTypes.APPLICATION_SUBRIP.equals(mimeType) + || MimeTypes.APPLICATION_TX3G.equals(mimeType) + || MimeTypes.APPLICATION_CEA608.equals(mimeType) + || MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) + || MimeTypes.APPLICATION_CEA708.equals(mimeType) + || MimeTypes.APPLICATION_DVBSUBS.equals(mimeType) + || MimeTypes.APPLICATION_PGS.equals(mimeType); + } + + @Override + public SubtitleDecoder createDecoder(Format format) { + @Nullable String mimeType = format.sampleMimeType; + if (mimeType != null) { + switch (mimeType) { + case MimeTypes.TEXT_VTT: + return new WebvttDecoder(); + case MimeTypes.TEXT_SSA: + return new SsaDecoder(format.initializationData); + case MimeTypes.APPLICATION_MP4VTT: + return new Mp4WebvttDecoder(); + case MimeTypes.APPLICATION_TTML: + return new TtmlDecoder(); + case MimeTypes.APPLICATION_SUBRIP: + return new SubripDecoder(); + case MimeTypes.APPLICATION_TX3G: + return new Tx3gDecoder(format.initializationData); + case MimeTypes.APPLICATION_CEA608: + case MimeTypes.APPLICATION_MP4CEA608: + return new Cea608Decoder(mimeType, format.accessibilityChannel); + case MimeTypes.APPLICATION_CEA708: + return new Cea708Decoder(format.accessibilityChannel, format.initializationData); + case MimeTypes.APPLICATION_DVBSUBS: + return new DvbDecoder(format.initializationData); + case MimeTypes.APPLICATION_PGS: + return new PgsDecoder(); + default: + break; + } + } + throw new IllegalArgumentException( + "Attempted to create decoder for unsupported MIME type: " + mimeType); + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java new file mode 100644 index 0000000000..dbcfe649b8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleInputBuffer.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; + +/** A {@link DecoderInputBuffer} for a {@link SubtitleDecoder}. */ +public class SubtitleInputBuffer extends DecoderInputBuffer { + + /** + * An offset that must be added to the subtitle's event times after it's been decoded, or + * {@link Format#OFFSET_SAMPLE_RELATIVE} if {@link #timeUs} should be added. + */ + public long subsampleOffsetUs; + + public SubtitleInputBuffer() { + super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java new file mode 100644 index 0000000000..9cc7671b24 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/SubtitleOutputBuffer.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.OutputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.List; + +/** + * Base class for {@link SubtitleDecoder} output buffers. + */ +public abstract class SubtitleOutputBuffer extends OutputBuffer implements Subtitle { + + @Nullable private Subtitle subtitle; + private long subsampleOffsetUs; + + /** + * Sets the content of the output buffer, consisting of a {@link Subtitle} and associated + * metadata. + * + * @param timeUs The time of the start of the subtitle in microseconds. + * @param subtitle The subtitle. + * @param subsampleOffsetUs An offset that must be added to the subtitle's event times, or + * {@link Format#OFFSET_SAMPLE_RELATIVE} if {@code timeUs} should be added. + */ + public void setContent(long timeUs, Subtitle subtitle, long subsampleOffsetUs) { + this.timeUs = timeUs; + this.subtitle = subtitle; + this.subsampleOffsetUs = subsampleOffsetUs == Format.OFFSET_SAMPLE_RELATIVE ? this.timeUs + : subsampleOffsetUs; + } + + @Override + public int getEventTimeCount() { + return Assertions.checkNotNull(subtitle).getEventTimeCount(); + } + + @Override + public long getEventTime(int index) { + return Assertions.checkNotNull(subtitle).getEventTime(index) + subsampleOffsetUs; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + return Assertions.checkNotNull(subtitle).getNextEventTimeIndex(timeUs - subsampleOffsetUs); + } + + @Override + public List<Cue> getCues(long timeUs) { + return Assertions.checkNotNull(subtitle).getCues(timeUs - subsampleOffsetUs); + } + + @Override + public abstract void release(); + + @Override + public void clear() { + super.clear(); + subtitle = null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java new file mode 100644 index 0000000000..b15a2f1b35 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextOutput.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import java.util.List; + +/** + * Receives text output. + */ +public interface TextOutput { + + /** + * Called when there is a change in the {@link Cue}s. + * + * @param cues The {@link Cue}s. May be empty. + */ + void onCues(List<Cue> cues); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java new file mode 100644 index 0000000000..428b106fcd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/TextRenderer.java @@ -0,0 +1,350 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Looper; +import android.os.Message; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +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.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.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; + +/** + * A renderer for text. + * <p> + * {@link Subtitle}s are decoded from sample data using {@link SubtitleDecoder} instances obtained + * from a {@link SubtitleDecoderFactory}. The actual rendering of the subtitle {@link Cue}s is + * delegated to a {@link TextOutput}. + */ +public final class TextRenderer extends BaseRenderer implements Callback { + + private static final String TAG = "TextRenderer"; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + REPLACEMENT_STATE_NONE, + REPLACEMENT_STATE_SIGNAL_END_OF_STREAM, + REPLACEMENT_STATE_WAIT_END_OF_STREAM + }) + private @interface ReplacementState {} + /** + * The decoder does not need to be replaced. + */ + private static final int REPLACEMENT_STATE_NONE = 0; + /** + * The decoder needs to be replaced, but we haven't yet signaled an end of stream to the existing + * decoder. We need to do so in order to ensure that it outputs any remaining buffers before we + * release it. + */ + private static final int REPLACEMENT_STATE_SIGNAL_END_OF_STREAM = 1; + /** + * The decoder needs to be replaced, and we've signaled an end of stream to the existing decoder. + * We're waiting for the decoder to output an end of stream signal to indicate that it has output + * any remaining buffers before we release it. + */ + private static final int REPLACEMENT_STATE_WAIT_END_OF_STREAM = 2; + + private static final int MSG_UPDATE_OUTPUT = 0; + + @Nullable private final Handler outputHandler; + private final TextOutput output; + private final SubtitleDecoderFactory decoderFactory; + private final FormatHolder formatHolder; + + private boolean inputStreamEnded; + private boolean outputStreamEnded; + @ReplacementState private int decoderReplacementState; + @Nullable private Format streamFormat; + @Nullable private SubtitleDecoder decoder; + @Nullable private SubtitleInputBuffer nextInputBuffer; + @Nullable private SubtitleOutputBuffer subtitle; + @Nullable private SubtitleOutputBuffer nextSubtitle; + private int nextSubtitleEventIndex; + + /** + * @param output The output. + * @param outputLooper The looper associated with the thread on which the output should be called. + * If the output makes use of standard Android UI components, then this should normally be the + * looper associated with the application's main thread, which can be obtained using {@link + * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called + * directly on the player's internal rendering thread. + */ + public TextRenderer(TextOutput output, @Nullable Looper outputLooper) { + this(output, outputLooper, SubtitleDecoderFactory.DEFAULT); + } + + /** + * @param output The output. + * @param outputLooper The looper associated with the thread on which the output should be called. + * If the output makes use of standard Android UI components, then this should normally be the + * looper associated with the application's main thread, which can be obtained using {@link + * android.app.Activity#getMainLooper()}. Null may be passed if the output should be called + * directly on the player's internal rendering thread. + * @param decoderFactory A factory from which to obtain {@link SubtitleDecoder} instances. + */ + public TextRenderer( + TextOutput output, @Nullable Looper outputLooper, SubtitleDecoderFactory decoderFactory) { + super(C.TRACK_TYPE_TEXT); + this.output = Assertions.checkNotNull(output); + this.outputHandler = + outputLooper == null ? null : Util.createHandler(outputLooper, /* callback= */ this); + this.decoderFactory = decoderFactory; + formatHolder = new FormatHolder(); + } + + @Override + @Capabilities + public int supportsFormat(Format format) { + if (decoderFactory.supportsFormat(format)) { + return RendererCapabilities.create( + supportsFormatDrm(null, format.drmInitData) ? FORMAT_HANDLED : FORMAT_UNSUPPORTED_DRM); + } else if (MimeTypes.isText(format.sampleMimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } else { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) { + streamFormat = formats[0]; + if (decoder != null) { + decoderReplacementState = REPLACEMENT_STATE_SIGNAL_END_OF_STREAM; + } else { + decoder = decoderFactory.createDecoder(streamFormat); + } + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) { + inputStreamEnded = false; + outputStreamEnded = false; + resetOutputAndDecoder(); + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) { + if (outputStreamEnded) { + return; + } + + if (nextSubtitle == null) { + decoder.setPositionUs(positionUs); + try { + nextSubtitle = decoder.dequeueOutputBuffer(); + } catch (SubtitleDecoderException e) { + handleDecoderError(e); + return; + } + } + + if (getState() != STATE_STARTED) { + return; + } + + boolean textRendererNeedsUpdate = false; + if (subtitle != null) { + // We're iterating through the events in a subtitle. Set textRendererNeedsUpdate if we + // advance to the next event. + long subtitleNextEventTimeUs = getNextEventTime(); + while (subtitleNextEventTimeUs <= positionUs) { + nextSubtitleEventIndex++; + subtitleNextEventTimeUs = getNextEventTime(); + textRendererNeedsUpdate = true; + } + } + + if (nextSubtitle != null) { + if (nextSubtitle.isEndOfStream()) { + if (!textRendererNeedsUpdate && getNextEventTime() == Long.MAX_VALUE) { + if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { + replaceDecoder(); + } else { + releaseBuffers(); + outputStreamEnded = true; + } + } + } else if (nextSubtitle.timeUs <= positionUs) { + // Advance to the next subtitle. Sync the next event index and trigger an update. + if (subtitle != null) { + subtitle.release(); + } + subtitle = nextSubtitle; + nextSubtitle = null; + nextSubtitleEventIndex = subtitle.getNextEventTimeIndex(positionUs); + textRendererNeedsUpdate = true; + } + } + + if (textRendererNeedsUpdate) { + // textRendererNeedsUpdate is set and we're playing. Update the renderer. + updateOutput(subtitle.getCues(positionUs)); + } + + if (decoderReplacementState == REPLACEMENT_STATE_WAIT_END_OF_STREAM) { + return; + } + + try { + while (!inputStreamEnded) { + if (nextInputBuffer == null) { + nextInputBuffer = decoder.dequeueInputBuffer(); + if (nextInputBuffer == null) { + return; + } + } + if (decoderReplacementState == REPLACEMENT_STATE_SIGNAL_END_OF_STREAM) { + nextInputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(nextInputBuffer); + nextInputBuffer = null; + decoderReplacementState = REPLACEMENT_STATE_WAIT_END_OF_STREAM; + return; + } + // Try and read the next subtitle from the source. + int result = readSource(formatHolder, nextInputBuffer, false); + if (result == C.RESULT_BUFFER_READ) { + if (nextInputBuffer.isEndOfStream()) { + inputStreamEnded = true; + } else { + nextInputBuffer.subsampleOffsetUs = formatHolder.format.subsampleOffsetUs; + nextInputBuffer.flip(); + } + decoder.queueInputBuffer(nextInputBuffer); + nextInputBuffer = null; + } else if (result == C.RESULT_NOTHING_READ) { + return; + } + } + } catch (SubtitleDecoderException e) { + handleDecoderError(e); + return; + } + } + + @Override + protected void onDisabled() { + streamFormat = null; + clearOutput(); + releaseDecoder(); + } + + @Override + public boolean isEnded() { + return outputStreamEnded; + } + + @Override + public boolean isReady() { + // Don't block playback whilst subtitles are loading. + // Note: To change this behavior, it will be necessary to consider [Internal: b/12949941]. + return true; + } + + private void releaseBuffers() { + nextInputBuffer = null; + nextSubtitleEventIndex = C.INDEX_UNSET; + if (subtitle != null) { + subtitle.release(); + subtitle = null; + } + if (nextSubtitle != null) { + nextSubtitle.release(); + nextSubtitle = null; + } + } + + private void releaseDecoder() { + releaseBuffers(); + decoder.release(); + decoder = null; + decoderReplacementState = REPLACEMENT_STATE_NONE; + } + + private void replaceDecoder() { + releaseDecoder(); + decoder = decoderFactory.createDecoder(streamFormat); + } + + private long getNextEventTime() { + return nextSubtitleEventIndex == C.INDEX_UNSET + || nextSubtitleEventIndex >= subtitle.getEventTimeCount() + ? Long.MAX_VALUE : subtitle.getEventTime(nextSubtitleEventIndex); + } + + private void updateOutput(List<Cue> cues) { + if (outputHandler != null) { + outputHandler.obtainMessage(MSG_UPDATE_OUTPUT, cues).sendToTarget(); + } else { + invokeUpdateOutputInternal(cues); + } + } + + private void clearOutput() { + updateOutput(Collections.emptyList()); + } + + @SuppressWarnings("unchecked") + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_UPDATE_OUTPUT: + invokeUpdateOutputInternal((List<Cue>) msg.obj); + return true; + default: + throw new IllegalStateException(); + } + } + + private void invokeUpdateOutputInternal(List<Cue> cues) { + output.onCues(cues); + } + + /** + * Called when {@link #decoder} throws an exception, so it can be logged and playback can + * continue. + * + * <p>Logs {@code e} and resets state to allow decoding the next sample. + */ + private void handleDecoderError(SubtitleDecoderException e) { + Log.e(TAG, "Subtitle decoding failed. streamFormat=" + streamFormat, e); + resetOutputAndDecoder(); + } + + private void resetOutputAndDecoder() { + clearOutput(); + if (decoderReplacementState != REPLACEMENT_STATE_NONE) { + replaceDecoder(); + } else { + releaseBuffers(); + decoder.flush(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java new file mode 100644 index 0000000000..320b4f3f07 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea608Decoder.java @@ -0,0 +1,1014 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Layout.Alignment; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A {@link SubtitleDecoder} for CEA-608 (also known as "line 21 captions" and "EIA-608"). + */ +public final class Cea608Decoder extends CeaDecoder { + + private static final String TAG = "Cea608Decoder"; + + private static final int CC_VALID_FLAG = 0x04; + private static final int CC_TYPE_FLAG = 0x02; + private static final int CC_FIELD_FLAG = 0x01; + + private static final int NTSC_CC_FIELD_1 = 0x00; + private static final int NTSC_CC_FIELD_2 = 0x01; + private static final int NTSC_CC_CHANNEL_1 = 0x00; + private static final int NTSC_CC_CHANNEL_2 = 0x01; + + private static final int CC_MODE_UNKNOWN = 0; + private static final int CC_MODE_ROLL_UP = 1; + private static final int CC_MODE_POP_ON = 2; + private static final int CC_MODE_PAINT_ON = 3; + + private static final int[] ROW_INDICES = new int[] {11, 1, 3, 12, 14, 5, 7, 9}; + private static final int[] COLUMN_INDICES = new int[] {0, 4, 8, 12, 16, 20, 24, 28}; + + private static final int[] STYLE_COLORS = + new int[] { + Color.WHITE, Color.GREEN, Color.BLUE, Color.CYAN, Color.RED, Color.YELLOW, Color.MAGENTA + }; + private static final int STYLE_ITALICS = 0x07; + private static final int STYLE_UNCHANGED = 0x08; + + // The default number of rows to display in roll-up captions mode. + private static final int DEFAULT_CAPTIONS_ROW_COUNT = 4; + + // An implied first byte for packets that are only 2 bytes long, consisting of marker bits + // (0b11111) + valid bit (0b1) + NTSC field 1 type bits (0b00). + private static final byte CC_IMPLICIT_DATA_HEADER = (byte) 0xFC; + + /** + * Command initiating pop-on style captioning. Subsequent data should be loaded into a + * non-displayed memory and held there until the {@link #CTRL_END_OF_CAPTION} command is received, + * at which point the non-displayed memory becomes the displayed memory (and vice versa). + */ + private static final byte CTRL_RESUME_CAPTION_LOADING = 0x20; + + private static final byte CTRL_BACKSPACE = 0x21; + + private static final byte CTRL_DELETE_TO_END_OF_ROW = 0x24; + + /** + * Command initiating roll-up style captioning, with the maximum of 2 rows displayed + * simultaneously. + */ + private static final byte CTRL_ROLL_UP_CAPTIONS_2_ROWS = 0x25; + /** + * Command initiating roll-up style captioning, with the maximum of 3 rows displayed + * simultaneously. + */ + private static final byte CTRL_ROLL_UP_CAPTIONS_3_ROWS = 0x26; + /** + * Command initiating roll-up style captioning, with the maximum of 4 rows displayed + * simultaneously. + */ + private static final byte CTRL_ROLL_UP_CAPTIONS_4_ROWS = 0x27; + + /** + * Command initiating paint-on style captioning. Subsequent data should be addressed immediately + * to displayed memory without need for the {@link #CTRL_RESUME_CAPTION_LOADING} command. + */ + private static final byte CTRL_RESUME_DIRECT_CAPTIONING = 0x29; + /** + * TEXT commands are switching to TEXT service. All consecutive incoming data must be filtered out + * until a command is received that switches back to the CAPTION service. + */ + private static final byte CTRL_TEXT_RESTART = 0x2A; + + private static final byte CTRL_RESUME_TEXT_DISPLAY = 0x2B; + + private static final byte CTRL_ERASE_DISPLAYED_MEMORY = 0x2C; + private static final byte CTRL_CARRIAGE_RETURN = 0x2D; + private static final byte CTRL_ERASE_NON_DISPLAYED_MEMORY = 0x2E; + + /** + * Command indicating the end of a pop-on style caption. At this point the caption loaded in + * non-displayed memory should be swapped with the one in displayed memory. If no {@link + * #CTRL_RESUME_CAPTION_LOADING} command has been received, this command forces the receiver into + * pop-on style. + */ + private static final byte CTRL_END_OF_CAPTION = 0x2F; + + // Basic North American 608 CC char set, mostly ASCII. Indexed by (char-0x20). + private static final int[] BASIC_CHARACTER_SET = new int[] { + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, // ! " # $ % & ' + 0x28, 0x29, // ( ) + 0xE1, // 2A: 225 'á' "Latin small letter A with acute" + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, // + , - . / + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, // 0 1 2 3 4 5 6 7 + 0x38, 0x39, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x3F, // 8 9 : ; < = > ? + 0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, // @ A B C D E F G + 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, // H I J K L M N O + 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, // P Q R S T U V W + 0x58, 0x59, 0x5A, 0x5B, // X Y Z [ + 0xE9, // 5C: 233 'é' "Latin small letter E with acute" + 0x5D, // ] + 0xED, // 5E: 237 'í' "Latin small letter I with acute" + 0xF3, // 5F: 243 'ó' "Latin small letter O with acute" + 0xFA, // 60: 250 'ú' "Latin small letter U with acute" + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, // a b c d e f g + 0x68, 0x69, 0x6A, 0x6B, 0x6C, 0x6D, 0x6E, 0x6F, // h i j k l m n o + 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, // p q r s t u v w + 0x78, 0x79, 0x7A, // x y z + 0xE7, // 7B: 231 'ç' "Latin small letter C with cedilla" + 0xF7, // 7C: 247 '÷' "Division sign" + 0xD1, // 7D: 209 'Ñ' "Latin capital letter N with tilde" + 0xF1, // 7E: 241 'ñ' "Latin small letter N with tilde" + 0x25A0 // 7F: "Black Square" (NB: 2588 = Full Block) + }; + + // Special North American 608 CC char set. + private static final int[] SPECIAL_CHARACTER_SET = new int[] { + 0xAE, // 30: 174 '®' "Registered Sign" - registered trademark symbol + 0xB0, // 31: 176 '°' "Degree Sign" + 0xBD, // 32: 189 '½' "Vulgar Fraction One Half" (1/2 symbol) + 0xBF, // 33: 191 '¿' "Inverted Question Mark" + 0x2122, // 34: "Trade Mark Sign" (tm superscript) + 0xA2, // 35: 162 '¢' "Cent Sign" + 0xA3, // 36: 163 '£' "Pound Sign" - pounds sterling + 0x266A, // 37: "Eighth Note" - music note + 0xE0, // 38: 224 'à' "Latin small letter A with grave" + 0x20, // 39: TRANSPARENT SPACE - for now use ordinary space + 0xE8, // 3A: 232 'è' "Latin small letter E with grave" + 0xE2, // 3B: 226 'â' "Latin small letter A with circumflex" + 0xEA, // 3C: 234 'ê' "Latin small letter E with circumflex" + 0xEE, // 3D: 238 'î' "Latin small letter I with circumflex" + 0xF4, // 3E: 244 'ô' "Latin small letter O with circumflex" + 0xFB // 3F: 251 'û' "Latin small letter U with circumflex" + }; + + // Extended Spanish/Miscellaneous and French char set. + private static final int[] SPECIAL_ES_FR_CHARACTER_SET = new int[] { + // Spanish and misc. + 0xC1, 0xC9, 0xD3, 0xDA, 0xDC, 0xFC, 0x2018, 0xA1, + 0x2A, 0x27, 0x2014, 0xA9, 0x2120, 0x2022, 0x201C, 0x201D, + // French. + 0xC0, 0xC2, 0xC7, 0xC8, 0xCA, 0xCB, 0xEB, 0xCE, + 0xCF, 0xEF, 0xD4, 0xD9, 0xF9, 0xDB, 0xAB, 0xBB + }; + + //Extended Portuguese and German/Danish char set. + private static final int[] SPECIAL_PT_DE_CHARACTER_SET = new int[] { + // Portuguese. + 0xC3, 0xE3, 0xCD, 0xCC, 0xEC, 0xD2, 0xF2, 0xD5, + 0xF5, 0x7B, 0x7D, 0x5C, 0x5E, 0x5F, 0x7C, 0x7E, + // German/Danish. + 0xC4, 0xE4, 0xD6, 0xF6, 0xDF, 0xA5, 0xA4, 0x2502, + 0xC5, 0xE5, 0xD8, 0xF8, 0x250C, 0x2510, 0x2514, 0x2518 + }; + + private static final boolean[] ODD_PARITY_BYTE_TABLE = { + false, true, true, false, true, false, false, true, // 0 + true, false, false, true, false, true, true, false, // 8 + true, false, false, true, false, true, true, false, // 16 + false, true, true, false, true, false, false, true, // 24 + true, false, false, true, false, true, true, false, // 32 + false, true, true, false, true, false, false, true, // 40 + false, true, true, false, true, false, false, true, // 48 + true, false, false, true, false, true, true, false, // 56 + true, false, false, true, false, true, true, false, // 64 + false, true, true, false, true, false, false, true, // 72 + false, true, true, false, true, false, false, true, // 80 + true, false, false, true, false, true, true, false, // 88 + false, true, true, false, true, false, false, true, // 96 + true, false, false, true, false, true, true, false, // 104 + true, false, false, true, false, true, true, false, // 112 + false, true, true, false, true, false, false, true, // 120 + true, false, false, true, false, true, true, false, // 128 + false, true, true, false, true, false, false, true, // 136 + false, true, true, false, true, false, false, true, // 144 + true, false, false, true, false, true, true, false, // 152 + false, true, true, false, true, false, false, true, // 160 + true, false, false, true, false, true, true, false, // 168 + true, false, false, true, false, true, true, false, // 176 + false, true, true, false, true, false, false, true, // 184 + false, true, true, false, true, false, false, true, // 192 + true, false, false, true, false, true, true, false, // 200 + true, false, false, true, false, true, true, false, // 208 + false, true, true, false, true, false, false, true, // 216 + true, false, false, true, false, true, true, false, // 224 + false, true, true, false, true, false, false, true, // 232 + false, true, true, false, true, false, false, true, // 240 + true, false, false, true, false, true, true, false, // 248 + }; + + private final ParsableByteArray ccData; + private final int packetLength; + private final int selectedField; + private final int selectedChannel; + private final ArrayList<CueBuilder> cueBuilders; + + private CueBuilder currentCueBuilder; + private List<Cue> cues; + private List<Cue> lastCues; + + private int captionMode; + private int captionRowCount; + + private boolean isCaptionValid; + private boolean repeatableControlSet; + private byte repeatableControlCc1; + private byte repeatableControlCc2; + private int currentChannel; + + // The incoming characters may belong to 3 different services based on the last received control + // codes. The 3 services are Captioning, Text and XDS. The decoder only processes Captioning + // service bytes and drops the rest. + private boolean isInCaptionService; + + public Cea608Decoder(String mimeType, int accessibilityChannel) { + ccData = new ParsableByteArray(); + cueBuilders = new ArrayList<>(); + currentCueBuilder = new CueBuilder(CC_MODE_UNKNOWN, DEFAULT_CAPTIONS_ROW_COUNT); + currentChannel = NTSC_CC_CHANNEL_1; + packetLength = MimeTypes.APPLICATION_MP4CEA608.equals(mimeType) ? 2 : 3; + switch (accessibilityChannel) { + case 1: + selectedChannel = NTSC_CC_CHANNEL_1; + selectedField = NTSC_CC_FIELD_1; + break; + case 2: + selectedChannel = NTSC_CC_CHANNEL_2; + selectedField = NTSC_CC_FIELD_1; + break; + case 3: + selectedChannel = NTSC_CC_CHANNEL_1; + selectedField = NTSC_CC_FIELD_2; + break; + case 4: + selectedChannel = NTSC_CC_CHANNEL_2; + selectedField = NTSC_CC_FIELD_2; + break; + default: + Log.w(TAG, "Invalid channel. Defaulting to CC1."); + selectedChannel = NTSC_CC_CHANNEL_1; + selectedField = NTSC_CC_FIELD_1; + } + + setCaptionMode(CC_MODE_UNKNOWN); + resetCueBuilders(); + isInCaptionService = true; + } + + @Override + public String getName() { + return "Cea608Decoder"; + } + + @Override + public void flush() { + super.flush(); + cues = null; + lastCues = null; + setCaptionMode(CC_MODE_UNKNOWN); + setCaptionRowCount(DEFAULT_CAPTIONS_ROW_COUNT); + resetCueBuilders(); + isCaptionValid = false; + repeatableControlSet = false; + repeatableControlCc1 = 0; + repeatableControlCc2 = 0; + currentChannel = NTSC_CC_CHANNEL_1; + isInCaptionService = true; + } + + @Override + public void release() { + // Do nothing + } + + @Override + protected boolean isNewSubtitleDataAvailable() { + return cues != lastCues; + } + + @Override + protected Subtitle createSubtitle() { + lastCues = cues; + return new CeaSubtitle(cues); + } + + @SuppressWarnings("ByteBufferBackingArray") + @Override + protected void decode(SubtitleInputBuffer inputBuffer) { + ccData.reset(inputBuffer.data.array(), inputBuffer.data.limit()); + boolean captionDataProcessed = false; + while (ccData.bytesLeft() >= packetLength) { + byte ccHeader = packetLength == 2 ? CC_IMPLICIT_DATA_HEADER + : (byte) ccData.readUnsignedByte(); + int ccByte1 = ccData.readUnsignedByte(); + int ccByte2 = ccData.readUnsignedByte(); + + // TODO: We're currently ignoring the top 5 marker bits, which should all be 1s according + // to the CEA-608 specification. We need to determine if the data should be handled + // differently when that is not the case. + + if ((ccHeader & CC_TYPE_FLAG) != 0) { + // Do not process anything that is not part of the 608 byte stream. + continue; + } + + if ((ccHeader & CC_FIELD_FLAG) != selectedField) { + // Do not process packets not within the selected field. + continue; + } + + // Strip the parity bit from each byte to get CC data. + byte ccData1 = (byte) (ccByte1 & 0x7F); + byte ccData2 = (byte) (ccByte2 & 0x7F); + + if (ccData1 == 0 && ccData2 == 0) { + // Ignore empty captions. + continue; + } + + boolean previousIsCaptionValid = isCaptionValid; + isCaptionValid = + (ccHeader & CC_VALID_FLAG) == CC_VALID_FLAG + && ODD_PARITY_BYTE_TABLE[ccByte1] + && ODD_PARITY_BYTE_TABLE[ccByte2]; + + if (isRepeatedCommand(isCaptionValid, ccData1, ccData2)) { + // Ignore repeated valid commands. + continue; + } + + if (!isCaptionValid) { + if (previousIsCaptionValid) { + // The encoder has flipped the validity bit to indicate captions are being turned off. + resetCueBuilders(); + captionDataProcessed = true; + } + continue; + } + + maybeUpdateIsInCaptionService(ccData1, ccData2); + if (!isInCaptionService) { + // Only the Captioning service is supported. Drop all other bytes. + continue; + } + + if (!updateAndVerifyCurrentChannel(ccData1)) { + // Wrong channel. + continue; + } + + if (isCtrlCode(ccData1)) { + if (isSpecialNorthAmericanChar(ccData1, ccData2)) { + currentCueBuilder.append(getSpecialNorthAmericanChar(ccData2)); + } else if (isExtendedWestEuropeanChar(ccData1, ccData2)) { + // Remove standard equivalent of the special extended char before appending new one. + currentCueBuilder.backspace(); + currentCueBuilder.append(getExtendedWestEuropeanChar(ccData1, ccData2)); + } else if (isMidrowCtrlCode(ccData1, ccData2)) { + handleMidrowCtrl(ccData2); + } else if (isPreambleAddressCode(ccData1, ccData2)) { + handlePreambleAddressCode(ccData1, ccData2); + } else if (isTabCtrlCode(ccData1, ccData2)) { + currentCueBuilder.tabOffset = ccData2 - 0x20; + } else if (isMiscCode(ccData1, ccData2)) { + handleMiscCode(ccData2); + } + } else { + // Basic North American character set. + currentCueBuilder.append(getBasicChar(ccData1)); + if ((ccData2 & 0xE0) != 0x00) { + currentCueBuilder.append(getBasicChar(ccData2)); + } + } + captionDataProcessed = true; + } + + if (captionDataProcessed) { + if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { + cues = getDisplayCues(); + } + } + } + + private boolean updateAndVerifyCurrentChannel(byte cc1) { + if (isCtrlCode(cc1)) { + currentChannel = getChannel(cc1); + } + return currentChannel == selectedChannel; + } + + private boolean isRepeatedCommand(boolean captionValid, byte cc1, byte cc2) { + // Most control commands are sent twice in succession to ensure they are received properly. We + // don't want to process duplicate commands, so if we see the same repeatable command twice in a + // row then we ignore the second one. + if (captionValid && isRepeatable(cc1)) { + if (repeatableControlSet && repeatableControlCc1 == cc1 && repeatableControlCc2 == cc2) { + // This is a repeated command, so we ignore it. + repeatableControlSet = false; + return true; + } else { + // This is the first occurrence of a repeatable command. Set the repeatable control + // variables so that we can recognize and ignore a duplicate (if there is one), and then + // continue to process the command below. + repeatableControlSet = true; + repeatableControlCc1 = cc1; + repeatableControlCc2 = cc2; + } + } else { + // This command is not repeatable. + repeatableControlSet = false; + } + return false; + } + + private void handleMidrowCtrl(byte cc2) { + // TODO: support the extended styles (i.e. backgrounds and transparencies) + + // A midrow control code advances the cursor. + currentCueBuilder.append(' '); + + // cc2 - 0|0|1|0|STYLE|U + boolean underline = (cc2 & 0x01) == 0x01; + int style = (cc2 >> 1) & 0x07; + currentCueBuilder.setStyle(style, underline); + } + + private void handlePreambleAddressCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|E|ROW + // C is the channel toggle, E is the extended flag, and ROW is the encoded row + int row = ROW_INDICES[cc1 & 0x07]; + // TODO: support the extended address and style + + // cc2 - 0|1|N|ATTRBTE|U + // N is the next row down toggle, ATTRBTE is the 4-byte encoded attribute, and U is the + // underline toggle. + boolean nextRowDown = (cc2 & 0x20) != 0; + if (nextRowDown) { + row++; + } + + if (row != currentCueBuilder.row) { + if (captionMode != CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { + currentCueBuilder = new CueBuilder(captionMode, captionRowCount); + cueBuilders.add(currentCueBuilder); + } + currentCueBuilder.row = row; + } + + // cc2 - 0|1|N|0|STYLE|U + // cc2 - 0|1|N|1|CURSR|U + boolean isCursor = (cc2 & 0x10) == 0x10; + boolean underline = (cc2 & 0x01) == 0x01; + int cursorOrStyle = (cc2 >> 1) & 0x07; + + // We need to call setStyle even for the isCursor case, to update the underline bit. + // STYLE_UNCHANGED is used for this case. + currentCueBuilder.setStyle(isCursor ? STYLE_UNCHANGED : cursorOrStyle, underline); + + if (isCursor) { + currentCueBuilder.indent = COLUMN_INDICES[cursorOrStyle]; + } + } + + private void handleMiscCode(byte cc2) { + switch (cc2) { + case CTRL_ROLL_UP_CAPTIONS_2_ROWS: + setCaptionMode(CC_MODE_ROLL_UP); + setCaptionRowCount(2); + return; + case CTRL_ROLL_UP_CAPTIONS_3_ROWS: + setCaptionMode(CC_MODE_ROLL_UP); + setCaptionRowCount(3); + return; + case CTRL_ROLL_UP_CAPTIONS_4_ROWS: + setCaptionMode(CC_MODE_ROLL_UP); + setCaptionRowCount(4); + return; + case CTRL_RESUME_CAPTION_LOADING: + setCaptionMode(CC_MODE_POP_ON); + return; + case CTRL_RESUME_DIRECT_CAPTIONING: + setCaptionMode(CC_MODE_PAINT_ON); + return; + default: + // Fall through. + break; + } + + if (captionMode == CC_MODE_UNKNOWN) { + return; + } + + switch (cc2) { + case CTRL_ERASE_DISPLAYED_MEMORY: + cues = Collections.emptyList(); + if (captionMode == CC_MODE_ROLL_UP || captionMode == CC_MODE_PAINT_ON) { + resetCueBuilders(); + } + break; + case CTRL_ERASE_NON_DISPLAYED_MEMORY: + resetCueBuilders(); + break; + case CTRL_END_OF_CAPTION: + cues = getDisplayCues(); + resetCueBuilders(); + break; + case CTRL_CARRIAGE_RETURN: + // carriage returns only apply to rollup captions; don't bother if we don't have anything + // to add a carriage return to + if (captionMode == CC_MODE_ROLL_UP && !currentCueBuilder.isEmpty()) { + currentCueBuilder.rollUp(); + } + break; + case CTRL_BACKSPACE: + currentCueBuilder.backspace(); + break; + case CTRL_DELETE_TO_END_OF_ROW: + // TODO: implement + break; + default: + // Fall through. + break; + } + } + + private List<Cue> getDisplayCues() { + // CEA-608 does not define middle and end alignment, however content providers artificially + // introduce them using whitespace. When each cue is built, we try and infer the alignment based + // on the amount of whitespace either side of the text. To avoid consecutive cues being aligned + // differently, we force all cues to have the same alignment, with start alignment given + // preference, then middle alignment, then end alignment. + @Cue.AnchorType int positionAnchor = Cue.ANCHOR_TYPE_END; + int cueBuilderCount = cueBuilders.size(); + List<Cue> cueBuilderCues = new ArrayList<>(cueBuilderCount); + for (int i = 0; i < cueBuilderCount; i++) { + Cue cue = cueBuilders.get(i).build(/* forcedPositionAnchor= */ Cue.TYPE_UNSET); + cueBuilderCues.add(cue); + if (cue != null) { + positionAnchor = Math.min(positionAnchor, cue.positionAnchor); + } + } + + // Skip null cues and rebuild any that don't have the preferred alignment. + List<Cue> displayCues = new ArrayList<>(cueBuilderCount); + for (int i = 0; i < cueBuilderCount; i++) { + Cue cue = cueBuilderCues.get(i); + if (cue != null) { + if (cue.positionAnchor != positionAnchor) { + cue = cueBuilders.get(i).build(positionAnchor); + } + displayCues.add(cue); + } + } + + return displayCues; + } + + private void setCaptionMode(int captionMode) { + if (this.captionMode == captionMode) { + return; + } + + int oldCaptionMode = this.captionMode; + this.captionMode = captionMode; + + if (captionMode == CC_MODE_PAINT_ON) { + // Switching to paint-on mode should have no effect except to select the mode. + for (int i = 0; i < cueBuilders.size(); i++) { + cueBuilders.get(i).setCaptionMode(captionMode); + } + return; + } + + // Clear the working memory. + resetCueBuilders(); + if (oldCaptionMode == CC_MODE_PAINT_ON || captionMode == CC_MODE_ROLL_UP + || captionMode == CC_MODE_UNKNOWN) { + // When switching from paint-on or to roll-up or unknown, we also need to clear the caption. + cues = Collections.emptyList(); + } + } + + private void setCaptionRowCount(int captionRowCount) { + this.captionRowCount = captionRowCount; + currentCueBuilder.setCaptionRowCount(captionRowCount); + } + + private void resetCueBuilders() { + currentCueBuilder.reset(captionMode); + cueBuilders.clear(); + cueBuilders.add(currentCueBuilder); + } + + private void maybeUpdateIsInCaptionService(byte cc1, byte cc2) { + if (isXdsControlCode(cc1)) { + isInCaptionService = false; + } else if (isServiceSwitchCommand(cc1)) { + switch (cc2) { + case CTRL_TEXT_RESTART: + case CTRL_RESUME_TEXT_DISPLAY: + isInCaptionService = false; + break; + case CTRL_END_OF_CAPTION: + case CTRL_RESUME_CAPTION_LOADING: + case CTRL_RESUME_DIRECT_CAPTIONING: + case CTRL_ROLL_UP_CAPTIONS_2_ROWS: + case CTRL_ROLL_UP_CAPTIONS_3_ROWS: + case CTRL_ROLL_UP_CAPTIONS_4_ROWS: + isInCaptionService = true; + break; + default: + // No update. + } + } + } + + private static char getBasicChar(byte ccData) { + int index = (ccData & 0x7F) - 0x20; + return (char) BASIC_CHARACTER_SET[index]; + } + + private static boolean isSpecialNorthAmericanChar(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|0|0|1 + // cc2 - 0|0|1|1|X|X|X|X + return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x30); + } + + private static char getSpecialNorthAmericanChar(byte ccData) { + int index = ccData & 0x0F; + return (char) SPECIAL_CHARACTER_SET[index]; + } + + private static boolean isExtendedWestEuropeanChar(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|0|1|S + // cc2 - 0|0|1|X|X|X|X|X + return ((cc1 & 0xF6) == 0x12) && ((cc2 & 0xE0) == 0x20); + } + + private static char getExtendedWestEuropeanChar(byte cc1, byte cc2) { + if ((cc1 & 0x01) == 0x00) { + // Extended Spanish/Miscellaneous and French character set (S = 0). + return getExtendedEsFrChar(cc2); + } else { + // Extended Portuguese and German/Danish character set (S = 1). + return getExtendedPtDeChar(cc2); + } + } + + private static char getExtendedEsFrChar(byte ccData) { + int index = ccData & 0x1F; + return (char) SPECIAL_ES_FR_CHARACTER_SET[index]; + } + + private static char getExtendedPtDeChar(byte ccData) { + int index = ccData & 0x1F; + return (char) SPECIAL_PT_DE_CHARACTER_SET[index]; + } + + private static boolean isCtrlCode(byte cc1) { + // cc1 - 0|0|0|X|X|X|X|X + return (cc1 & 0xE0) == 0x00; + } + + private static int getChannel(byte cc1) { + // cc1 - X|X|X|X|C|X|X|X + return (cc1 >> 3) & 0x1; + } + + private static boolean isMidrowCtrlCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|0|0|1 + // cc2 - 0|0|1|0|X|X|X|X + return ((cc1 & 0xF7) == 0x11) && ((cc2 & 0xF0) == 0x20); + } + + private static boolean isPreambleAddressCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|X|X|X + // cc2 - 0|1|X|X|X|X|X|X + return ((cc1 & 0xF0) == 0x10) && ((cc2 & 0xC0) == 0x40); + } + + private static boolean isTabCtrlCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|1|1|1 + // cc2 - 0|0|1|0|0|0|0|1 to 0|0|1|0|0|0|1|1 + return ((cc1 & 0xF7) == 0x17) && (cc2 >= 0x21 && cc2 <= 0x23); + } + + private static boolean isMiscCode(byte cc1, byte cc2) { + // cc1 - 0|0|0|1|C|1|0|F + // cc2 - 0|0|1|0|X|X|X|X + return ((cc1 & 0xF6) == 0x14) && ((cc2 & 0xF0) == 0x20); + } + + private static boolean isRepeatable(byte cc1) { + // cc1 - 0|0|0|1|X|X|X|X + return (cc1 & 0xF0) == 0x10; + } + + private static boolean isXdsControlCode(byte cc1) { + return 0x01 <= cc1 && cc1 <= 0x0F; + } + + private static boolean isServiceSwitchCommand(byte cc1) { + // cc1 - 0|0|0|1|C|1|0|0 + return (cc1 & 0xF7) == 0x14; + } + + private static class CueBuilder { + + // 608 captions define a 15 row by 32 column screen grid. These constants convert from 608 + // positions to normalized screen position. + private static final int SCREEN_CHARWIDTH = 32; + private static final int BASE_ROW = 15; + + private final List<CueStyle> cueStyles; + private final List<SpannableString> rolledUpCaptions; + private final StringBuilder captionStringBuilder; + + private int row; + private int indent; + private int tabOffset; + private int captionMode; + private int captionRowCount; + + public CueBuilder(int captionMode, int captionRowCount) { + cueStyles = new ArrayList<>(); + rolledUpCaptions = new ArrayList<>(); + captionStringBuilder = new StringBuilder(); + reset(captionMode); + setCaptionRowCount(captionRowCount); + } + + public void reset(int captionMode) { + this.captionMode = captionMode; + cueStyles.clear(); + rolledUpCaptions.clear(); + captionStringBuilder.setLength(0); + row = BASE_ROW; + indent = 0; + tabOffset = 0; + } + + public boolean isEmpty() { + return cueStyles.isEmpty() + && rolledUpCaptions.isEmpty() + && captionStringBuilder.length() == 0; + } + + public void setCaptionMode(int captionMode) { + this.captionMode = captionMode; + } + + public void setCaptionRowCount(int captionRowCount) { + this.captionRowCount = captionRowCount; + } + + public void setStyle(int style, boolean underline) { + cueStyles.add(new CueStyle(style, underline, captionStringBuilder.length())); + } + + public void backspace() { + int length = captionStringBuilder.length(); + if (length > 0) { + captionStringBuilder.delete(length - 1, length); + // Decrement style start positions if necessary. + for (int i = cueStyles.size() - 1; i >= 0; i--) { + CueStyle style = cueStyles.get(i); + if (style.start == length) { + style.start--; + } else { + // All earlier cues must have style.start < length. + break; + } + } + } + } + + public void append(char text) { + captionStringBuilder.append(text); + } + + public void rollUp() { + rolledUpCaptions.add(buildCurrentLine()); + captionStringBuilder.setLength(0); + cueStyles.clear(); + int numRows = Math.min(captionRowCount, row); + while (rolledUpCaptions.size() >= numRows) { + rolledUpCaptions.remove(0); + } + } + + public Cue build(@Cue.AnchorType int forcedPositionAnchor) { + SpannableStringBuilder cueString = new SpannableStringBuilder(); + // Add any rolled up captions, separated by new lines. + for (int i = 0; i < rolledUpCaptions.size(); i++) { + cueString.append(rolledUpCaptions.get(i)); + cueString.append('\n'); + } + // Add the current line. + cueString.append(buildCurrentLine()); + + if (cueString.length() == 0) { + // The cue is empty. + return null; + } + + int positionAnchor; + // The number of empty columns before the start of the text, in the range [0-31]. + int startPadding = indent + tabOffset; + // The number of empty columns after the end of the text, in the same range. + int endPadding = SCREEN_CHARWIDTH - startPadding - cueString.length(); + int startEndPaddingDelta = startPadding - endPadding; + if (forcedPositionAnchor != Cue.TYPE_UNSET) { + positionAnchor = forcedPositionAnchor; + } else if (captionMode == CC_MODE_POP_ON + && (Math.abs(startEndPaddingDelta) < 3 || endPadding < 0)) { + // Treat approximately centered pop-on captions as middle aligned. We also treat captions + // that are wider than they should be in this way. See + // https://github.com/google/ExoPlayer/issues/3534. + positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; + } else if (captionMode == CC_MODE_POP_ON && startEndPaddingDelta > 0) { + // Treat pop-on captions with less padding at the end than the start as end aligned. + positionAnchor = Cue.ANCHOR_TYPE_END; + } else { + // For all other cases assume start aligned. + positionAnchor = Cue.ANCHOR_TYPE_START; + } + + float position; + switch (positionAnchor) { + case Cue.ANCHOR_TYPE_MIDDLE: + position = 0.5f; + break; + case Cue.ANCHOR_TYPE_END: + position = (float) (SCREEN_CHARWIDTH - endPadding) / SCREEN_CHARWIDTH; + // Adjust the position to fit within the safe area. + position = position * 0.8f + 0.1f; + break; + case Cue.ANCHOR_TYPE_START: + default: + position = (float) startPadding / SCREEN_CHARWIDTH; + // Adjust the position to fit within the safe area. + position = position * 0.8f + 0.1f; + break; + } + + int lineAnchor; + int line; + // Note: Row indices are in the range [1-15]. + if (captionMode == CC_MODE_ROLL_UP || row > (BASE_ROW / 2)) { + lineAnchor = Cue.ANCHOR_TYPE_END; + line = row - BASE_ROW; + // Two line adjustments. The first is because line indices from the bottom of the window + // start from -1 rather than 0. The second is a blank row to act as the safe area. + line -= 2; + } else { + lineAnchor = Cue.ANCHOR_TYPE_START; + // Line indices from the top of the window start from 0, but we want a blank row to act as + // the safe area. As a result no adjustment is necessary. + line = row; + } + + return new Cue( + cueString, + Alignment.ALIGN_NORMAL, + line, + Cue.LINE_TYPE_NUMBER, + lineAnchor, + position, + positionAnchor, + Cue.DIMEN_UNSET); + } + + private SpannableString buildCurrentLine() { + SpannableStringBuilder builder = new SpannableStringBuilder(captionStringBuilder); + int length = builder.length(); + + int underlineStartPosition = C.INDEX_UNSET; + int italicStartPosition = C.INDEX_UNSET; + int colorStartPosition = 0; + int color = Color.WHITE; + + boolean nextItalic = false; + int nextColor = Color.WHITE; + + for (int i = 0; i < cueStyles.size(); i++) { + CueStyle cueStyle = cueStyles.get(i); + boolean underline = cueStyle.underline; + int style = cueStyle.style; + if (style != STYLE_UNCHANGED) { + // If the style is a color then italic is cleared. + nextItalic = style == STYLE_ITALICS; + // If the style is italic then the color is left unchanged. + nextColor = style == STYLE_ITALICS ? nextColor : STYLE_COLORS[style]; + } + + int position = cueStyle.start; + int nextPosition = (i + 1) < cueStyles.size() ? cueStyles.get(i + 1).start : length; + if (position == nextPosition) { + // There are more cueStyles to process at the current position. + continue; + } + + // Process changes to underline up to the current position. + if (underlineStartPosition != C.INDEX_UNSET && !underline) { + setUnderlineSpan(builder, underlineStartPosition, position); + underlineStartPosition = C.INDEX_UNSET; + } else if (underlineStartPosition == C.INDEX_UNSET && underline) { + underlineStartPosition = position; + } + // Process changes to italic up to the current position. + if (italicStartPosition != C.INDEX_UNSET && !nextItalic) { + setItalicSpan(builder, italicStartPosition, position); + italicStartPosition = C.INDEX_UNSET; + } else if (italicStartPosition == C.INDEX_UNSET && nextItalic) { + italicStartPosition = position; + } + // Process changes to color up to the current position. + if (nextColor != color) { + setColorSpan(builder, colorStartPosition, position, color); + color = nextColor; + colorStartPosition = position; + } + } + + // Add any final spans. + if (underlineStartPosition != C.INDEX_UNSET && underlineStartPosition != length) { + setUnderlineSpan(builder, underlineStartPosition, length); + } + if (italicStartPosition != C.INDEX_UNSET && italicStartPosition != length) { + setItalicSpan(builder, italicStartPosition, length); + } + if (colorStartPosition != length) { + setColorSpan(builder, colorStartPosition, length, color); + } + + return new SpannableString(builder); + } + + private static void setUnderlineSpan(SpannableStringBuilder builder, int start, int end) { + builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + private static void setItalicSpan(SpannableStringBuilder builder, int start, int end) { + builder.setSpan(new StyleSpan(Typeface.ITALIC), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + private static void setColorSpan( + SpannableStringBuilder builder, int start, int end, int color) { + if (color == Color.WHITE) { + // White is treated as the default color (i.e. no span is attached). + return; + } + builder.setSpan(new ForegroundColorSpan(color), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + private static class CueStyle { + + public final int style; + public final boolean underline; + + public int start; + + public CueStyle(int style, boolean underline, int start) { + this.style = style; + this.underline = underline; + this.start = start; + } + + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java new file mode 100644 index 0000000000..268b6baec0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Cue.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import android.text.Layout.Alignment; +import androidx.annotation.NonNull; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; + +/** + * A {@link Cue} for CEA-708. + */ +/* package */ final class Cea708Cue extends Cue implements Comparable<Cea708Cue> { + + /** + * The priority of the cue box. + */ + public final int priority; + + /** + * @param text See {@link #text}. + * @param textAlignment See {@link #textAlignment}. + * @param line See {@link #line}. + * @param lineType See {@link #lineType}. + * @param lineAnchor See {@link #lineAnchor}. + * @param position See {@link #position}. + * @param positionAnchor See {@link #positionAnchor}. + * @param size See {@link #size}. + * @param windowColorSet See {@link #windowColorSet}. + * @param windowColor See {@link #windowColor}. + * @param priority See (@link #priority}. + */ + public Cea708Cue(CharSequence text, Alignment textAlignment, float line, @LineType int lineType, + @AnchorType int lineAnchor, float position, @AnchorType int positionAnchor, float size, + boolean windowColorSet, int windowColor, int priority) { + super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, size, + windowColorSet, windowColor); + this.priority = priority; + } + + @Override + public int compareTo(@NonNull Cea708Cue other) { + if (other.priority < priority) { + return -1; + } else if (other.priority > priority) { + return 1; + } + return 0; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java new file mode 100644 index 0000000000..c8af0ed350 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708Decoder.java @@ -0,0 +1,1255 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.Layout.Alignment; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.UnderlineSpan; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue.AnchorType; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * A {@link SubtitleDecoder} for CEA-708 (also known as "EIA-708"). + */ +public final class Cea708Decoder extends CeaDecoder { + + private static final String TAG = "Cea708Decoder"; + + private static final int NUM_WINDOWS = 8; + + private static final int DTVCC_PACKET_DATA = 0x02; + private static final int DTVCC_PACKET_START = 0x03; + private static final int CC_VALID_FLAG = 0x04; + + // Base Commands + private static final int GROUP_C0_END = 0x1F; // Miscellaneous Control Codes + private static final int GROUP_G0_END = 0x7F; // ASCII Printable Characters + private static final int GROUP_C1_END = 0x9F; // Captioning Command Control Codes + private static final int GROUP_G1_END = 0xFF; // ISO 8859-1 LATIN-1 Character Set + + // Extended Commands + private static final int GROUP_C2_END = 0x1F; // Extended Control Code Set 1 + private static final int GROUP_G2_END = 0x7F; // Extended Miscellaneous Characters + private static final int GROUP_C3_END = 0x9F; // Extended Control Code Set 2 + private static final int GROUP_G3_END = 0xFF; // Future Expansion + + // Group C0 Commands + private static final int COMMAND_NUL = 0x00; // Nul + private static final int COMMAND_ETX = 0x03; // EndOfText + private static final int COMMAND_BS = 0x08; // Backspace + private static final int COMMAND_FF = 0x0C; // FormFeed (Flush) + private static final int COMMAND_CR = 0x0D; // CarriageReturn + private static final int COMMAND_HCR = 0x0E; // ClearLine + private static final int COMMAND_EXT1 = 0x10; // Extended Control Code Flag + private static final int COMMAND_EXT1_START = 0x11; + private static final int COMMAND_EXT1_END = 0x17; + private static final int COMMAND_P16_START = 0x18; + private static final int COMMAND_P16_END = 0x1F; + + // Group C1 Commands + private static final int COMMAND_CW0 = 0x80; // SetCurrentWindow to 0 + private static final int COMMAND_CW1 = 0x81; // SetCurrentWindow to 1 + private static final int COMMAND_CW2 = 0x82; // SetCurrentWindow to 2 + private static final int COMMAND_CW3 = 0x83; // SetCurrentWindow to 3 + private static final int COMMAND_CW4 = 0x84; // SetCurrentWindow to 4 + private static final int COMMAND_CW5 = 0x85; // SetCurrentWindow to 5 + private static final int COMMAND_CW6 = 0x86; // SetCurrentWindow to 6 + private static final int COMMAND_CW7 = 0x87; // SetCurrentWindow to 7 + private static final int COMMAND_CLW = 0x88; // ClearWindows (+1 byte) + private static final int COMMAND_DSW = 0x89; // DisplayWindows (+1 byte) + private static final int COMMAND_HDW = 0x8A; // HideWindows (+1 byte) + private static final int COMMAND_TGW = 0x8B; // ToggleWindows (+1 byte) + private static final int COMMAND_DLW = 0x8C; // DeleteWindows (+1 byte) + private static final int COMMAND_DLY = 0x8D; // Delay (+1 byte) + private static final int COMMAND_DLC = 0x8E; // DelayCancel + private static final int COMMAND_RST = 0x8F; // Reset + private static final int COMMAND_SPA = 0x90; // SetPenAttributes (+2 bytes) + private static final int COMMAND_SPC = 0x91; // SetPenColor (+3 bytes) + private static final int COMMAND_SPL = 0x92; // SetPenLocation (+2 bytes) + private static final int COMMAND_SWA = 0x97; // SetWindowAttributes (+4 bytes) + private static final int COMMAND_DF0 = 0x98; // DefineWindow 0 (+6 bytes) + private static final int COMMAND_DF1 = 0x99; // DefineWindow 1 (+6 bytes) + private static final int COMMAND_DF2 = 0x9A; // DefineWindow 2 (+6 bytes) + private static final int COMMAND_DF3 = 0x9B; // DefineWindow 3 (+6 bytes) + private static final int COMMAND_DF4 = 0x9C; // DefineWindow 4 (+6 bytes) + private static final int COMMAND_DF5 = 0x9D; // DefineWindow 5 (+6 bytes) + private static final int COMMAND_DF6 = 0x9E; // DefineWindow 6 (+6 bytes) + private static final int COMMAND_DF7 = 0x9F; // DefineWindow 7 (+6 bytes) + + // G0 Table Special Chars + private static final int CHARACTER_MN = 0x7F; // MusicNote + + // G2 Table Special Chars + private static final int CHARACTER_TSP = 0x20; + private static final int CHARACTER_NBTSP = 0x21; + private static final int CHARACTER_ELLIPSIS = 0x25; + private static final int CHARACTER_BIG_CARONS = 0x2A; + private static final int CHARACTER_BIG_OE = 0x2C; + private static final int CHARACTER_SOLID_BLOCK = 0x30; + private static final int CHARACTER_OPEN_SINGLE_QUOTE = 0x31; + private static final int CHARACTER_CLOSE_SINGLE_QUOTE = 0x32; + private static final int CHARACTER_OPEN_DOUBLE_QUOTE = 0x33; + private static final int CHARACTER_CLOSE_DOUBLE_QUOTE = 0x34; + private static final int CHARACTER_BOLD_BULLET = 0x35; + private static final int CHARACTER_TM = 0x39; + private static final int CHARACTER_SMALL_CARONS = 0x3A; + private static final int CHARACTER_SMALL_OE = 0x3C; + private static final int CHARACTER_SM = 0x3D; + private static final int CHARACTER_DIAERESIS_Y = 0x3F; + private static final int CHARACTER_ONE_EIGHTH = 0x76; + private static final int CHARACTER_THREE_EIGHTHS = 0x77; + private static final int CHARACTER_FIVE_EIGHTHS = 0x78; + private static final int CHARACTER_SEVEN_EIGHTHS = 0x79; + private static final int CHARACTER_VERTICAL_BORDER = 0x7A; + private static final int CHARACTER_UPPER_RIGHT_BORDER = 0x7B; + private static final int CHARACTER_LOWER_LEFT_BORDER = 0x7C; + private static final int CHARACTER_HORIZONTAL_BORDER = 0x7D; + private static final int CHARACTER_LOWER_RIGHT_BORDER = 0x7E; + private static final int CHARACTER_UPPER_LEFT_BORDER = 0x7F; + + private final ParsableByteArray ccData; + private final ParsableBitArray serviceBlockPacket; + + private final int selectedServiceNumber; + private final CueBuilder[] cueBuilders; + + private CueBuilder currentCueBuilder; + private List<Cue> cues; + private List<Cue> lastCues; + + private DtvCcPacket currentDtvCcPacket; + private int currentWindow; + + // TODO: Retrieve isWideAspectRatio from initializationData and use it. + public Cea708Decoder(int accessibilityChannel, @Nullable List<byte[]> initializationData) { + ccData = new ParsableByteArray(); + serviceBlockPacket = new ParsableBitArray(); + selectedServiceNumber = accessibilityChannel == Format.NO_VALUE ? 1 : accessibilityChannel; + + cueBuilders = new CueBuilder[NUM_WINDOWS]; + for (int i = 0; i < NUM_WINDOWS; i++) { + cueBuilders[i] = new CueBuilder(); + } + + currentCueBuilder = cueBuilders[0]; + resetCueBuilders(); + } + + @Override + public String getName() { + return "Cea708Decoder"; + } + + @Override + public void flush() { + super.flush(); + cues = null; + lastCues = null; + currentWindow = 0; + currentCueBuilder = cueBuilders[currentWindow]; + resetCueBuilders(); + currentDtvCcPacket = null; + } + + @Override + protected boolean isNewSubtitleDataAvailable() { + return cues != lastCues; + } + + @Override + protected Subtitle createSubtitle() { + lastCues = cues; + return new CeaSubtitle(cues); + } + + @Override + protected void decode(SubtitleInputBuffer inputBuffer) { + // Subtitle input buffers are non-direct and the position is zero, so calling array() is safe. + @SuppressWarnings("ByteBufferBackingArray") + byte[] inputBufferData = inputBuffer.data.array(); + ccData.reset(inputBufferData, inputBuffer.data.limit()); + while (ccData.bytesLeft() >= 3) { + int ccTypeAndValid = (ccData.readUnsignedByte() & 0x07); + + int ccType = ccTypeAndValid & (DTVCC_PACKET_DATA | DTVCC_PACKET_START); + boolean ccValid = (ccTypeAndValid & CC_VALID_FLAG) == CC_VALID_FLAG; + byte ccData1 = (byte) ccData.readUnsignedByte(); + byte ccData2 = (byte) ccData.readUnsignedByte(); + + // Ignore any non-CEA-708 data + if (ccType != DTVCC_PACKET_DATA && ccType != DTVCC_PACKET_START) { + continue; + } + + if (!ccValid) { + // This byte-pair isn't valid, ignore it and continue. + continue; + } + + if (ccType == DTVCC_PACKET_START) { + finalizeCurrentPacket(); + + int sequenceNumber = (ccData1 & 0xC0) >> 6; // first 2 bits + int packetSize = ccData1 & 0x3F; // last 6 bits + if (packetSize == 0) { + packetSize = 64; + } + + currentDtvCcPacket = new DtvCcPacket(sequenceNumber, packetSize); + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2; + } else { + // The only remaining valid packet type is DTVCC_PACKET_DATA + Assertions.checkArgument(ccType == DTVCC_PACKET_DATA); + + if (currentDtvCcPacket == null) { + Log.e(TAG, "Encountered DTVCC_PACKET_DATA before DTVCC_PACKET_START"); + continue; + } + + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData1; + currentDtvCcPacket.packetData[currentDtvCcPacket.currentIndex++] = ccData2; + } + + if (currentDtvCcPacket.currentIndex == (currentDtvCcPacket.packetSize * 2 - 1)) { + finalizeCurrentPacket(); + } + } + } + + private void finalizeCurrentPacket() { + if (currentDtvCcPacket == null) { + // No packet to finalize; + return; + } + + processCurrentPacket(); + currentDtvCcPacket = null; + } + + private void processCurrentPacket() { + if (currentDtvCcPacket.currentIndex != (currentDtvCcPacket.packetSize * 2 - 1)) { + Log.w(TAG, "DtvCcPacket ended prematurely; size is " + (currentDtvCcPacket.packetSize * 2 - 1) + + ", but current index is " + currentDtvCcPacket.currentIndex + " (sequence number " + + currentDtvCcPacket.sequenceNumber + "); ignoring packet"); + return; + } + + serviceBlockPacket.reset(currentDtvCcPacket.packetData, currentDtvCcPacket.currentIndex); + + int serviceNumber = serviceBlockPacket.readBits(3); + int blockSize = serviceBlockPacket.readBits(5); + if (serviceNumber == 7) { + // extended service numbers + serviceBlockPacket.skipBits(2); + serviceNumber = serviceBlockPacket.readBits(6); + if (serviceNumber < 7) { + Log.w(TAG, "Invalid extended service number: " + serviceNumber); + } + } + + // Ignore packets in which blockSize is 0 + if (blockSize == 0) { + if (serviceNumber != 0) { + Log.w(TAG, "serviceNumber is non-zero (" + serviceNumber + ") when blockSize is 0"); + } + return; + } + + if (serviceNumber != selectedServiceNumber) { + return; + } + + // The cues should be updated if we receive a C0 ETX command, any C1 command, or if after + // processing the service block any text has been added to the buffer. See CEA-708-B Section + // 8.10.4 for more details. + boolean cuesNeedUpdate = false; + + while (serviceBlockPacket.bitsLeft() > 0) { + int command = serviceBlockPacket.readBits(8); + if (command != COMMAND_EXT1) { + if (command <= GROUP_C0_END) { + handleC0Command(command); + // If the C0 command was an ETX command, the cues are updated in handleC0Command. + } else if (command <= GROUP_G0_END) { + handleG0Character(command); + cuesNeedUpdate = true; + } else if (command <= GROUP_C1_END) { + handleC1Command(command); + cuesNeedUpdate = true; + } else if (command <= GROUP_G1_END) { + handleG1Character(command); + cuesNeedUpdate = true; + } else { + Log.w(TAG, "Invalid base command: " + command); + } + } else { + // Read the extended command + command = serviceBlockPacket.readBits(8); + if (command <= GROUP_C2_END) { + handleC2Command(command); + } else if (command <= GROUP_G2_END) { + handleG2Character(command); + cuesNeedUpdate = true; + } else if (command <= GROUP_C3_END) { + handleC3Command(command); + } else if (command <= GROUP_G3_END) { + handleG3Character(command); + cuesNeedUpdate = true; + } else { + Log.w(TAG, "Invalid extended command: " + command); + } + } + } + + if (cuesNeedUpdate) { + cues = getDisplayCues(); + } + } + + private void handleC0Command(int command) { + switch (command) { + case COMMAND_NUL: + // Do nothing. + break; + case COMMAND_ETX: + cues = getDisplayCues(); + break; + case COMMAND_BS: + currentCueBuilder.backspace(); + break; + case COMMAND_FF: + resetCueBuilders(); + break; + case COMMAND_CR: + currentCueBuilder.append('\n'); + break; + case COMMAND_HCR: + // TODO: Add support for this command. + break; + default: + if (command >= COMMAND_EXT1_START && command <= COMMAND_EXT1_END) { + Log.w(TAG, "Currently unsupported COMMAND_EXT1 Command: " + command); + serviceBlockPacket.skipBits(8); + } else if (command >= COMMAND_P16_START && command <= COMMAND_P16_END) { + Log.w(TAG, "Currently unsupported COMMAND_P16 Command: " + command); + serviceBlockPacket.skipBits(16); + } else { + Log.w(TAG, "Invalid C0 command: " + command); + } + } + } + + private void handleC1Command(int command) { + int window; + switch (command) { + case COMMAND_CW0: + case COMMAND_CW1: + case COMMAND_CW2: + case COMMAND_CW3: + case COMMAND_CW4: + case COMMAND_CW5: + case COMMAND_CW6: + case COMMAND_CW7: + window = (command - COMMAND_CW0); + if (currentWindow != window) { + currentWindow = window; + currentCueBuilder = cueBuilders[window]; + } + break; + case COMMAND_CLW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].clear(); + } + } + break; + case COMMAND_DSW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].setVisibility(true); + } + } + break; + case COMMAND_HDW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].setVisibility(false); + } + } + break; + case COMMAND_TGW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + CueBuilder cueBuilder = cueBuilders[NUM_WINDOWS - i]; + cueBuilder.setVisibility(!cueBuilder.isVisible()); + } + } + break; + case COMMAND_DLW: + for (int i = 1; i <= NUM_WINDOWS; i++) { + if (serviceBlockPacket.readBit()) { + cueBuilders[NUM_WINDOWS - i].reset(); + } + } + break; + case COMMAND_DLY: + // TODO: Add support for delay commands. + serviceBlockPacket.skipBits(8); + break; + case COMMAND_DLC: + // TODO: Add support for delay commands. + break; + case COMMAND_RST: + resetCueBuilders(); + break; + case COMMAND_SPA: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(16); + } else { + handleSetPenAttributes(); + } + break; + case COMMAND_SPC: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(24); + } else { + handleSetPenColor(); + } + break; + case COMMAND_SPL: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(16); + } else { + handleSetPenLocation(); + } + break; + case COMMAND_SWA: + if (!currentCueBuilder.isDefined()) { + // ignore this command if the current window/cue isn't defined + serviceBlockPacket.skipBits(32); + } else { + handleSetWindowAttributes(); + } + break; + case COMMAND_DF0: + case COMMAND_DF1: + case COMMAND_DF2: + case COMMAND_DF3: + case COMMAND_DF4: + case COMMAND_DF5: + case COMMAND_DF6: + case COMMAND_DF7: + window = (command - COMMAND_DF0); + handleDefineWindow(window); + // We also set the current window to the newly defined window. + if (currentWindow != window) { + currentWindow = window; + currentCueBuilder = cueBuilders[window]; + } + break; + default: + Log.w(TAG, "Invalid C1 command: " + command); + } + } + + private void handleC2Command(int command) { + // C2 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes + if (command <= 0x07) { + // Do nothing. + } else if (command <= 0x0F) { + serviceBlockPacket.skipBits(8); + } else if (command <= 0x17) { + serviceBlockPacket.skipBits(16); + } else if (command <= 0x1F) { + serviceBlockPacket.skipBits(24); + } + } + + private void handleC3Command(int command) { + // C3 Table doesn't contain any commands in CEA-708-B, but we do need to skip bytes + if (command <= 0x87) { + serviceBlockPacket.skipBits(32); + } else if (command <= 0x8F) { + serviceBlockPacket.skipBits(40); + } else if (command <= 0x9F) { + // 90-9F are variable length codes; the first byte defines the header with the first + // 2 bits specifying the type and the last 6 bits specifying the remaining length of the + // command in bytes + serviceBlockPacket.skipBits(2); + int length = serviceBlockPacket.readBits(6); + serviceBlockPacket.skipBits(8 * length); + } + } + + private void handleG0Character(int characterCode) { + if (characterCode == CHARACTER_MN) { + currentCueBuilder.append('\u266B'); + } else { + currentCueBuilder.append((char) (characterCode & 0xFF)); + } + } + + private void handleG1Character(int characterCode) { + currentCueBuilder.append((char) (characterCode & 0xFF)); + } + + private void handleG2Character(int characterCode) { + switch (characterCode) { + case CHARACTER_TSP: + currentCueBuilder.append('\u0020'); + break; + case CHARACTER_NBTSP: + currentCueBuilder.append('\u00A0'); + break; + case CHARACTER_ELLIPSIS: + currentCueBuilder.append('\u2026'); + break; + case CHARACTER_BIG_CARONS: + currentCueBuilder.append('\u0160'); + break; + case CHARACTER_BIG_OE: + currentCueBuilder.append('\u0152'); + break; + case CHARACTER_SOLID_BLOCK: + currentCueBuilder.append('\u2588'); + break; + case CHARACTER_OPEN_SINGLE_QUOTE: + currentCueBuilder.append('\u2018'); + break; + case CHARACTER_CLOSE_SINGLE_QUOTE: + currentCueBuilder.append('\u2019'); + break; + case CHARACTER_OPEN_DOUBLE_QUOTE: + currentCueBuilder.append('\u201C'); + break; + case CHARACTER_CLOSE_DOUBLE_QUOTE: + currentCueBuilder.append('\u201D'); + break; + case CHARACTER_BOLD_BULLET: + currentCueBuilder.append('\u2022'); + break; + case CHARACTER_TM: + currentCueBuilder.append('\u2122'); + break; + case CHARACTER_SMALL_CARONS: + currentCueBuilder.append('\u0161'); + break; + case CHARACTER_SMALL_OE: + currentCueBuilder.append('\u0153'); + break; + case CHARACTER_SM: + currentCueBuilder.append('\u2120'); + break; + case CHARACTER_DIAERESIS_Y: + currentCueBuilder.append('\u0178'); + break; + case CHARACTER_ONE_EIGHTH: + currentCueBuilder.append('\u215B'); + break; + case CHARACTER_THREE_EIGHTHS: + currentCueBuilder.append('\u215C'); + break; + case CHARACTER_FIVE_EIGHTHS: + currentCueBuilder.append('\u215D'); + break; + case CHARACTER_SEVEN_EIGHTHS: + currentCueBuilder.append('\u215E'); + break; + case CHARACTER_VERTICAL_BORDER: + currentCueBuilder.append('\u2502'); + break; + case CHARACTER_UPPER_RIGHT_BORDER: + currentCueBuilder.append('\u2510'); + break; + case CHARACTER_LOWER_LEFT_BORDER: + currentCueBuilder.append('\u2514'); + break; + case CHARACTER_HORIZONTAL_BORDER: + currentCueBuilder.append('\u2500'); + break; + case CHARACTER_LOWER_RIGHT_BORDER: + currentCueBuilder.append('\u2518'); + break; + case CHARACTER_UPPER_LEFT_BORDER: + currentCueBuilder.append('\u250C'); + break; + default: + Log.w(TAG, "Invalid G2 character: " + characterCode); + // The CEA-708 specification doesn't specify what to do in the case of an unexpected + // value in the G2 character range, so we ignore it. + } + } + + private void handleG3Character(int characterCode) { + if (characterCode == 0xA0) { + currentCueBuilder.append('\u33C4'); + } else { + Log.w(TAG, "Invalid G3 character: " + characterCode); + // Substitute any unsupported G3 character with an underscore as per CEA-708 specification. + currentCueBuilder.append('_'); + } + } + + private void handleSetPenAttributes() { + // the SetPenAttributes command contains 2 bytes of data + // first byte + int textTag = serviceBlockPacket.readBits(4); + int offset = serviceBlockPacket.readBits(2); + int penSize = serviceBlockPacket.readBits(2); + // second byte + boolean italicsToggle = serviceBlockPacket.readBit(); + boolean underlineToggle = serviceBlockPacket.readBit(); + int edgeType = serviceBlockPacket.readBits(3); + int fontStyle = serviceBlockPacket.readBits(3); + + currentCueBuilder.setPenAttributes(textTag, offset, penSize, italicsToggle, underlineToggle, + edgeType, fontStyle); + } + + private void handleSetPenColor() { + // the SetPenColor command contains 3 bytes of data + // first byte + int foregroundO = serviceBlockPacket.readBits(2); + int foregroundR = serviceBlockPacket.readBits(2); + int foregroundG = serviceBlockPacket.readBits(2); + int foregroundB = serviceBlockPacket.readBits(2); + int foregroundColor = CueBuilder.getArgbColorFromCeaColor(foregroundR, foregroundG, foregroundB, + foregroundO); + // second byte + int backgroundO = serviceBlockPacket.readBits(2); + int backgroundR = serviceBlockPacket.readBits(2); + int backgroundG = serviceBlockPacket.readBits(2); + int backgroundB = serviceBlockPacket.readBits(2); + int backgroundColor = CueBuilder.getArgbColorFromCeaColor(backgroundR, backgroundG, backgroundB, + backgroundO); + // third byte + serviceBlockPacket.skipBits(2); // null padding + int edgeR = serviceBlockPacket.readBits(2); + int edgeG = serviceBlockPacket.readBits(2); + int edgeB = serviceBlockPacket.readBits(2); + int edgeColor = CueBuilder.getArgbColorFromCeaColor(edgeR, edgeG, edgeB); + + currentCueBuilder.setPenColor(foregroundColor, backgroundColor, edgeColor); + } + + private void handleSetPenLocation() { + // the SetPenLocation command contains 2 bytes of data + // first byte + serviceBlockPacket.skipBits(4); + int row = serviceBlockPacket.readBits(4); + // second byte + serviceBlockPacket.skipBits(2); + int column = serviceBlockPacket.readBits(6); + + currentCueBuilder.setPenLocation(row, column); + } + + private void handleSetWindowAttributes() { + // the SetWindowAttributes command contains 4 bytes of data + // first byte + int fillO = serviceBlockPacket.readBits(2); + int fillR = serviceBlockPacket.readBits(2); + int fillG = serviceBlockPacket.readBits(2); + int fillB = serviceBlockPacket.readBits(2); + int fillColor = CueBuilder.getArgbColorFromCeaColor(fillR, fillG, fillB, fillO); + // second byte + int borderType = serviceBlockPacket.readBits(2); // only the lower 2 bits of borderType + int borderR = serviceBlockPacket.readBits(2); + int borderG = serviceBlockPacket.readBits(2); + int borderB = serviceBlockPacket.readBits(2); + int borderColor = CueBuilder.getArgbColorFromCeaColor(borderR, borderG, borderB); + // third byte + if (serviceBlockPacket.readBit()) { + borderType |= 0x04; // set the top bit of the 3-bit borderType + } + boolean wordWrapToggle = serviceBlockPacket.readBit(); + int printDirection = serviceBlockPacket.readBits(2); + int scrollDirection = serviceBlockPacket.readBits(2); + int justification = serviceBlockPacket.readBits(2); + // fourth byte + // Note that we don't intend to support display effects + serviceBlockPacket.skipBits(8); // effectSpeed(4), effectDirection(2), displayEffect(2) + + currentCueBuilder.setWindowAttributes(fillColor, borderColor, wordWrapToggle, borderType, + printDirection, scrollDirection, justification); + } + + private void handleDefineWindow(int window) { + CueBuilder cueBuilder = cueBuilders[window]; + + // the DefineWindow command contains 6 bytes of data + // first byte + serviceBlockPacket.skipBits(2); // null padding + boolean visible = serviceBlockPacket.readBit(); + boolean rowLock = serviceBlockPacket.readBit(); + boolean columnLock = serviceBlockPacket.readBit(); + int priority = serviceBlockPacket.readBits(3); + // second byte + boolean relativePositioning = serviceBlockPacket.readBit(); + int verticalAnchor = serviceBlockPacket.readBits(7); + // third byte + int horizontalAnchor = serviceBlockPacket.readBits(8); + // fourth byte + int anchorId = serviceBlockPacket.readBits(4); + int rowCount = serviceBlockPacket.readBits(4); + // fifth byte + serviceBlockPacket.skipBits(2); // null padding + int columnCount = serviceBlockPacket.readBits(6); + // sixth byte + serviceBlockPacket.skipBits(2); // null padding + int windowStyle = serviceBlockPacket.readBits(3); + int penStyle = serviceBlockPacket.readBits(3); + + cueBuilder.defineWindow(visible, rowLock, columnLock, priority, relativePositioning, + verticalAnchor, horizontalAnchor, rowCount, columnCount, anchorId, windowStyle, penStyle); + } + + private List<Cue> getDisplayCues() { + List<Cea708Cue> displayCues = new ArrayList<>(); + for (int i = 0; i < NUM_WINDOWS; i++) { + if (!cueBuilders[i].isEmpty() && cueBuilders[i].isVisible()) { + displayCues.add(cueBuilders[i].build()); + } + } + Collections.sort(displayCues); + return Collections.unmodifiableList(displayCues); + } + + private void resetCueBuilders() { + for (int i = 0; i < NUM_WINDOWS; i++) { + cueBuilders[i].reset(); + } + } + + private static final class DtvCcPacket { + + public final int sequenceNumber; + public final int packetSize; + public final byte[] packetData; + + int currentIndex; + + public DtvCcPacket(int sequenceNumber, int packetSize) { + this.sequenceNumber = sequenceNumber; + this.packetSize = packetSize; + packetData = new byte[2 * packetSize - 1]; + currentIndex = 0; + } + + } + + // TODO: There is a lot of overlap between Cea708Decoder.CueBuilder and Cea608Decoder.CueBuilder + // which could be refactored into a separate class. + private static final class CueBuilder { + + private static final int RELATIVE_CUE_SIZE = 99; + private static final int VERTICAL_SIZE = 74; + private static final int HORIZONTAL_SIZE = 209; + + private static final int DEFAULT_PRIORITY = 4; + + private static final int MAXIMUM_ROW_COUNT = 15; + + private static final int JUSTIFICATION_LEFT = 0; + private static final int JUSTIFICATION_RIGHT = 1; + private static final int JUSTIFICATION_CENTER = 2; + private static final int JUSTIFICATION_FULL = 3; + + private static final int DIRECTION_LEFT_TO_RIGHT = 0; + private static final int DIRECTION_RIGHT_TO_LEFT = 1; + private static final int DIRECTION_TOP_TO_BOTTOM = 2; + private static final int DIRECTION_BOTTOM_TO_TOP = 3; + + // TODO: Add other border/edge types when utilized. + private static final int BORDER_AND_EDGE_TYPE_NONE = 0; + private static final int BORDER_AND_EDGE_TYPE_UNIFORM = 3; + + public static final int COLOR_SOLID_WHITE = getArgbColorFromCeaColor(2, 2, 2, 0); + public static final int COLOR_SOLID_BLACK = getArgbColorFromCeaColor(0, 0, 0, 0); + public static final int COLOR_TRANSPARENT = getArgbColorFromCeaColor(0, 0, 0, 3); + + // TODO: Add other sizes when utilized. + private static final int PEN_SIZE_STANDARD = 1; + + // TODO: Add other pen font styles when utilized. + private static final int PEN_FONT_STYLE_DEFAULT = 0; + private static final int PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS = 1; + private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS = 2; + private static final int PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS = 3; + private static final int PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS = 4; + + // TODO: Add other pen offsets when utilized. + private static final int PEN_OFFSET_NORMAL = 1; + + // The window style properties are specified in the CEA-708 specification. + private static final int[] WINDOW_STYLE_JUSTIFICATION = new int[] { + JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, + JUSTIFICATION_LEFT, JUSTIFICATION_LEFT, JUSTIFICATION_CENTER, + JUSTIFICATION_LEFT + }; + private static final int[] WINDOW_STYLE_PRINT_DIRECTION = new int[] { + DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, + DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, DIRECTION_LEFT_TO_RIGHT, + DIRECTION_TOP_TO_BOTTOM + }; + private static final int[] WINDOW_STYLE_SCROLL_DIRECTION = new int[] { + DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, + DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, DIRECTION_BOTTOM_TO_TOP, + DIRECTION_RIGHT_TO_LEFT + }; + private static final boolean[] WINDOW_STYLE_WORD_WRAP = new boolean[] { + false, false, false, true, true, true, false + }; + private static final int[] WINDOW_STYLE_FILL = new int[] { + COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, + COLOR_TRANSPARENT, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK + }; + + // The pen style properties are specified in the CEA-708 specification. + private static final int[] PEN_STYLE_FONT_STYLE = new int[] { + PEN_FONT_STYLE_DEFAULT, PEN_FONT_STYLE_MONOSPACED_WITH_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITH_SERIFS, PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_MONOSPACED_WITHOUT_SERIFS, + PEN_FONT_STYLE_PROPORTIONALLY_SPACED_WITHOUT_SERIFS + }; + private static final int[] PEN_STYLE_EDGE_TYPE = new int[] { + BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, + BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_NONE, BORDER_AND_EDGE_TYPE_UNIFORM, + BORDER_AND_EDGE_TYPE_UNIFORM + }; + private static final int[] PEN_STYLE_BACKGROUND = new int[] { + COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, COLOR_SOLID_BLACK, + COLOR_SOLID_BLACK, COLOR_TRANSPARENT, COLOR_TRANSPARENT}; + + private final List<SpannableString> rolledUpCaptions; + private final SpannableStringBuilder captionStringBuilder; + + // Window/Cue properties + private boolean defined; + private boolean visible; + private int priority; + private boolean relativePositioning; + private int verticalAnchor; + private int horizontalAnchor; + private int anchorId; + private int rowCount; + private boolean rowLock; + private int justification; + private int windowStyleId; + private int penStyleId; + private int windowFillColor; + + // Pen/Text properties + private int italicsStartPosition; + private int underlineStartPosition; + private int foregroundColorStartPosition; + private int foregroundColor; + private int backgroundColorStartPosition; + private int backgroundColor; + private int row; + + public CueBuilder() { + rolledUpCaptions = new ArrayList<>(); + captionStringBuilder = new SpannableStringBuilder(); + reset(); + } + + public boolean isEmpty() { + return !isDefined() || (rolledUpCaptions.isEmpty() && captionStringBuilder.length() == 0); + } + + public void reset() { + clear(); + + defined = false; + visible = false; + priority = DEFAULT_PRIORITY; + relativePositioning = false; + verticalAnchor = 0; + horizontalAnchor = 0; + anchorId = 0; + rowCount = MAXIMUM_ROW_COUNT; + rowLock = true; + justification = JUSTIFICATION_LEFT; + windowStyleId = 0; + penStyleId = 0; + windowFillColor = COLOR_SOLID_BLACK; + + foregroundColor = COLOR_SOLID_WHITE; + backgroundColor = COLOR_SOLID_BLACK; + } + + public void clear() { + rolledUpCaptions.clear(); + captionStringBuilder.clear(); + italicsStartPosition = C.POSITION_UNSET; + underlineStartPosition = C.POSITION_UNSET; + foregroundColorStartPosition = C.POSITION_UNSET; + backgroundColorStartPosition = C.POSITION_UNSET; + row = 0; + } + + public boolean isDefined() { + return defined; + } + + public void setVisibility(boolean visible) { + this.visible = visible; + } + + public boolean isVisible() { + return visible; + } + + public void defineWindow(boolean visible, boolean rowLock, boolean columnLock, int priority, + boolean relativePositioning, int verticalAnchor, int horizontalAnchor, int rowCount, + int columnCount, int anchorId, int windowStyleId, int penStyleId) { + this.defined = true; + this.visible = visible; + this.rowLock = rowLock; + this.priority = priority; + this.relativePositioning = relativePositioning; + this.verticalAnchor = verticalAnchor; + this.horizontalAnchor = horizontalAnchor; + this.anchorId = anchorId; + + // Decoders must add one to rowCount to get the desired number of rows. + if (this.rowCount != rowCount + 1) { + this.rowCount = rowCount + 1; + + // Trim any rolled up captions that are no longer valid, if applicable. + while ((rowLock && (rolledUpCaptions.size() >= this.rowCount)) + || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) { + rolledUpCaptions.remove(0); + } + } + + // TODO: Add support for column lock and count. + + if (windowStyleId != 0 && this.windowStyleId != windowStyleId) { + this.windowStyleId = windowStyleId; + // windowStyleId is 1-based. + int windowStyleIdIndex = windowStyleId - 1; + // Note that Border type and border color are the same for all window styles. + setWindowAttributes(WINDOW_STYLE_FILL[windowStyleIdIndex], COLOR_TRANSPARENT, + WINDOW_STYLE_WORD_WRAP[windowStyleIdIndex], BORDER_AND_EDGE_TYPE_NONE, + WINDOW_STYLE_PRINT_DIRECTION[windowStyleIdIndex], + WINDOW_STYLE_SCROLL_DIRECTION[windowStyleIdIndex], + WINDOW_STYLE_JUSTIFICATION[windowStyleIdIndex]); + } + + if (penStyleId != 0 && this.penStyleId != penStyleId) { + this.penStyleId = penStyleId; + // penStyleId is 1-based. + int penStyleIdIndex = penStyleId - 1; + // Note that pen size, offset, italics, underline, foreground color, and foreground + // opacity are the same for all pen styles. + setPenAttributes(0, PEN_OFFSET_NORMAL, PEN_SIZE_STANDARD, false, false, + PEN_STYLE_EDGE_TYPE[penStyleIdIndex], PEN_STYLE_FONT_STYLE[penStyleIdIndex]); + setPenColor(COLOR_SOLID_WHITE, PEN_STYLE_BACKGROUND[penStyleIdIndex], COLOR_SOLID_BLACK); + } + } + + + public void setWindowAttributes(int fillColor, int borderColor, boolean wordWrapToggle, + int borderType, int printDirection, int scrollDirection, int justification) { + this.windowFillColor = fillColor; + // TODO: Add support for border color and types. + // TODO: Add support for word wrap. + // TODO: Add support for other scroll directions. + // TODO: Add support for other print directions. + this.justification = justification; + + } + + public void setPenAttributes(int textTag, int offset, int penSize, boolean italicsToggle, + boolean underlineToggle, int edgeType, int fontStyle) { + // TODO: Add support for text tags. + // TODO: Add support for other offsets. + // TODO: Add support for other pen sizes. + + if (italicsStartPosition != C.POSITION_UNSET) { + if (!italicsToggle) { + captionStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + italicsStartPosition = C.POSITION_UNSET; + } + } else if (italicsToggle) { + italicsStartPosition = captionStringBuilder.length(); + } + + if (underlineStartPosition != C.POSITION_UNSET) { + if (!underlineToggle) { + captionStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + captionStringBuilder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + underlineStartPosition = C.POSITION_UNSET; + } + } else if (underlineToggle) { + underlineStartPosition = captionStringBuilder.length(); + } + + // TODO: Add support for edge types. + // TODO: Add support for other font styles. + } + + public void setPenColor(int foregroundColor, int backgroundColor, int edgeColor) { + if (foregroundColorStartPosition != C.POSITION_UNSET) { + if (this.foregroundColor != foregroundColor) { + captionStringBuilder.setSpan(new ForegroundColorSpan(this.foregroundColor), + foregroundColorStartPosition, captionStringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (foregroundColor != COLOR_SOLID_WHITE) { + foregroundColorStartPosition = captionStringBuilder.length(); + this.foregroundColor = foregroundColor; + } + + if (backgroundColorStartPosition != C.POSITION_UNSET) { + if (this.backgroundColor != backgroundColor) { + captionStringBuilder.setSpan(new BackgroundColorSpan(this.backgroundColor), + backgroundColorStartPosition, captionStringBuilder.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + if (backgroundColor != COLOR_SOLID_BLACK) { + backgroundColorStartPosition = captionStringBuilder.length(); + this.backgroundColor = backgroundColor; + } + + // TODO: Add support for edge color. + } + + public void setPenLocation(int row, int column) { + // TODO: Support moving the pen location with a window properly. + + // Until we support proper pen locations, if we encounter a row that's different from the + // previous one, we should append a new line. Otherwise, we'll see strings that should be + // on new lines concatenated with the previous, resulting in 2 words being combined, as + // well as potentially drawing beyond the width of the window/screen. + if (this.row != row) { + append('\n'); + } + this.row = row; + } + + public void backspace() { + int length = captionStringBuilder.length(); + if (length > 0) { + captionStringBuilder.delete(length - 1, length); + } + } + + public void append(char text) { + if (text == '\n') { + rolledUpCaptions.add(buildSpannableString()); + captionStringBuilder.clear(); + + if (italicsStartPosition != C.POSITION_UNSET) { + italicsStartPosition = 0; + } + if (underlineStartPosition != C.POSITION_UNSET) { + underlineStartPosition = 0; + } + if (foregroundColorStartPosition != C.POSITION_UNSET) { + foregroundColorStartPosition = 0; + } + if (backgroundColorStartPosition != C.POSITION_UNSET) { + backgroundColorStartPosition = 0; + } + + while ((rowLock && (rolledUpCaptions.size() >= rowCount)) + || (rolledUpCaptions.size() >= MAXIMUM_ROW_COUNT)) { + rolledUpCaptions.remove(0); + } + } else { + captionStringBuilder.append(text); + } + } + + public SpannableString buildSpannableString() { + SpannableStringBuilder spannableStringBuilder = + new SpannableStringBuilder(captionStringBuilder); + int length = spannableStringBuilder.length(); + + if (length > 0) { + if (italicsStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new StyleSpan(Typeface.ITALIC), italicsStartPosition, + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (underlineStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new UnderlineSpan(), underlineStartPosition, + length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (foregroundColorStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new ForegroundColorSpan(foregroundColor), + foregroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + if (backgroundColorStartPosition != C.POSITION_UNSET) { + spannableStringBuilder.setSpan(new BackgroundColorSpan(backgroundColor), + backgroundColorStartPosition, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + return new SpannableString(spannableStringBuilder); + } + + public Cea708Cue build() { + if (isEmpty()) { + // The cue is empty. + return null; + } + + SpannableStringBuilder cueString = new SpannableStringBuilder(); + + // Add any rolled up captions, separated by new lines. + for (int i = 0; i < rolledUpCaptions.size(); i++) { + cueString.append(rolledUpCaptions.get(i)); + cueString.append('\n'); + } + // Add the current line. + cueString.append(buildSpannableString()); + + // TODO: Add support for right-to-left languages (i.e. where right would correspond to normal + // alignment). + Alignment alignment; + switch (justification) { + case JUSTIFICATION_FULL: + // TODO: Add support for full justification. + case JUSTIFICATION_LEFT: + alignment = Alignment.ALIGN_NORMAL; + break; + case JUSTIFICATION_RIGHT: + alignment = Alignment.ALIGN_OPPOSITE; + break; + case JUSTIFICATION_CENTER: + alignment = Alignment.ALIGN_CENTER; + break; + default: + throw new IllegalArgumentException("Unexpected justification value: " + justification); + } + + float position; + float line; + if (relativePositioning) { + position = (float) horizontalAnchor / RELATIVE_CUE_SIZE; + line = (float) verticalAnchor / RELATIVE_CUE_SIZE; + } else { + position = (float) horizontalAnchor / HORIZONTAL_SIZE; + line = (float) verticalAnchor / VERTICAL_SIZE; + } + // Apply screen-edge padding to the line and position. + position = (position * 0.9f) + 0.05f; + line = (line * 0.9f) + 0.05f; + + // anchorId specifies where the anchor should be placed on the caption cue/window. The 9 + // possible configurations are as follows: + // 0-----1-----2 + // | | + // 3 4 5 + // | | + // 6-----7-----8 + @AnchorType int verticalAnchorType; + if (anchorId % 3 == 0) { + verticalAnchorType = Cue.ANCHOR_TYPE_START; + } else if (anchorId % 3 == 1) { + verticalAnchorType = Cue.ANCHOR_TYPE_MIDDLE; + } else { + verticalAnchorType = Cue.ANCHOR_TYPE_END; + } + // TODO: Add support for right-to-left languages (i.e. where start is on the right). + @AnchorType int horizontalAnchorType; + if (anchorId / 3 == 0) { + horizontalAnchorType = Cue.ANCHOR_TYPE_START; + } else if (anchorId / 3 == 1) { + horizontalAnchorType = Cue.ANCHOR_TYPE_MIDDLE; + } else { + horizontalAnchorType = Cue.ANCHOR_TYPE_END; + } + + boolean windowColorSet = (windowFillColor != COLOR_SOLID_BLACK); + + return new Cea708Cue(cueString, alignment, line, Cue.LINE_TYPE_FRACTION, verticalAnchorType, + position, horizontalAnchorType, Cue.DIMEN_UNSET, windowColorSet, windowFillColor, + priority); + } + + public static int getArgbColorFromCeaColor(int red, int green, int blue) { + return getArgbColorFromCeaColor(red, green, blue, 0); + } + + public static int getArgbColorFromCeaColor(int red, int green, int blue, int opacity) { + Assertions.checkIndex(red, 0, 4); + Assertions.checkIndex(green, 0, 4); + Assertions.checkIndex(blue, 0, 4); + Assertions.checkIndex(opacity, 0, 4); + + int alpha; + switch (opacity) { + case 0: + case 1: + // Note the value of '1' is actually FLASH, but we don't support that. + alpha = 255; + break; + case 2: + alpha = 127; + break; + case 3: + alpha = 0; + break; + default: + alpha = 255; + } + + // TODO: Add support for the Alternative Minimum Color List or the full 64 RGB combinations. + + // Return values based on the Minimum Color List + return Color.argb(alpha, + (red > 1 ? 255 : 0), + (green > 1 ? 255 : 0), + (blue > 1 ? 255 : 0)); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java new file mode 100644 index 0000000000..5d63ca8e82 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/Cea708InitializationData.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import java.util.Collections; +import java.util.List; + +/** Initialization data for CEA-708 decoders. */ +public final class Cea708InitializationData { + + /** + * Whether the closed caption service is formatted for displays with 16:9 aspect ratio. If false, + * the closed caption service is formatted for 4:3 displays. + */ + public final boolean isWideAspectRatio; + + private Cea708InitializationData(List<byte[]> initializationData) { + isWideAspectRatio = initializationData.get(0)[0] != 0; + } + + /** + * Returns an object representation of CEA-708 initialization data + * + * @param initializationData Binary CEA-708 initialization data. + * @return The object representation. + */ + public static Cea708InitializationData fromData(List<byte[]> initializationData) { + return new Cea708InitializationData(initializationData); + } + + /** + * Builds binary CEA-708 initialization data. + * + * @param isWideAspectRatio Whether the closed caption service is formatted for displays with 16:9 + * aspect ratio. + * @return Binary CEA-708 initializaton data. + */ + public static List<byte[]> buildData(boolean isWideAspectRatio) { + return Collections.singletonList(new byte[] {(byte) (isWideAspectRatio ? 1 : 0)}); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java new file mode 100644 index 0000000000..42fa915fc5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaDecoder.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import androidx.annotation.NonNull; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleOutputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayDeque; +import java.util.PriorityQueue; + +/** + * Base class for subtitle parsers for CEA captions. + */ +/* package */ abstract class CeaDecoder implements SubtitleDecoder { + + private static final int NUM_INPUT_BUFFERS = 10; + private static final int NUM_OUTPUT_BUFFERS = 2; + + private final ArrayDeque<CeaInputBuffer> availableInputBuffers; + private final ArrayDeque<SubtitleOutputBuffer> availableOutputBuffers; + private final PriorityQueue<CeaInputBuffer> queuedInputBuffers; + + private CeaInputBuffer dequeuedInputBuffer; + private long playbackPositionUs; + private long queuedInputBufferCount; + + public CeaDecoder() { + availableInputBuffers = new ArrayDeque<>(); + for (int i = 0; i < NUM_INPUT_BUFFERS; i++) { + availableInputBuffers.add(new CeaInputBuffer()); + } + availableOutputBuffers = new ArrayDeque<>(); + for (int i = 0; i < NUM_OUTPUT_BUFFERS; i++) { + availableOutputBuffers.add(new CeaOutputBuffer()); + } + queuedInputBuffers = new PriorityQueue<>(); + } + + @Override + public abstract String getName(); + + @Override + public void setPositionUs(long positionUs) { + playbackPositionUs = positionUs; + } + + @Override + public SubtitleInputBuffer dequeueInputBuffer() throws SubtitleDecoderException { + Assertions.checkState(dequeuedInputBuffer == null); + if (availableInputBuffers.isEmpty()) { + return null; + } + dequeuedInputBuffer = availableInputBuffers.pollFirst(); + return dequeuedInputBuffer; + } + + @Override + public void queueInputBuffer(SubtitleInputBuffer inputBuffer) throws SubtitleDecoderException { + Assertions.checkArgument(inputBuffer == dequeuedInputBuffer); + if (inputBuffer.isDecodeOnly()) { + // We can drop this buffer early (i.e. before it would be decoded) as the CEA formats allow + // for decoding to begin mid-stream. + releaseInputBuffer(dequeuedInputBuffer); + } else { + dequeuedInputBuffer.queuedInputBufferCount = queuedInputBufferCount++; + queuedInputBuffers.add(dequeuedInputBuffer); + } + dequeuedInputBuffer = null; + } + + @Override + public SubtitleOutputBuffer dequeueOutputBuffer() throws SubtitleDecoderException { + if (availableOutputBuffers.isEmpty()) { + return null; + } + // iterate through all available input buffers whose timestamps are less than or equal + // to the current playback position; processing input buffers for future content should + // be deferred until they would be applicable + while (!queuedInputBuffers.isEmpty() + && queuedInputBuffers.peek().timeUs <= playbackPositionUs) { + CeaInputBuffer inputBuffer = queuedInputBuffers.poll(); + + // If the input buffer indicates we've reached the end of the stream, we can + // return immediately with an output buffer propagating that + if (inputBuffer.isEndOfStream()) { + SubtitleOutputBuffer outputBuffer = availableOutputBuffers.pollFirst(); + outputBuffer.addFlag(C.BUFFER_FLAG_END_OF_STREAM); + releaseInputBuffer(inputBuffer); + return outputBuffer; + } + + decode(inputBuffer); + + // check if we have any caption updates to report + if (isNewSubtitleDataAvailable()) { + // Even if the subtitle is decode-only; we need to generate it to consume the data so it + // isn't accidentally prepended to the next subtitle + Subtitle subtitle = createSubtitle(); + if (!inputBuffer.isDecodeOnly()) { + SubtitleOutputBuffer outputBuffer = availableOutputBuffers.pollFirst(); + outputBuffer.setContent(inputBuffer.timeUs, subtitle, Format.OFFSET_SAMPLE_RELATIVE); + releaseInputBuffer(inputBuffer); + return outputBuffer; + } + } + + releaseInputBuffer(inputBuffer); + } + + return null; + } + + private void releaseInputBuffer(CeaInputBuffer inputBuffer) { + inputBuffer.clear(); + availableInputBuffers.add(inputBuffer); + } + + protected void releaseOutputBuffer(SubtitleOutputBuffer outputBuffer) { + outputBuffer.clear(); + availableOutputBuffers.add(outputBuffer); + } + + @Override + public void flush() { + queuedInputBufferCount = 0; + playbackPositionUs = 0; + while (!queuedInputBuffers.isEmpty()) { + releaseInputBuffer(queuedInputBuffers.poll()); + } + if (dequeuedInputBuffer != null) { + releaseInputBuffer(dequeuedInputBuffer); + dequeuedInputBuffer = null; + } + } + + @Override + public void release() { + // Do nothing + } + + /** + * Returns whether there is data available to create a new {@link Subtitle}. + */ + protected abstract boolean isNewSubtitleDataAvailable(); + + /** + * Creates a {@link Subtitle} from the available data. + */ + protected abstract Subtitle createSubtitle(); + + /** + * Filters and processes the raw data, providing {@link Subtitle}s via {@link #createSubtitle()} + * when sufficient data has been processed. + */ + protected abstract void decode(SubtitleInputBuffer inputBuffer); + + private static final class CeaInputBuffer extends SubtitleInputBuffer + implements Comparable<CeaInputBuffer> { + + private long queuedInputBufferCount; + + @Override + public int compareTo(@NonNull CeaInputBuffer other) { + if (isEndOfStream() != other.isEndOfStream()) { + return isEndOfStream() ? 1 : -1; + } + long delta = timeUs - other.timeUs; + if (delta == 0) { + delta = queuedInputBufferCount - other.queuedInputBufferCount; + if (delta == 0) { + return 0; + } + } + return delta > 0 ? 1 : -1; + } + } + + private final class CeaOutputBuffer extends SubtitleOutputBuffer { + + @Override + public final void release() { + releaseOutputBuffer(this); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java new file mode 100644 index 0000000000..f4649c4c4b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaSubtitle.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Collections; +import java.util.List; + +/** + * A representation of a CEA subtitle. + */ +/* package */ final class CeaSubtitle implements Subtitle { + + private final List<Cue> cues; + + /** + * @param cues The subtitle cues. + */ + public CeaSubtitle(List<Cue> cues) { + this.cues = cues; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + return timeUs < 0 ? 0 : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return 1; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index == 0); + return 0; + } + + @Override + public List<Cue> getCues(long timeUs) { + return timeUs >= 0 ? cues : Collections.emptyList(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java new file mode 100644 index 0000000000..ced169ba17 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/CeaUtil.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.TrackOutput; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** Utility methods for handling CEA-608/708 messages. Defined in A/53 Part 4:2009. */ +public final class CeaUtil { + + private static final String TAG = "CeaUtil"; + + public static final int USER_DATA_IDENTIFIER_GA94 = 0x47413934; + public static final int USER_DATA_TYPE_CODE_MPEG_CC = 0x3; + + private static final int PAYLOAD_TYPE_CC = 4; + private static final int COUNTRY_CODE = 0xB5; + private static final int PROVIDER_CODE_ATSC = 0x31; + private static final int PROVIDER_CODE_DIRECTV = 0x2F; + + /** + * Consumes the unescaped content of an SEI NAL unit, writing the content of any CEA-608 messages + * as samples to all of the provided outputs. + * + * @param presentationTimeUs The presentation time in microseconds for any samples. + * @param seiBuffer The unescaped SEI NAL unit data, excluding the NAL unit start code and type. + * @param outputs The outputs to which any samples should be written. + */ + public static void consume(long presentationTimeUs, ParsableByteArray seiBuffer, + TrackOutput[] outputs) { + while (seiBuffer.bytesLeft() > 1 /* last byte will be rbsp_trailing_bits */) { + int payloadType = readNon255TerminatedValue(seiBuffer); + int payloadSize = readNon255TerminatedValue(seiBuffer); + int nextPayloadPosition = seiBuffer.getPosition() + payloadSize; + // Process the payload. + if (payloadSize == -1 || payloadSize > seiBuffer.bytesLeft()) { + // This might occur if we're trying to read an encrypted SEI NAL unit. + Log.w(TAG, "Skipping remainder of malformed SEI NAL unit."); + nextPayloadPosition = seiBuffer.limit(); + } else if (payloadType == PAYLOAD_TYPE_CC && payloadSize >= 8) { + int countryCode = seiBuffer.readUnsignedByte(); + int providerCode = seiBuffer.readUnsignedShort(); + int userIdentifier = 0; + if (providerCode == PROVIDER_CODE_ATSC) { + userIdentifier = seiBuffer.readInt(); + } + int userDataTypeCode = seiBuffer.readUnsignedByte(); + if (providerCode == PROVIDER_CODE_DIRECTV) { + seiBuffer.skipBytes(1); // user_data_length. + } + boolean messageIsSupportedCeaCaption = + countryCode == COUNTRY_CODE + && (providerCode == PROVIDER_CODE_ATSC || providerCode == PROVIDER_CODE_DIRECTV) + && userDataTypeCode == USER_DATA_TYPE_CODE_MPEG_CC; + if (providerCode == PROVIDER_CODE_ATSC) { + messageIsSupportedCeaCaption &= userIdentifier == USER_DATA_IDENTIFIER_GA94; + } + if (messageIsSupportedCeaCaption) { + consumeCcData(presentationTimeUs, seiBuffer, outputs); + } + } + seiBuffer.setPosition(nextPayloadPosition); + } + } + + /** + * Consumes caption data (cc_data), writing the content as samples to all of the provided outputs. + * + * @param presentationTimeUs The presentation time in microseconds for any samples. + * @param ccDataBuffer The buffer containing the caption data. + * @param outputs The outputs to which any samples should be written. + */ + public static void consumeCcData( + long presentationTimeUs, ParsableByteArray ccDataBuffer, TrackOutput[] outputs) { + // First byte contains: reserved (1), process_cc_data_flag (1), zero_bit (1), cc_count (5). + int firstByte = ccDataBuffer.readUnsignedByte(); + boolean processCcDataFlag = (firstByte & 0x40) != 0; + if (!processCcDataFlag) { + // No need to process. + return; + } + int ccCount = firstByte & 0x1F; + ccDataBuffer.skipBytes(1); // Ignore em_data + // Each data packet consists of 24 bits: marker bits (5) + cc_valid (1) + cc_type (2) + // + cc_data_1 (8) + cc_data_2 (8). + int sampleLength = ccCount * 3; + int sampleStartPosition = ccDataBuffer.getPosition(); + for (TrackOutput output : outputs) { + ccDataBuffer.setPosition(sampleStartPosition); + output.sampleData(ccDataBuffer, sampleLength); + output.sampleMetadata( + presentationTimeUs, + C.BUFFER_FLAG_KEY_FRAME, + sampleLength, + /* offset= */ 0, + /* encryptionData= */ null); + } + } + + /** + * Reads a value from the provided buffer consisting of zero or more 0xFF bytes followed by a + * terminating byte not equal to 0xFF. The returned value is ((0xFF * N) + T), where N is the + * number of 0xFF bytes and T is the value of the terminating byte. + * + * @param buffer The buffer from which to read the value. + * @return The read value, or -1 if the end of the buffer is reached before a value is read. + */ + private static int readNon255TerminatedValue(ParsableByteArray buffer) { + int b; + int value = 0; + do { + if (buffer.bytesLeft() == 0) { + return -1; + } + b = buffer.readUnsignedByte(); + value += b; + } while (b == 0xFF); + return value; + } + + private CeaUtil() {} + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java new file mode 100644 index 0000000000..e80d06586a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/cea/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.cea; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java new file mode 100644 index 0000000000..063872ae2e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbDecoder.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.List; + +/** A {@link SimpleSubtitleDecoder} for DVB subtitles. */ +public final class DvbDecoder extends SimpleSubtitleDecoder { + + private final DvbParser parser; + + /** + * @param initializationData The initialization data for the decoder. The initialization data + * must consist of a single byte array containing 5 bytes: flag_pes_stripped (1), + * composition_page (2), ancillary_page (2). + */ + public DvbDecoder(List<byte[]> initializationData) { + super("DvbDecoder"); + ParsableByteArray data = new ParsableByteArray(initializationData.get(0)); + int subtitleCompositionPage = data.readUnsignedShort(); + int subtitleAncillaryPage = data.readUnsignedShort(); + parser = new DvbParser(subtitleCompositionPage, subtitleAncillaryPage); + } + + @Override + protected Subtitle decode(byte[] data, int length, boolean reset) { + if (reset) { + parser.reset(); + } + return new DvbSubtitle(parser.decode(data, length)); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java new file mode 100644 index 0000000000..839c206ad7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbParser.java @@ -0,0 +1,1059 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb; + +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.util.SparseArray; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Parses {@link Cue}s from a DVB subtitle bitstream. + */ +/* package */ final class DvbParser { + + private static final String TAG = "DvbParser"; + + // Segment types, as defined by ETSI EN 300 743 Table 2 + private static final int SEGMENT_TYPE_PAGE_COMPOSITION = 0x10; + private static final int SEGMENT_TYPE_REGION_COMPOSITION = 0x11; + private static final int SEGMENT_TYPE_CLUT_DEFINITION = 0x12; + private static final int SEGMENT_TYPE_OBJECT_DATA = 0x13; + private static final int SEGMENT_TYPE_DISPLAY_DEFINITION = 0x14; + + // Page states, as defined by ETSI EN 300 743 Table 3 + private static final int PAGE_STATE_NORMAL = 0; // Update. Only changed elements. + // private static final int PAGE_STATE_ACQUISITION = 1; // Refresh. All elements. + // private static final int PAGE_STATE_CHANGE = 2; // New. All elements. + + // Region depths, as defined by ETSI EN 300 743 Table 5 + // private static final int REGION_DEPTH_2_BIT = 1; + private static final int REGION_DEPTH_4_BIT = 2; + private static final int REGION_DEPTH_8_BIT = 3; + + // Object codings, as defined by ETSI EN 300 743 Table 8 + private static final int OBJECT_CODING_PIXELS = 0; + private static final int OBJECT_CODING_STRING = 1; + + // Pixel-data types, as defined by ETSI EN 300 743 Table 9 + private static final int DATA_TYPE_2BP_CODE_STRING = 0x10; + private static final int DATA_TYPE_4BP_CODE_STRING = 0x11; + private static final int DATA_TYPE_8BP_CODE_STRING = 0x12; + private static final int DATA_TYPE_24_TABLE_DATA = 0x20; + private static final int DATA_TYPE_28_TABLE_DATA = 0x21; + private static final int DATA_TYPE_48_TABLE_DATA = 0x22; + private static final int DATA_TYPE_END_LINE = 0xF0; + + // Clut mapping tables, as defined by ETSI EN 300 743 10.4, 10.5, 10.6 + private static final byte[] defaultMap2To4 = { + (byte) 0x00, (byte) 0x07, (byte) 0x08, (byte) 0x0F}; + private static final byte[] defaultMap2To8 = { + (byte) 0x00, (byte) 0x77, (byte) 0x88, (byte) 0xFF}; + private static final byte[] defaultMap4To8 = { + (byte) 0x00, (byte) 0x11, (byte) 0x22, (byte) 0x33, + (byte) 0x44, (byte) 0x55, (byte) 0x66, (byte) 0x77, + (byte) 0x88, (byte) 0x99, (byte) 0xAA, (byte) 0xBB, + (byte) 0xCC, (byte) 0xDD, (byte) 0xEE, (byte) 0xFF}; + + private final Paint defaultPaint; + private final Paint fillRegionPaint; + private final Canvas canvas; + private final DisplayDefinition defaultDisplayDefinition; + private final ClutDefinition defaultClutDefinition; + private final SubtitleService subtitleService; + + @MonotonicNonNull private Bitmap bitmap; + + /** + * Construct an instance for the given subtitle and ancillary page ids. + * + * @param subtitlePageId The id of the subtitle page carrying the subtitle to be parsed. + * @param ancillaryPageId The id of the ancillary page containing additional data. + */ + public DvbParser(int subtitlePageId, int ancillaryPageId) { + defaultPaint = new Paint(); + defaultPaint.setStyle(Paint.Style.FILL_AND_STROKE); + defaultPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC)); + defaultPaint.setPathEffect(null); + fillRegionPaint = new Paint(); + fillRegionPaint.setStyle(Paint.Style.FILL); + fillRegionPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER)); + fillRegionPaint.setPathEffect(null); + canvas = new Canvas(); + defaultDisplayDefinition = new DisplayDefinition(719, 575, 0, 719, 0, 575); + defaultClutDefinition = new ClutDefinition(0, generateDefault2BitClutEntries(), + generateDefault4BitClutEntries(), generateDefault8BitClutEntries()); + subtitleService = new SubtitleService(subtitlePageId, ancillaryPageId); + } + + /** + * Resets the parser. + */ + public void reset() { + subtitleService.reset(); + } + + /** + * Decodes a subtitling packet, returning a list of parsed {@link Cue}s. + * + * @param data The subtitling packet data to decode. + * @param limit The limit in {@code data} at which to stop decoding. + * @return The parsed {@link Cue}s. + */ + public List<Cue> decode(byte[] data, int limit) { + // Parse the input data. + ParsableBitArray dataBitArray = new ParsableBitArray(data, limit); + while (dataBitArray.bitsLeft() >= 48 // sync_byte (8) + segment header (40) + && dataBitArray.readBits(8) == 0x0F) { + parseSubtitlingSegment(dataBitArray, subtitleService); + } + + @Nullable PageComposition pageComposition = subtitleService.pageComposition; + if (pageComposition == null) { + return Collections.emptyList(); + } + + // Update the canvas bitmap if necessary. + DisplayDefinition displayDefinition = subtitleService.displayDefinition != null + ? subtitleService.displayDefinition : defaultDisplayDefinition; + if (bitmap == null || displayDefinition.width + 1 != bitmap.getWidth() + || displayDefinition.height + 1 != bitmap.getHeight()) { + bitmap = Bitmap.createBitmap(displayDefinition.width + 1, displayDefinition.height + 1, + Bitmap.Config.ARGB_8888); + canvas.setBitmap(bitmap); + } + + // Build the cues. + List<Cue> cues = new ArrayList<>(); + SparseArray<PageRegion> pageRegions = pageComposition.regions; + for (int i = 0; i < pageRegions.size(); i++) { + // Save clean clipping state. + canvas.save(); + PageRegion pageRegion = pageRegions.valueAt(i); + int regionId = pageRegions.keyAt(i); + RegionComposition regionComposition = subtitleService.regions.get(regionId); + + // Clip drawing to the current region and display definition window. + int baseHorizontalAddress = pageRegion.horizontalAddress + + displayDefinition.horizontalPositionMinimum; + int baseVerticalAddress = pageRegion.verticalAddress + + displayDefinition.verticalPositionMinimum; + int clipRight = Math.min(baseHorizontalAddress + regionComposition.width, + displayDefinition.horizontalPositionMaximum); + int clipBottom = Math.min(baseVerticalAddress + regionComposition.height, + displayDefinition.verticalPositionMaximum); + canvas.clipRect(baseHorizontalAddress, baseVerticalAddress, clipRight, clipBottom); + ClutDefinition clutDefinition = subtitleService.cluts.get(regionComposition.clutId); + if (clutDefinition == null) { + clutDefinition = subtitleService.ancillaryCluts.get(regionComposition.clutId); + if (clutDefinition == null) { + clutDefinition = defaultClutDefinition; + } + } + + SparseArray<RegionObject> regionObjects = regionComposition.regionObjects; + for (int j = 0; j < regionObjects.size(); j++) { + int objectId = regionObjects.keyAt(j); + RegionObject regionObject = regionObjects.valueAt(j); + ObjectData objectData = subtitleService.objects.get(objectId); + if (objectData == null) { + objectData = subtitleService.ancillaryObjects.get(objectId); + } + if (objectData != null) { + @Nullable Paint paint = objectData.nonModifyingColorFlag ? null : defaultPaint; + paintPixelDataSubBlocks(objectData, clutDefinition, regionComposition.depth, + baseHorizontalAddress + regionObject.horizontalPosition, + baseVerticalAddress + regionObject.verticalPosition, paint, canvas); + } + } + + if (regionComposition.fillFlag) { + int color; + if (regionComposition.depth == REGION_DEPTH_8_BIT) { + color = clutDefinition.clutEntries8Bit[regionComposition.pixelCode8Bit]; + } else if (regionComposition.depth == REGION_DEPTH_4_BIT) { + color = clutDefinition.clutEntries4Bit[regionComposition.pixelCode4Bit]; + } else { + color = clutDefinition.clutEntries2Bit[regionComposition.pixelCode2Bit]; + } + fillRegionPaint.setColor(color); + canvas.drawRect(baseHorizontalAddress, baseVerticalAddress, + baseHorizontalAddress + regionComposition.width, + baseVerticalAddress + regionComposition.height, + fillRegionPaint); + } + + Bitmap cueBitmap = Bitmap.createBitmap(bitmap, baseHorizontalAddress, baseVerticalAddress, + regionComposition.width, regionComposition.height); + cues.add(new Cue(cueBitmap, (float) baseHorizontalAddress / displayDefinition.width, + Cue.ANCHOR_TYPE_START, (float) baseVerticalAddress / displayDefinition.height, + Cue.ANCHOR_TYPE_START, (float) regionComposition.width / displayDefinition.width, + (float) regionComposition.height / displayDefinition.height)); + + canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR); + // Restore clean clipping state. + canvas.restore(); + } + + return Collections.unmodifiableList(cues); + } + + // Static parsing. + + /** + * Parses a subtitling segment, as defined by ETSI EN 300 743 7.2 + * <p> + * The {@link SubtitleService} is updated with the parsed segment data. + */ + private static void parseSubtitlingSegment(ParsableBitArray data, SubtitleService service) { + int segmentType = data.readBits(8); + int pageId = data.readBits(16); + int dataFieldLength = data.readBits(16); + int dataFieldLimit = data.getBytePosition() + dataFieldLength; + + if ((dataFieldLength * 8) > data.bitsLeft()) { + Log.w(TAG, "Data field length exceeds limit"); + // Skip to the very end. + data.skipBits(data.bitsLeft()); + return; + } + + switch (segmentType) { + case SEGMENT_TYPE_DISPLAY_DEFINITION: + if (pageId == service.subtitlePageId) { + service.displayDefinition = parseDisplayDefinition(data); + } + break; + case SEGMENT_TYPE_PAGE_COMPOSITION: + if (pageId == service.subtitlePageId) { + @Nullable PageComposition current = service.pageComposition; + PageComposition pageComposition = parsePageComposition(data, dataFieldLength); + if (pageComposition.state != PAGE_STATE_NORMAL) { + service.pageComposition = pageComposition; + service.regions.clear(); + service.cluts.clear(); + service.objects.clear(); + } else if (current != null && current.version != pageComposition.version) { + service.pageComposition = pageComposition; + } + } + break; + case SEGMENT_TYPE_REGION_COMPOSITION: + @Nullable PageComposition pageComposition = service.pageComposition; + if (pageId == service.subtitlePageId && pageComposition != null) { + RegionComposition regionComposition = parseRegionComposition(data, dataFieldLength); + if (pageComposition.state == PAGE_STATE_NORMAL) { + @Nullable + RegionComposition existingRegionComposition = service.regions.get(regionComposition.id); + if (existingRegionComposition != null) { + regionComposition.mergeFrom(existingRegionComposition); + } + } + service.regions.put(regionComposition.id, regionComposition); + } + break; + case SEGMENT_TYPE_CLUT_DEFINITION: + if (pageId == service.subtitlePageId) { + ClutDefinition clutDefinition = parseClutDefinition(data, dataFieldLength); + service.cluts.put(clutDefinition.id, clutDefinition); + } else if (pageId == service.ancillaryPageId) { + ClutDefinition clutDefinition = parseClutDefinition(data, dataFieldLength); + service.ancillaryCluts.put(clutDefinition.id, clutDefinition); + } + break; + case SEGMENT_TYPE_OBJECT_DATA: + if (pageId == service.subtitlePageId) { + ObjectData objectData = parseObjectData(data); + service.objects.put(objectData.id, objectData); + } else if (pageId == service.ancillaryPageId) { + ObjectData objectData = parseObjectData(data); + service.ancillaryObjects.put(objectData.id, objectData); + } + break; + default: + // Do nothing. + break; + } + + // Skip to the next segment. + data.skipBytes(dataFieldLimit - data.getBytePosition()); + } + + /** + * Parses a display definition segment, as defined by ETSI EN 300 743 7.2.1. + */ + private static DisplayDefinition parseDisplayDefinition(ParsableBitArray data) { + data.skipBits(4); // dds_version_number (4). + boolean displayWindowFlag = data.readBit(); + data.skipBits(3); // Skip reserved. + int width = data.readBits(16); + int height = data.readBits(16); + + int horizontalPositionMinimum; + int horizontalPositionMaximum; + int verticalPositionMinimum; + int verticalPositionMaximum; + if (displayWindowFlag) { + horizontalPositionMinimum = data.readBits(16); + horizontalPositionMaximum = data.readBits(16); + verticalPositionMinimum = data.readBits(16); + verticalPositionMaximum = data.readBits(16); + } else { + horizontalPositionMinimum = 0; + horizontalPositionMaximum = width; + verticalPositionMinimum = 0; + verticalPositionMaximum = height; + } + + return new DisplayDefinition(width, height, horizontalPositionMinimum, + horizontalPositionMaximum, verticalPositionMinimum, verticalPositionMaximum); + } + + /** + * Parses a page composition segment, as defined by ETSI EN 300 743 7.2.2. + */ + private static PageComposition parsePageComposition(ParsableBitArray data, int length) { + int timeoutSecs = data.readBits(8); + int version = data.readBits(4); + int state = data.readBits(2); + data.skipBits(2); + int remainingLength = length - 2; + + SparseArray<PageRegion> regions = new SparseArray<>(); + while (remainingLength > 0) { + int regionId = data.readBits(8); + data.skipBits(8); // Skip reserved. + int regionHorizontalAddress = data.readBits(16); + int regionVerticalAddress = data.readBits(16); + remainingLength -= 6; + regions.put(regionId, new PageRegion(regionHorizontalAddress, regionVerticalAddress)); + } + + return new PageComposition(timeoutSecs, version, state, regions); + } + + /** + * Parses a region composition segment, as defined by ETSI EN 300 743 7.2.3. + */ + private static RegionComposition parseRegionComposition(ParsableBitArray data, int length) { + int id = data.readBits(8); + data.skipBits(4); // Skip region_version_number + boolean fillFlag = data.readBit(); + data.skipBits(3); // Skip reserved. + int width = data.readBits(16); + int height = data.readBits(16); + int levelOfCompatibility = data.readBits(3); + int depth = data.readBits(3); + data.skipBits(2); // Skip reserved. + int clutId = data.readBits(8); + int pixelCode8Bit = data.readBits(8); + int pixelCode4Bit = data.readBits(4); + int pixelCode2Bit = data.readBits(2); + data.skipBits(2); // Skip reserved + int remainingLength = length - 10; + + SparseArray<RegionObject> regionObjects = new SparseArray<>(); + while (remainingLength > 0) { + int objectId = data.readBits(16); + int objectType = data.readBits(2); + int objectProvider = data.readBits(2); + int objectHorizontalPosition = data.readBits(12); + data.skipBits(4); // Skip reserved. + int objectVerticalPosition = data.readBits(12); + remainingLength -= 6; + + int foregroundPixelCode = 0; + int backgroundPixelCode = 0; + if (objectType == 0x01 || objectType == 0x02) { // Only seems to affect to char subtitles. + foregroundPixelCode = data.readBits(8); + backgroundPixelCode = data.readBits(8); + remainingLength -= 2; + } + + regionObjects.put(objectId, new RegionObject(objectType, objectProvider, + objectHorizontalPosition, objectVerticalPosition, foregroundPixelCode, + backgroundPixelCode)); + } + + return new RegionComposition(id, fillFlag, width, height, levelOfCompatibility, depth, clutId, + pixelCode8Bit, pixelCode4Bit, pixelCode2Bit, regionObjects); + } + + /** + * Parses a CLUT definition segment, as defined by ETSI EN 300 743 7.2.4. + */ + private static ClutDefinition parseClutDefinition(ParsableBitArray data, int length) { + int clutId = data.readBits(8); + data.skipBits(8); // Skip clut_version_number (4), reserved (4) + int remainingLength = length - 2; + + int[] clutEntries2Bit = generateDefault2BitClutEntries(); + int[] clutEntries4Bit = generateDefault4BitClutEntries(); + int[] clutEntries8Bit = generateDefault8BitClutEntries(); + + while (remainingLength > 0) { + int entryId = data.readBits(8); + int entryFlags = data.readBits(8); + remainingLength -= 2; + + int[] clutEntries; + if ((entryFlags & 0x80) != 0) { + clutEntries = clutEntries2Bit; + } else if ((entryFlags & 0x40) != 0) { + clutEntries = clutEntries4Bit; + } else { + clutEntries = clutEntries8Bit; + } + + int y; + int cr; + int cb; + int t; + if ((entryFlags & 0x01) != 0) { + y = data.readBits(8); + cr = data.readBits(8); + cb = data.readBits(8); + t = data.readBits(8); + remainingLength -= 4; + } else { + y = data.readBits(6) << 2; + cr = data.readBits(4) << 4; + cb = data.readBits(4) << 4; + t = data.readBits(2) << 6; + remainingLength -= 2; + } + + if (y == 0x00) { + cr = 0x00; + cb = 0x00; + t = 0xFF; + } + + int a = (byte) (0xFF - (t & 0xFF)); + int r = (int) (y + (1.40200 * (cr - 128))); + int g = (int) (y - (0.34414 * (cb - 128)) - (0.71414 * (cr - 128))); + int b = (int) (y + (1.77200 * (cb - 128))); + clutEntries[entryId] = getColor(a, Util.constrainValue(r, 0, 255), + Util.constrainValue(g, 0, 255), Util.constrainValue(b, 0, 255)); + } + + return new ClutDefinition(clutId, clutEntries2Bit, clutEntries4Bit, clutEntries8Bit); + } + + /** + * Parses an object data segment, as defined by ETSI EN 300 743 7.2.5. + * + * @return The parsed object data. + */ + private static ObjectData parseObjectData(ParsableBitArray data) { + int objectId = data.readBits(16); + data.skipBits(4); // Skip object_version_number + int objectCodingMethod = data.readBits(2); + boolean nonModifyingColorFlag = data.readBit(); + data.skipBits(1); // Skip reserved. + + @Nullable byte[] topFieldData = null; + @Nullable byte[] bottomFieldData = null; + + if (objectCodingMethod == OBJECT_CODING_STRING) { + int numberOfCodes = data.readBits(8); + // TODO: Parse and use character_codes. + data.skipBits(numberOfCodes * 16); // Skip character_codes. + } else if (objectCodingMethod == OBJECT_CODING_PIXELS) { + int topFieldDataLength = data.readBits(16); + int bottomFieldDataLength = data.readBits(16); + if (topFieldDataLength > 0) { + topFieldData = new byte[topFieldDataLength]; + data.readBytes(topFieldData, 0, topFieldDataLength); + } + if (bottomFieldDataLength > 0) { + bottomFieldData = new byte[bottomFieldDataLength]; + data.readBytes(bottomFieldData, 0, bottomFieldDataLength); + } else { + bottomFieldData = topFieldData; + } + } + + return new ObjectData(objectId, nonModifyingColorFlag, topFieldData, bottomFieldData); + } + + private static int[] generateDefault2BitClutEntries() { + int[] entries = new int[4]; + entries[0] = 0x00000000; + entries[1] = 0xFFFFFFFF; + entries[2] = 0xFF000000; + entries[3] = 0xFF7F7F7F; + return entries; + } + + private static int[] generateDefault4BitClutEntries() { + int[] entries = new int[16]; + entries[0] = 0x00000000; + for (int i = 1; i < entries.length; i++) { + if (i < 8) { + entries[i] = getColor( + 0xFF, + ((i & 0x01) != 0 ? 0xFF : 0x00), + ((i & 0x02) != 0 ? 0xFF : 0x00), + ((i & 0x04) != 0 ? 0xFF : 0x00)); + } else { + entries[i] = getColor( + 0xFF, + ((i & 0x01) != 0 ? 0x7F : 0x00), + ((i & 0x02) != 0 ? 0x7F : 0x00), + ((i & 0x04) != 0 ? 0x7F : 0x00)); + } + } + return entries; + } + + private static int[] generateDefault8BitClutEntries() { + int[] entries = new int[256]; + entries[0] = 0x00000000; + for (int i = 0; i < entries.length; i++) { + if (i < 8) { + entries[i] = getColor( + 0x3F, + ((i & 0x01) != 0 ? 0xFF : 0x00), + ((i & 0x02) != 0 ? 0xFF : 0x00), + ((i & 0x04) != 0 ? 0xFF : 0x00)); + } else { + switch (i & 0x88) { + case 0x00: + entries[i] = getColor( + 0xFF, + (((i & 0x01) != 0 ? 0x55 : 0x00) + ((i & 0x10) != 0 ? 0xAA : 0x00)), + (((i & 0x02) != 0 ? 0x55 : 0x00) + ((i & 0x20) != 0 ? 0xAA : 0x00)), + (((i & 0x04) != 0 ? 0x55 : 0x00) + ((i & 0x40) != 0 ? 0xAA : 0x00))); + break; + case 0x08: + entries[i] = getColor( + 0x7F, + (((i & 0x01) != 0 ? 0x55 : 0x00) + ((i & 0x10) != 0 ? 0xAA : 0x00)), + (((i & 0x02) != 0 ? 0x55 : 0x00) + ((i & 0x20) != 0 ? 0xAA : 0x00)), + (((i & 0x04) != 0 ? 0x55 : 0x00) + ((i & 0x40) != 0 ? 0xAA : 0x00))); + break; + case 0x80: + entries[i] = getColor( + 0xFF, + (127 + ((i & 0x01) != 0 ? 0x2B : 0x00) + ((i & 0x10) != 0 ? 0x55 : 0x00)), + (127 + ((i & 0x02) != 0 ? 0x2B : 0x00) + ((i & 0x20) != 0 ? 0x55 : 0x00)), + (127 + ((i & 0x04) != 0 ? 0x2B : 0x00) + ((i & 0x40) != 0 ? 0x55 : 0x00))); + break; + case 0x88: + entries[i] = getColor( + 0xFF, + (((i & 0x01) != 0 ? 0x2B : 0x00) + ((i & 0x10) != 0 ? 0x55 : 0x00)), + (((i & 0x02) != 0 ? 0x2B : 0x00) + ((i & 0x20) != 0 ? 0x55 : 0x00)), + (((i & 0x04) != 0 ? 0x2B : 0x00) + ((i & 0x40) != 0 ? 0x55 : 0x00))); + break; + } + } + } + return entries; + } + + private static int getColor(int a, int r, int g, int b) { + return (a << 24) | (r << 16) | (g << 8) | b; + } + + // Static drawing. + + /** Draws a pixel data sub-block, as defined by ETSI EN 300 743 7.2.5.1, into a canvas. */ + private static void paintPixelDataSubBlocks( + ObjectData objectData, + ClutDefinition clutDefinition, + int regionDepth, + int horizontalAddress, + int verticalAddress, + @Nullable Paint paint, + Canvas canvas) { + int[] clutEntries; + if (regionDepth == REGION_DEPTH_8_BIT) { + clutEntries = clutDefinition.clutEntries8Bit; + } else if (regionDepth == REGION_DEPTH_4_BIT) { + clutEntries = clutDefinition.clutEntries4Bit; + } else { + clutEntries = clutDefinition.clutEntries2Bit; + } + paintPixelDataSubBlock(objectData.topFieldData, clutEntries, regionDepth, horizontalAddress, + verticalAddress, paint, canvas); + paintPixelDataSubBlock(objectData.bottomFieldData, clutEntries, regionDepth, horizontalAddress, + verticalAddress + 1, paint, canvas); + } + + /** Draws a pixel data sub-block, as defined by ETSI EN 300 743 7.2.5.1, into a canvas. */ + private static void paintPixelDataSubBlock( + byte[] pixelData, + int[] clutEntries, + int regionDepth, + int horizontalAddress, + int verticalAddress, + @Nullable Paint paint, + Canvas canvas) { + ParsableBitArray data = new ParsableBitArray(pixelData); + int column = horizontalAddress; + int line = verticalAddress; + @Nullable byte[] clutMapTable2To4 = null; + @Nullable byte[] clutMapTable2To8 = null; + @Nullable byte[] clutMapTable4To8 = null; + + while (data.bitsLeft() != 0) { + int dataType = data.readBits(8); + switch (dataType) { + case DATA_TYPE_2BP_CODE_STRING: + @Nullable byte[] clutMapTable2ToX; + if (regionDepth == REGION_DEPTH_8_BIT) { + clutMapTable2ToX = clutMapTable2To8 == null ? defaultMap2To8 : clutMapTable2To8; + } else if (regionDepth == REGION_DEPTH_4_BIT) { + clutMapTable2ToX = clutMapTable2To4 == null ? defaultMap2To4 : clutMapTable2To4; + } else { + clutMapTable2ToX = null; + } + column = paint2BitPixelCodeString(data, clutEntries, clutMapTable2ToX, column, line, + paint, canvas); + data.byteAlign(); + break; + case DATA_TYPE_4BP_CODE_STRING: + @Nullable byte[] clutMapTable4ToX; + if (regionDepth == REGION_DEPTH_8_BIT) { + clutMapTable4ToX = clutMapTable4To8 == null ? defaultMap4To8 : clutMapTable4To8; + } else { + clutMapTable4ToX = null; + } + column = paint4BitPixelCodeString(data, clutEntries, clutMapTable4ToX, column, line, + paint, canvas); + data.byteAlign(); + break; + case DATA_TYPE_8BP_CODE_STRING: + column = + paint8BitPixelCodeString( + data, clutEntries, /* clutMapTable= */ null, column, line, paint, canvas); + break; + case DATA_TYPE_24_TABLE_DATA: + clutMapTable2To4 = buildClutMapTable(4, 4, data); + break; + case DATA_TYPE_28_TABLE_DATA: + clutMapTable2To8 = buildClutMapTable(4, 8, data); + break; + case DATA_TYPE_48_TABLE_DATA: + clutMapTable4To8 = buildClutMapTable(16, 8, data); + break; + case DATA_TYPE_END_LINE: + column = horizontalAddress; + line += 2; + break; + default: + // Do nothing. + break; + } + } + } + + /** Paint a 2-bit/pixel code string, as defined by ETSI EN 300 743 7.2.5.2, to a canvas. */ + private static int paint2BitPixelCodeString( + ParsableBitArray data, + int[] clutEntries, + @Nullable byte[] clutMapTable, + int column, + int line, + @Nullable Paint paint, + Canvas canvas) { + boolean endOfPixelCodeString = false; + do { + int runLength = 0; + int clutIndex = 0; + int peek = data.readBits(2); + if (peek != 0x00) { + runLength = 1; + clutIndex = peek; + } else if (data.readBit()) { + runLength = 3 + data.readBits(3); + clutIndex = data.readBits(2); + } else if (data.readBit()) { + runLength = 1; + } else { + switch (data.readBits(2)) { + case 0x00: + endOfPixelCodeString = true; + break; + case 0x01: + runLength = 2; + break; + case 0x02: + runLength = 12 + data.readBits(4); + clutIndex = data.readBits(2); + break; + case 0x03: + runLength = 29 + data.readBits(8); + clutIndex = data.readBits(2); + break; + } + } + + if (runLength != 0 && paint != null) { + paint.setColor(clutEntries[clutMapTable != null ? clutMapTable[clutIndex] : clutIndex]); + canvas.drawRect(column, line, column + runLength, line + 1, paint); + } + + column += runLength; + } while (!endOfPixelCodeString); + + return column; + } + + /** Paint a 4-bit/pixel code string, as defined by ETSI EN 300 743 7.2.5.2, to a canvas. */ + private static int paint4BitPixelCodeString( + ParsableBitArray data, + int[] clutEntries, + @Nullable byte[] clutMapTable, + int column, + int line, + @Nullable Paint paint, + Canvas canvas) { + boolean endOfPixelCodeString = false; + do { + int runLength = 0; + int clutIndex = 0; + int peek = data.readBits(4); + if (peek != 0x00) { + runLength = 1; + clutIndex = peek; + } else if (!data.readBit()) { + peek = data.readBits(3); + if (peek != 0x00) { + runLength = 2 + peek; + clutIndex = 0x00; + } else { + endOfPixelCodeString = true; + } + } else if (!data.readBit()) { + runLength = 4 + data.readBits(2); + clutIndex = data.readBits(4); + } else { + switch (data.readBits(2)) { + case 0x00: + runLength = 1; + break; + case 0x01: + runLength = 2; + break; + case 0x02: + runLength = 9 + data.readBits(4); + clutIndex = data.readBits(4); + break; + case 0x03: + runLength = 25 + data.readBits(8); + clutIndex = data.readBits(4); + break; + } + } + + if (runLength != 0 && paint != null) { + paint.setColor(clutEntries[clutMapTable != null ? clutMapTable[clutIndex] : clutIndex]); + canvas.drawRect(column, line, column + runLength, line + 1, paint); + } + + column += runLength; + } while (!endOfPixelCodeString); + + return column; + } + + /** Paint an 8-bit/pixel code string, as defined by ETSI EN 300 743 7.2.5.2, to a canvas. */ + private static int paint8BitPixelCodeString( + ParsableBitArray data, + int[] clutEntries, + @Nullable byte[] clutMapTable, + int column, + int line, + @Nullable Paint paint, + Canvas canvas) { + boolean endOfPixelCodeString = false; + do { + int runLength = 0; + int clutIndex = 0; + int peek = data.readBits(8); + if (peek != 0x00) { + runLength = 1; + clutIndex = peek; + } else { + if (!data.readBit()) { + peek = data.readBits(7); + if (peek != 0x00) { + runLength = peek; + clutIndex = 0x00; + } else { + endOfPixelCodeString = true; + } + } else { + runLength = data.readBits(7); + clutIndex = data.readBits(8); + } + } + + if (runLength != 0 && paint != null) { + paint.setColor(clutEntries[clutMapTable != null ? clutMapTable[clutIndex] : clutIndex]); + canvas.drawRect(column, line, column + runLength, line + 1, paint); + } + column += runLength; + } while (!endOfPixelCodeString); + + return column; + } + + private static byte[] buildClutMapTable(int length, int bitsPerEntry, ParsableBitArray data) { + byte[] clutMapTable = new byte[length]; + for (int i = 0; i < length; i++) { + clutMapTable[i] = (byte) data.readBits(bitsPerEntry); + } + return clutMapTable; + } + + // Private inner classes. + + /** + * The subtitle service definition. + */ + private static final class SubtitleService { + + public final int subtitlePageId; + public final int ancillaryPageId; + + public final SparseArray<RegionComposition> regions; + public final SparseArray<ClutDefinition> cluts; + public final SparseArray<ObjectData> objects; + public final SparseArray<ClutDefinition> ancillaryCluts; + public final SparseArray<ObjectData> ancillaryObjects; + + @Nullable public DisplayDefinition displayDefinition; + @Nullable public PageComposition pageComposition; + + public SubtitleService(int subtitlePageId, int ancillaryPageId) { + this.subtitlePageId = subtitlePageId; + this.ancillaryPageId = ancillaryPageId; + regions = new SparseArray<>(); + cluts = new SparseArray<>(); + objects = new SparseArray<>(); + ancillaryCluts = new SparseArray<>(); + ancillaryObjects = new SparseArray<>(); + } + + public void reset() { + regions.clear(); + cluts.clear(); + objects.clear(); + ancillaryCluts.clear(); + ancillaryObjects.clear(); + displayDefinition = null; + pageComposition = null; + } + + } + + /** + * Contains the geometry and active area of the subtitle service. + * <p> + * See ETSI EN 300 743 7.2.1 + */ + private static final class DisplayDefinition { + + public final int width; + public final int height; + + public final int horizontalPositionMinimum; + public final int horizontalPositionMaximum; + public final int verticalPositionMinimum; + public final int verticalPositionMaximum; + + public DisplayDefinition(int width, int height, int horizontalPositionMinimum, + int horizontalPositionMaximum, int verticalPositionMinimum, int verticalPositionMaximum) { + this.width = width; + this.height = height; + this.horizontalPositionMinimum = horizontalPositionMinimum; + this.horizontalPositionMaximum = horizontalPositionMaximum; + this.verticalPositionMinimum = verticalPositionMinimum; + this.verticalPositionMaximum = verticalPositionMaximum; + } + + } + + /** + * The page is the definition and arrangement of regions in the screen. + * <p> + * See ETSI EN 300 743 7.2.2 + */ + private static final class PageComposition { + + public final int timeOutSecs; // TODO: Use this or remove it. + public final int version; + public final int state; + public final SparseArray<PageRegion> regions; + + public PageComposition(int timeoutSecs, int version, int state, + SparseArray<PageRegion> regions) { + this.timeOutSecs = timeoutSecs; + this.version = version; + this.state = state; + this.regions = regions; + } + + } + + /** + * A region within a {@link PageComposition}. + * <p> + * See ETSI EN 300 743 7.2.2 + */ + private static final class PageRegion { + + public final int horizontalAddress; + public final int verticalAddress; + + public PageRegion(int horizontalAddress, int verticalAddress) { + this.horizontalAddress = horizontalAddress; + this.verticalAddress = verticalAddress; + } + + } + + /** + * An area of the page composed of a list of objects and a CLUT. + * <p> + * See ETSI EN 300 743 7.2.3 + */ + private static final class RegionComposition { + + public final int id; + public final boolean fillFlag; + public final int width; + public final int height; + public final int levelOfCompatibility; // TODO: Use this or remove it. + public final int depth; + public final int clutId; + public final int pixelCode8Bit; + public final int pixelCode4Bit; + public final int pixelCode2Bit; + public final SparseArray<RegionObject> regionObjects; + + public RegionComposition(int id, boolean fillFlag, int width, int height, + int levelOfCompatibility, int depth, int clutId, int pixelCode8Bit, int pixelCode4Bit, + int pixelCode2Bit, SparseArray<RegionObject> regionObjects) { + this.id = id; + this.fillFlag = fillFlag; + this.width = width; + this.height = height; + this.levelOfCompatibility = levelOfCompatibility; + this.depth = depth; + this.clutId = clutId; + this.pixelCode8Bit = pixelCode8Bit; + this.pixelCode4Bit = pixelCode4Bit; + this.pixelCode2Bit = pixelCode2Bit; + this.regionObjects = regionObjects; + } + + public void mergeFrom(RegionComposition otherRegionComposition) { + SparseArray<RegionObject> otherRegionObjects = otherRegionComposition.regionObjects; + for (int i = 0; i < otherRegionObjects.size(); i++) { + regionObjects.put(otherRegionObjects.keyAt(i), otherRegionObjects.valueAt(i)); + } + } + + } + + /** + * An object within a {@link RegionComposition}. + * <p> + * See ETSI EN 300 743 7.2.3 + */ + private static final class RegionObject { + + public final int type; // TODO: Use this or remove it. + public final int provider; // TODO: Use this or remove it. + public final int horizontalPosition; + public final int verticalPosition; + public final int foregroundPixelCode; // TODO: Use this or remove it. + public final int backgroundPixelCode; // TODO: Use this or remove it. + + public RegionObject(int type, int provider, int horizontalPosition, + int verticalPosition, int foregroundPixelCode, int backgroundPixelCode) { + this.type = type; + this.provider = provider; + this.horizontalPosition = horizontalPosition; + this.verticalPosition = verticalPosition; + this.foregroundPixelCode = foregroundPixelCode; + this.backgroundPixelCode = backgroundPixelCode; + } + + } + + /** + * CLUT family definition containing the color tables for the three bit depths defined + * <p> + * See ETSI EN 300 743 7.2.4 + */ + private static final class ClutDefinition { + + public final int id; + public final int[] clutEntries2Bit; + public final int[] clutEntries4Bit; + public final int[] clutEntries8Bit; + + public ClutDefinition(int id, int[] clutEntries2Bit, int[] clutEntries4Bit, + int[] clutEntries8bit) { + this.id = id; + this.clutEntries2Bit = clutEntries2Bit; + this.clutEntries4Bit = clutEntries4Bit; + this.clutEntries8Bit = clutEntries8bit; + } + + } + + /** + * The textual or graphical representation of an object. + * <p> + * See ETSI EN 300 743 7.2.5 + */ + private static final class ObjectData { + + public final int id; + public final boolean nonModifyingColorFlag; + public final byte[] topFieldData; + public final byte[] bottomFieldData; + + public ObjectData(int id, boolean nonModifyingColorFlag, byte[] topFieldData, + byte[] bottomFieldData) { + this.id = id; + this.nonModifyingColorFlag = nonModifyingColorFlag; + this.topFieldData = topFieldData; + this.bottomFieldData = bottomFieldData; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java new file mode 100644 index 0000000000..a624ddaeae --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/DvbSubtitle.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import java.util.List; + +/** + * A representation of a DVB subtitle. + */ +/* package */ final class DvbSubtitle implements Subtitle { + + private final List<Cue> cues; + + public DvbSubtitle(List<Cue> cues) { + this.cues = cues; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + return C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return 1; + } + + @Override + public long getEventTime(int index) { + return 0; + } + + @Override + public List<Cue> getCues(long timeUs) { + return cues; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java new file mode 100644 index 0000000000..be6b16c5e6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/dvb/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.dvb; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java new file mode 100644 index 0000000000..0b6e0d1f8c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java new file mode 100644 index 0000000000..859d240e9b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsDecoder.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs; + +import android.graphics.Bitmap; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.zip.Inflater; + +/** A {@link SimpleSubtitleDecoder} for PGS subtitles. */ +public final class PgsDecoder extends SimpleSubtitleDecoder { + + private static final int SECTION_TYPE_PALETTE = 0x14; + private static final int SECTION_TYPE_BITMAP_PICTURE = 0x15; + private static final int SECTION_TYPE_IDENTIFIER = 0x16; + private static final int SECTION_TYPE_END = 0x80; + + private static final byte INFLATE_HEADER = 0x78; + + private final ParsableByteArray buffer; + private final ParsableByteArray inflatedBuffer; + private final CueBuilder cueBuilder; + + @Nullable private Inflater inflater; + + public PgsDecoder() { + super("PgsDecoder"); + buffer = new ParsableByteArray(); + inflatedBuffer = new ParsableByteArray(); + cueBuilder = new CueBuilder(); + } + + @Override + protected Subtitle decode(byte[] data, int size, boolean reset) throws SubtitleDecoderException { + buffer.reset(data, size); + maybeInflateData(buffer); + cueBuilder.reset(); + ArrayList<Cue> cues = new ArrayList<>(); + while (buffer.bytesLeft() >= 3) { + Cue cue = readNextSection(buffer, cueBuilder); + if (cue != null) { + cues.add(cue); + } + } + return new PgsSubtitle(Collections.unmodifiableList(cues)); + } + + private void maybeInflateData(ParsableByteArray buffer) { + if (buffer.bytesLeft() > 0 && buffer.peekUnsignedByte() == INFLATE_HEADER) { + if (inflater == null) { + inflater = new Inflater(); + } + if (Util.inflate(buffer, inflatedBuffer, inflater)) { + buffer.reset(inflatedBuffer.data, inflatedBuffer.limit()); + } // else assume data is not compressed. + } + } + + @Nullable + private static Cue readNextSection(ParsableByteArray buffer, CueBuilder cueBuilder) { + int limit = buffer.limit(); + int sectionType = buffer.readUnsignedByte(); + int sectionLength = buffer.readUnsignedShort(); + + int nextSectionPosition = buffer.getPosition() + sectionLength; + if (nextSectionPosition > limit) { + buffer.setPosition(limit); + return null; + } + + Cue cue = null; + switch (sectionType) { + case SECTION_TYPE_PALETTE: + cueBuilder.parsePaletteSection(buffer, sectionLength); + break; + case SECTION_TYPE_BITMAP_PICTURE: + cueBuilder.parseBitmapSection(buffer, sectionLength); + break; + case SECTION_TYPE_IDENTIFIER: + cueBuilder.parseIdentifierSection(buffer, sectionLength); + break; + case SECTION_TYPE_END: + cue = cueBuilder.build(); + cueBuilder.reset(); + break; + default: + break; + } + + buffer.setPosition(nextSectionPosition); + return cue; + } + + private static final class CueBuilder { + + private final ParsableByteArray bitmapData; + private final int[] colors; + + private boolean colorsSet; + private int planeWidth; + private int planeHeight; + private int bitmapX; + private int bitmapY; + private int bitmapWidth; + private int bitmapHeight; + + public CueBuilder() { + bitmapData = new ParsableByteArray(); + colors = new int[256]; + } + + private void parsePaletteSection(ParsableByteArray buffer, int sectionLength) { + if ((sectionLength % 5) != 2) { + // Section must be two bytes followed by a whole number of (index, y, cb, cr, a) entries. + return; + } + buffer.skipBytes(2); + + Arrays.fill(colors, 0); + int entryCount = sectionLength / 5; + for (int i = 0; i < entryCount; i++) { + int index = buffer.readUnsignedByte(); + int y = buffer.readUnsignedByte(); + int cr = buffer.readUnsignedByte(); + int cb = buffer.readUnsignedByte(); + int a = buffer.readUnsignedByte(); + int r = (int) (y + (1.40200 * (cr - 128))); + int g = (int) (y - (0.34414 * (cb - 128)) - (0.71414 * (cr - 128))); + int b = (int) (y + (1.77200 * (cb - 128))); + colors[index] = + (a << 24) + | (Util.constrainValue(r, 0, 255) << 16) + | (Util.constrainValue(g, 0, 255) << 8) + | Util.constrainValue(b, 0, 255); + } + colorsSet = true; + } + + private void parseBitmapSection(ParsableByteArray buffer, int sectionLength) { + if (sectionLength < 4) { + return; + } + buffer.skipBytes(3); // Id (2 bytes), version (1 byte). + boolean isBaseSection = (0x80 & buffer.readUnsignedByte()) != 0; + sectionLength -= 4; + + if (isBaseSection) { + if (sectionLength < 7) { + return; + } + int totalLength = buffer.readUnsignedInt24(); + if (totalLength < 4) { + return; + } + bitmapWidth = buffer.readUnsignedShort(); + bitmapHeight = buffer.readUnsignedShort(); + bitmapData.reset(totalLength - 4); + sectionLength -= 7; + } + + int position = bitmapData.getPosition(); + int limit = bitmapData.limit(); + if (position < limit && sectionLength > 0) { + int bytesToRead = Math.min(sectionLength, limit - position); + buffer.readBytes(bitmapData.data, position, bytesToRead); + bitmapData.setPosition(position + bytesToRead); + } + } + + private void parseIdentifierSection(ParsableByteArray buffer, int sectionLength) { + if (sectionLength < 19) { + return; + } + planeWidth = buffer.readUnsignedShort(); + planeHeight = buffer.readUnsignedShort(); + buffer.skipBytes(11); + bitmapX = buffer.readUnsignedShort(); + bitmapY = buffer.readUnsignedShort(); + } + + @Nullable + public Cue build() { + if (planeWidth == 0 + || planeHeight == 0 + || bitmapWidth == 0 + || bitmapHeight == 0 + || bitmapData.limit() == 0 + || bitmapData.getPosition() != bitmapData.limit() + || !colorsSet) { + return null; + } + // Build the bitmapData. + bitmapData.setPosition(0); + int[] argbBitmapData = new int[bitmapWidth * bitmapHeight]; + int argbBitmapDataIndex = 0; + while (argbBitmapDataIndex < argbBitmapData.length) { + int colorIndex = bitmapData.readUnsignedByte(); + if (colorIndex != 0) { + argbBitmapData[argbBitmapDataIndex++] = colors[colorIndex]; + } else { + int switchBits = bitmapData.readUnsignedByte(); + if (switchBits != 0) { + int runLength = + (switchBits & 0x40) == 0 + ? (switchBits & 0x3F) + : (((switchBits & 0x3F) << 8) | bitmapData.readUnsignedByte()); + int color = (switchBits & 0x80) == 0 ? 0 : colors[bitmapData.readUnsignedByte()]; + Arrays.fill( + argbBitmapData, argbBitmapDataIndex, argbBitmapDataIndex + runLength, color); + argbBitmapDataIndex += runLength; + } + } + } + Bitmap bitmap = + Bitmap.createBitmap(argbBitmapData, bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888); + // Build the cue. + return new Cue( + bitmap, + (float) bitmapX / planeWidth, + Cue.ANCHOR_TYPE_START, + (float) bitmapY / planeHeight, + Cue.ANCHOR_TYPE_START, + (float) bitmapWidth / planeWidth, + (float) bitmapHeight / planeHeight); + } + + public void reset() { + planeWidth = 0; + planeHeight = 0; + bitmapX = 0; + bitmapY = 0; + bitmapWidth = 0; + bitmapHeight = 0; + bitmapData.reset(0); + colorsSet = false; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java new file mode 100644 index 0000000000..e875763a45 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/PgsSubtitle.java @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import java.util.List; + +/** A representation of a PGS subtitle. */ +/* package */ final class PgsSubtitle implements Subtitle { + + private final List<Cue> cues; + + public PgsSubtitle(List<Cue> cues) { + this.cues = cues; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + return C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return 1; + } + + @Override + public long getEventTime(int index) { + return 0; + } + + @Override + public List<Cue> getCues(long timeUs) { + return cues; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java new file mode 100644 index 0000000000..ce385ea085 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/pgs/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.pgs; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java new file mode 100644 index 0000000000..8f878a998e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDecoder.java @@ -0,0 +1,446 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.text.Layout; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A {@link SimpleSubtitleDecoder} for SSA/ASS. */ +public final class SsaDecoder extends SimpleSubtitleDecoder { + + private static final String TAG = "SsaDecoder"; + + private static final Pattern SSA_TIMECODE_PATTERN = + Pattern.compile("(?:(\\d+):)?(\\d+):(\\d+)[:.](\\d+)"); + + /* package */ static final String FORMAT_LINE_PREFIX = "Format:"; + /* package */ static final String STYLE_LINE_PREFIX = "Style:"; + private static final String DIALOGUE_LINE_PREFIX = "Dialogue:"; + + private static final float DEFAULT_MARGIN = 0.05f; + + private final boolean haveInitializationData; + @Nullable private final SsaDialogueFormat dialogueFormatFromInitializationData; + + private @MonotonicNonNull Map<String, SsaStyle> styles; + + /** + * The horizontal resolution used by the subtitle author - all cue positions are relative to this. + * + * <p>Parsed from the {@code PlayResX} value in the {@code [Script Info]} section. + */ + private float screenWidth; + /** + * The vertical resolution used by the subtitle author - all cue positions are relative to this. + * + * <p>Parsed from the {@code PlayResY} value in the {@code [Script Info]} section. + */ + private float screenHeight; + + public SsaDecoder() { + this(/* initializationData= */ null); + } + + /** + * Constructs an SsaDecoder with optional format and header info. + * + * @param initializationData Optional initialization data for the decoder. If not null or empty, + * the initialization data must consist of two byte arrays. The first must contain an SSA + * format line. The second must contain an SSA header that will be assumed common to all + * samples. The header is everything in an SSA file before the {@code [Events]} section (i.e. + * {@code [Script Info]} and optional {@code [V4+ Styles]} section. + */ + public SsaDecoder(@Nullable List<byte[]> initializationData) { + super("SsaDecoder"); + screenWidth = Cue.DIMEN_UNSET; + screenHeight = Cue.DIMEN_UNSET; + + if (initializationData != null && !initializationData.isEmpty()) { + haveInitializationData = true; + String formatLine = Util.fromUtf8Bytes(initializationData.get(0)); + Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX)); + dialogueFormatFromInitializationData = + Assertions.checkNotNull(SsaDialogueFormat.fromFormatLine(formatLine)); + parseHeader(new ParsableByteArray(initializationData.get(1))); + } else { + haveInitializationData = false; + dialogueFormatFromInitializationData = null; + } + } + + @Override + protected Subtitle decode(byte[] bytes, int length, boolean reset) { + List<List<Cue>> cues = new ArrayList<>(); + List<Long> cueTimesUs = new ArrayList<>(); + + ParsableByteArray data = new ParsableByteArray(bytes, length); + if (!haveInitializationData) { + parseHeader(data); + } + parseEventBody(data, cues, cueTimesUs); + return new SsaSubtitle(cues, cueTimesUs); + } + + /** + * Parses the header of the subtitle. + * + * @param data A {@link ParsableByteArray} from which the header should be read. + */ + private void parseHeader(ParsableByteArray data) { + @Nullable String currentLine; + while ((currentLine = data.readLine()) != null) { + if ("[Script Info]".equalsIgnoreCase(currentLine)) { + parseScriptInfo(data); + } else if ("[V4+ Styles]".equalsIgnoreCase(currentLine)) { + styles = parseStyles(data); + } else if ("[V4 Styles]".equalsIgnoreCase(currentLine)) { + Log.i(TAG, "[V4 Styles] are not supported"); + } else if ("[Events]".equalsIgnoreCase(currentLine)) { + // We've reached the [Events] section, so the header is over. + return; + } + } + } + + /** + * Parse the {@code [Script Info]} section. + * + * <p>When this returns, {@code data.position} will be set to the beginning of the first line that + * starts with {@code [} (i.e. the title of the next section). + * + * @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition() position} + * set to the beginning of of the first line after {@code [Script Info]}. + */ + private void parseScriptInfo(ParsableByteArray data) { + @Nullable String currentLine; + while ((currentLine = data.readLine()) != null + && (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) { + String[] infoNameAndValue = currentLine.split(":"); + if (infoNameAndValue.length != 2) { + continue; + } + switch (Util.toLowerInvariant(infoNameAndValue[0].trim())) { + case "playresx": + try { + screenWidth = Float.parseFloat(infoNameAndValue[1].trim()); + } catch (NumberFormatException e) { + // Ignore invalid PlayResX value. + } + break; + case "playresy": + try { + screenHeight = Float.parseFloat(infoNameAndValue[1].trim()); + } catch (NumberFormatException e) { + // Ignore invalid PlayResY value. + } + break; + } + } + } + + /** + * Parse the {@code [V4+ Styles]} section. + * + * <p>When this returns, {@code data.position} will be set to the beginning of the first line that + * starts with {@code [} (i.e. the title of the next section). + * + * @param data A {@link ParsableByteArray} with {@link ParsableByteArray#getPosition()} pointing + * at the beginning of of the first line after {@code [V4+ Styles]}. + */ + private static Map<String, SsaStyle> parseStyles(ParsableByteArray data) { + Map<String, SsaStyle> styles = new LinkedHashMap<>(); + @Nullable SsaStyle.Format formatInfo = null; + @Nullable String currentLine; + while ((currentLine = data.readLine()) != null + && (data.bytesLeft() == 0 || data.peekUnsignedByte() != '[')) { + if (currentLine.startsWith(FORMAT_LINE_PREFIX)) { + formatInfo = SsaStyle.Format.fromFormatLine(currentLine); + } else if (currentLine.startsWith(STYLE_LINE_PREFIX)) { + if (formatInfo == null) { + Log.w(TAG, "Skipping 'Style:' line before 'Format:' line: " + currentLine); + continue; + } + @Nullable SsaStyle style = SsaStyle.fromStyleLine(currentLine, formatInfo); + if (style != null) { + styles.put(style.name, style); + } + } + } + return styles; + } + + /** + * Parses the event body of the subtitle. + * + * @param data A {@link ParsableByteArray} from which the body should be read. + * @param cues A list to which parsed cues will be added. + * @param cueTimesUs A sorted list to which parsed cue timestamps will be added. + */ + private void parseEventBody(ParsableByteArray data, List<List<Cue>> cues, List<Long> cueTimesUs) { + @Nullable + SsaDialogueFormat format = haveInitializationData ? dialogueFormatFromInitializationData : null; + @Nullable String currentLine; + while ((currentLine = data.readLine()) != null) { + if (currentLine.startsWith(FORMAT_LINE_PREFIX)) { + format = SsaDialogueFormat.fromFormatLine(currentLine); + } else if (currentLine.startsWith(DIALOGUE_LINE_PREFIX)) { + if (format == null) { + Log.w(TAG, "Skipping dialogue line before complete format: " + currentLine); + continue; + } + parseDialogueLine(currentLine, format, cues, cueTimesUs); + } + } + } + + /** + * Parses a dialogue line. + * + * @param dialogueLine The dialogue values (i.e. everything after {@code Dialogue:}). + * @param format The dialogue format to use when parsing {@code dialogueLine}. + * @param cues A list to which parsed cues will be added. + * @param cueTimesUs A sorted list to which parsed cue timestamps will be added. + */ + private void parseDialogueLine( + String dialogueLine, SsaDialogueFormat format, List<List<Cue>> cues, List<Long> cueTimesUs) { + Assertions.checkArgument(dialogueLine.startsWith(DIALOGUE_LINE_PREFIX)); + String[] lineValues = + dialogueLine.substring(DIALOGUE_LINE_PREFIX.length()).split(",", format.length); + if (lineValues.length != format.length) { + Log.w(TAG, "Skipping dialogue line with fewer columns than format: " + dialogueLine); + return; + } + + long startTimeUs = parseTimecodeUs(lineValues[format.startTimeIndex]); + if (startTimeUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping invalid timing: " + dialogueLine); + return; + } + + long endTimeUs = parseTimecodeUs(lineValues[format.endTimeIndex]); + if (endTimeUs == C.TIME_UNSET) { + Log.w(TAG, "Skipping invalid timing: " + dialogueLine); + return; + } + + @Nullable + SsaStyle style = + styles != null && format.styleIndex != C.INDEX_UNSET + ? styles.get(lineValues[format.styleIndex].trim()) + : null; + String rawText = lineValues[format.textIndex]; + SsaStyle.Overrides styleOverrides = SsaStyle.Overrides.parseFromDialogue(rawText); + String text = + SsaStyle.Overrides.stripStyleOverrides(rawText) + .replaceAll("\\\\N", "\n") + .replaceAll("\\\\n", "\n"); + Cue cue = createCue(text, style, styleOverrides, screenWidth, screenHeight); + + int startTimeIndex = addCuePlacerholderByTime(startTimeUs, cueTimesUs, cues); + int endTimeIndex = addCuePlacerholderByTime(endTimeUs, cueTimesUs, cues); + // Iterate on cues from startTimeIndex until endTimeIndex, adding the current cue. + for (int i = startTimeIndex; i < endTimeIndex; i++) { + cues.get(i).add(cue); + } + } + + /** + * Parses an SSA timecode string. + * + * @param timeString The string to parse. + * @return The parsed timestamp in microseconds. + */ + private static long parseTimecodeUs(String timeString) { + Matcher matcher = SSA_TIMECODE_PATTERN.matcher(timeString.trim()); + if (!matcher.matches()) { + return C.TIME_UNSET; + } + long timestampUs = + Long.parseLong(castNonNull(matcher.group(1))) * 60 * 60 * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(castNonNull(matcher.group(2))) * 60 * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(castNonNull(matcher.group(3))) * C.MICROS_PER_SECOND; + timestampUs += Long.parseLong(castNonNull(matcher.group(4))) * 10000; // 100ths of a second. + return timestampUs; + } + + private static Cue createCue( + String text, + @Nullable SsaStyle style, + SsaStyle.Overrides styleOverrides, + float screenWidth, + float screenHeight) { + @SsaStyle.SsaAlignment int alignment; + if (styleOverrides.alignment != SsaStyle.SSA_ALIGNMENT_UNKNOWN) { + alignment = styleOverrides.alignment; + } else if (style != null) { + alignment = style.alignment; + } else { + alignment = SsaStyle.SSA_ALIGNMENT_UNKNOWN; + } + @Cue.AnchorType int positionAnchor = toPositionAnchor(alignment); + @Cue.AnchorType int lineAnchor = toLineAnchor(alignment); + + float position; + float line; + if (styleOverrides.position != null + && screenHeight != Cue.DIMEN_UNSET + && screenWidth != Cue.DIMEN_UNSET) { + position = styleOverrides.position.x / screenWidth; + line = styleOverrides.position.y / screenHeight; + } else { + // TODO: Read the MarginL, MarginR and MarginV values from the Style & Dialogue lines. + position = computeDefaultLineOrPosition(positionAnchor); + line = computeDefaultLineOrPosition(lineAnchor); + } + + return new Cue( + text, + toTextAlignment(alignment), + line, + Cue.LINE_TYPE_FRACTION, + lineAnchor, + position, + positionAnchor, + /* size= */ Cue.DIMEN_UNSET); + } + + @Nullable + private static Layout.Alignment toTextAlignment(@SsaStyle.SsaAlignment int alignment) { + switch (alignment) { + case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + return Layout.Alignment.ALIGN_NORMAL; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + return Layout.Alignment.ALIGN_CENTER; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: + return Layout.Alignment.ALIGN_OPPOSITE; + case SsaStyle.SSA_ALIGNMENT_UNKNOWN: + return null; + default: + Log.w(TAG, "Unknown alignment: " + alignment); + return null; + } + } + + @Cue.AnchorType + private static int toLineAnchor(@SsaStyle.SsaAlignment int alignment) { + switch (alignment) { + case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + return Cue.ANCHOR_TYPE_END; + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + return Cue.ANCHOR_TYPE_MIDDLE; + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: + return Cue.ANCHOR_TYPE_START; + case SsaStyle.SSA_ALIGNMENT_UNKNOWN: + return Cue.TYPE_UNSET; + default: + Log.w(TAG, "Unknown alignment: " + alignment); + return Cue.TYPE_UNSET; + } + } + + @Cue.AnchorType + private static int toPositionAnchor(@SsaStyle.SsaAlignment int alignment) { + switch (alignment) { + case SsaStyle.SSA_ALIGNMENT_BOTTOM_LEFT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_LEFT: + case SsaStyle.SSA_ALIGNMENT_TOP_LEFT: + return Cue.ANCHOR_TYPE_START; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_CENTER: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_CENTER: + case SsaStyle.SSA_ALIGNMENT_TOP_CENTER: + return Cue.ANCHOR_TYPE_MIDDLE; + case SsaStyle.SSA_ALIGNMENT_BOTTOM_RIGHT: + case SsaStyle.SSA_ALIGNMENT_MIDDLE_RIGHT: + case SsaStyle.SSA_ALIGNMENT_TOP_RIGHT: + return Cue.ANCHOR_TYPE_END; + case SsaStyle.SSA_ALIGNMENT_UNKNOWN: + return Cue.TYPE_UNSET; + default: + Log.w(TAG, "Unknown alignment: " + alignment); + return Cue.TYPE_UNSET; + } + } + + private static float computeDefaultLineOrPosition(@Cue.AnchorType int anchor) { + switch (anchor) { + case Cue.ANCHOR_TYPE_START: + return DEFAULT_MARGIN; + case Cue.ANCHOR_TYPE_MIDDLE: + return 0.5f; + case Cue.ANCHOR_TYPE_END: + return 1.0f - DEFAULT_MARGIN; + case Cue.TYPE_UNSET: + default: + return Cue.DIMEN_UNSET; + } + } + + /** + * Searches for {@code timeUs} in {@code sortedCueTimesUs}, inserting it if it's not found, and + * returns the index. + * + * <p>If it's inserted, we also insert a matching entry to {@code cues}. + */ + private static int addCuePlacerholderByTime( + long timeUs, List<Long> sortedCueTimesUs, List<List<Cue>> cues) { + int insertionIndex = 0; + for (int i = sortedCueTimesUs.size() - 1; i >= 0; i--) { + if (sortedCueTimesUs.get(i) == timeUs) { + return i; + } + + if (sortedCueTimesUs.get(i) < timeUs) { + insertionIndex = i + 1; + break; + } + } + sortedCueTimesUs.add(insertionIndex, timeUs); + // Copy over cues from left, or use an empty list if we're inserting at the beginning. + cues.add( + insertionIndex, + insertionIndex == 0 ? new ArrayList<>() : new ArrayList<>(cues.get(insertionIndex - 1))); + return insertionIndex; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java new file mode 100644 index 0000000000..312c779e23 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaDialogueFormat.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder.FORMAT_LINE_PREFIX; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Represents a {@code Format:} line from the {@code [Events]} section + * + * <p>The indices are used to determine the location of particular properties in each {@code + * Dialogue:} line. + */ +/* package */ final class SsaDialogueFormat { + + public final int startTimeIndex; + public final int endTimeIndex; + public final int styleIndex; + public final int textIndex; + public final int length; + + private SsaDialogueFormat( + int startTimeIndex, int endTimeIndex, int styleIndex, int textIndex, int length) { + this.startTimeIndex = startTimeIndex; + this.endTimeIndex = endTimeIndex; + this.styleIndex = styleIndex; + this.textIndex = textIndex; + this.length = length; + } + + /** + * Parses the format info from a 'Format:' line in the [Events] section. + * + * @return the parsed info, or null if {@code formatLine} doesn't contain both 'start' and 'end'. + */ + @Nullable + public static SsaDialogueFormat fromFormatLine(String formatLine) { + int startTimeIndex = C.INDEX_UNSET; + int endTimeIndex = C.INDEX_UNSET; + int styleIndex = C.INDEX_UNSET; + int textIndex = C.INDEX_UNSET; + Assertions.checkArgument(formatLine.startsWith(FORMAT_LINE_PREFIX)); + String[] keys = TextUtils.split(formatLine.substring(FORMAT_LINE_PREFIX.length()), ","); + for (int i = 0; i < keys.length; i++) { + switch (Util.toLowerInvariant(keys[i].trim())) { + case "start": + startTimeIndex = i; + break; + case "end": + endTimeIndex = i; + break; + case "style": + styleIndex = i; + break; + case "text": + textIndex = i; + break; + } + } + return (startTimeIndex != C.INDEX_UNSET && endTimeIndex != C.INDEX_UNSET) + ? new SsaDialogueFormat(startTimeIndex, endTimeIndex, styleIndex, textIndex, keys.length) + : null; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java new file mode 100644 index 0000000000..3c3639a3fb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaStyle.java @@ -0,0 +1,301 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa.SsaDecoder.STYLE_LINE_PREFIX; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.graphics.PointF; +import android.text.TextUtils; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Represents a line from an SSA/ASS {@code [V4+ Styles]} section. */ +/* package */ final class SsaStyle { + + private static final String TAG = "SsaStyle"; + + /** + * The SSA/ASS alignments. + * + * <p>Allowed values: + * + * <ul> + * <li>{@link #SSA_ALIGNMENT_UNKNOWN} + * <li>{@link #SSA_ALIGNMENT_BOTTOM_LEFT} + * <li>{@link #SSA_ALIGNMENT_BOTTOM_CENTER} + * <li>{@link #SSA_ALIGNMENT_BOTTOM_RIGHT} + * <li>{@link #SSA_ALIGNMENT_MIDDLE_LEFT} + * <li>{@link #SSA_ALIGNMENT_MIDDLE_CENTER} + * <li>{@link #SSA_ALIGNMENT_MIDDLE_RIGHT} + * <li>{@link #SSA_ALIGNMENT_TOP_LEFT} + * <li>{@link #SSA_ALIGNMENT_TOP_CENTER} + * <li>{@link #SSA_ALIGNMENT_TOP_RIGHT} + * </ul> + */ + @IntDef({ + SSA_ALIGNMENT_UNKNOWN, + SSA_ALIGNMENT_BOTTOM_LEFT, + SSA_ALIGNMENT_BOTTOM_CENTER, + SSA_ALIGNMENT_BOTTOM_RIGHT, + SSA_ALIGNMENT_MIDDLE_LEFT, + SSA_ALIGNMENT_MIDDLE_CENTER, + SSA_ALIGNMENT_MIDDLE_RIGHT, + SSA_ALIGNMENT_TOP_LEFT, + SSA_ALIGNMENT_TOP_CENTER, + SSA_ALIGNMENT_TOP_RIGHT, + }) + @Documented + @Retention(SOURCE) + public @interface SsaAlignment {} + + // The numbering follows the ASS (v4+) spec (i.e. the points on the number pad). + public static final int SSA_ALIGNMENT_UNKNOWN = -1; + public static final int SSA_ALIGNMENT_BOTTOM_LEFT = 1; + public static final int SSA_ALIGNMENT_BOTTOM_CENTER = 2; + public static final int SSA_ALIGNMENT_BOTTOM_RIGHT = 3; + public static final int SSA_ALIGNMENT_MIDDLE_LEFT = 4; + public static final int SSA_ALIGNMENT_MIDDLE_CENTER = 5; + public static final int SSA_ALIGNMENT_MIDDLE_RIGHT = 6; + public static final int SSA_ALIGNMENT_TOP_LEFT = 7; + public static final int SSA_ALIGNMENT_TOP_CENTER = 8; + public static final int SSA_ALIGNMENT_TOP_RIGHT = 9; + + public final String name; + @SsaAlignment public final int alignment; + + private SsaStyle(String name, @SsaAlignment int alignment) { + this.name = name; + this.alignment = alignment; + } + + @Nullable + public static SsaStyle fromStyleLine(String styleLine, Format format) { + Assertions.checkArgument(styleLine.startsWith(STYLE_LINE_PREFIX)); + String[] styleValues = TextUtils.split(styleLine.substring(STYLE_LINE_PREFIX.length()), ","); + if (styleValues.length != format.length) { + Log.w( + TAG, + Util.formatInvariant( + "Skipping malformed 'Style:' line (expected %s values, found %s): '%s'", + format.length, styleValues.length, styleLine)); + return null; + } + try { + return new SsaStyle( + styleValues[format.nameIndex].trim(), parseAlignment(styleValues[format.alignmentIndex])); + } catch (RuntimeException e) { + Log.w(TAG, "Skipping malformed 'Style:' line: '" + styleLine + "'", e); + return null; + } + } + + @SsaAlignment + private static int parseAlignment(String alignmentStr) { + try { + @SsaAlignment int alignment = Integer.parseInt(alignmentStr.trim()); + if (isValidAlignment(alignment)) { + return alignment; + } + } catch (NumberFormatException e) { + // Swallow the exception and return UNKNOWN below. + } + Log.w(TAG, "Ignoring unknown alignment: " + alignmentStr); + return SSA_ALIGNMENT_UNKNOWN; + } + + private static boolean isValidAlignment(@SsaAlignment int alignment) { + switch (alignment) { + case SSA_ALIGNMENT_BOTTOM_CENTER: + case SSA_ALIGNMENT_BOTTOM_LEFT: + case SSA_ALIGNMENT_BOTTOM_RIGHT: + case SSA_ALIGNMENT_MIDDLE_CENTER: + case SSA_ALIGNMENT_MIDDLE_LEFT: + case SSA_ALIGNMENT_MIDDLE_RIGHT: + case SSA_ALIGNMENT_TOP_CENTER: + case SSA_ALIGNMENT_TOP_LEFT: + case SSA_ALIGNMENT_TOP_RIGHT: + return true; + case SSA_ALIGNMENT_UNKNOWN: + default: + return false; + } + } + + /** + * Represents a {@code Format:} line from the {@code [V4+ Styles]} section + * + * <p>The indices are used to determine the location of particular properties in each {@code + * Style:} line. + */ + /* package */ static final class Format { + + public final int nameIndex; + public final int alignmentIndex; + public final int length; + + private Format(int nameIndex, int alignmentIndex, int length) { + this.nameIndex = nameIndex; + this.alignmentIndex = alignmentIndex; + this.length = length; + } + + /** + * Parses the format info from a 'Format:' line in the [V4+ Styles] section. + * + * @return the parsed info, or null if {@code styleFormatLine} doesn't contain 'name'. + */ + @Nullable + public static Format fromFormatLine(String styleFormatLine) { + int nameIndex = C.INDEX_UNSET; + int alignmentIndex = C.INDEX_UNSET; + String[] keys = + TextUtils.split(styleFormatLine.substring(SsaDecoder.FORMAT_LINE_PREFIX.length()), ","); + for (int i = 0; i < keys.length; i++) { + switch (Util.toLowerInvariant(keys[i].trim())) { + case "name": + nameIndex = i; + break; + case "alignment": + alignmentIndex = i; + break; + } + } + return nameIndex != C.INDEX_UNSET ? new Format(nameIndex, alignmentIndex, keys.length) : null; + } + } + + /** + * Represents the style override information parsed from an SSA/ASS dialogue line. + * + * <p>Overrides are contained in braces embedded in the dialogue text of the cue. + */ + /* package */ static final class Overrides { + + private static final String TAG = "SsaStyle.Overrides"; + + /** Matches "{foo}" and returns "foo" in group 1 */ + // Warning that \\} can be replaced with } is bogus [internal: b/144480183]. + private static final Pattern BRACES_PATTERN = Pattern.compile("\\{([^}]*)\\}"); + + private static final String PADDED_DECIMAL_PATTERN = "\\s*\\d+(?:\\.\\d+)?\\s*"; + + /** Matches "\pos(x,y)" and returns "x" in group 1 and "y" in group 2 */ + private static final Pattern POSITION_PATTERN = + Pattern.compile(Util.formatInvariant("\\\\pos\\((%1$s),(%1$s)\\)", PADDED_DECIMAL_PATTERN)); + /** Matches "\move(x1,y1,x2,y2[,t1,t2])" and returns "x2" in group 1 and "y2" in group 2 */ + private static final Pattern MOVE_PATTERN = + Pattern.compile( + Util.formatInvariant( + "\\\\move\\(%1$s,%1$s,(%1$s),(%1$s)(?:,%1$s,%1$s)?\\)", PADDED_DECIMAL_PATTERN)); + + /** Matches "\anx" and returns x in group 1 */ + private static final Pattern ALIGNMENT_OVERRIDE_PATTERN = Pattern.compile("\\\\an(\\d+)"); + + @SsaAlignment public final int alignment; + @Nullable public final PointF position; + + private Overrides(@SsaAlignment int alignment, @Nullable PointF position) { + this.alignment = alignment; + this.position = position; + } + + public static Overrides parseFromDialogue(String text) { + @SsaAlignment int alignment = SSA_ALIGNMENT_UNKNOWN; + PointF position = null; + Matcher matcher = BRACES_PATTERN.matcher(text); + while (matcher.find()) { + String braceContents = matcher.group(1); + try { + PointF parsedPosition = parsePosition(braceContents); + if (parsedPosition != null) { + position = parsedPosition; + } + } catch (RuntimeException e) { + // Ignore invalid \pos() or \move() function. + } + try { + @SsaAlignment int parsedAlignment = parseAlignmentOverride(braceContents); + if (parsedAlignment != SSA_ALIGNMENT_UNKNOWN) { + alignment = parsedAlignment; + } + } catch (RuntimeException e) { + // Ignore invalid \an alignment override. + } + } + return new Overrides(alignment, position); + } + + public static String stripStyleOverrides(String dialogueLine) { + return BRACES_PATTERN.matcher(dialogueLine).replaceAll(""); + } + + /** + * Parses the position from a style override, returns null if no position is found. + * + * <p>The attribute is expected to be in the form {@code \pos(x,y)} or {@code + * \move(x1,y1,x2,y2,startTime,endTime)} (startTime and endTime are optional). In the case of + * {@code \move()}, this returns {@code (x2, y2)} (i.e. the end position of the move). + * + * @param styleOverride The string to parse. + * @return The parsed position, or null if no position is found. + */ + @Nullable + private static PointF parsePosition(String styleOverride) { + Matcher positionMatcher = POSITION_PATTERN.matcher(styleOverride); + Matcher moveMatcher = MOVE_PATTERN.matcher(styleOverride); + boolean hasPosition = positionMatcher.find(); + boolean hasMove = moveMatcher.find(); + + String x; + String y; + if (hasPosition) { + if (hasMove) { + Log.i( + TAG, + "Override has both \\pos(x,y) and \\move(x1,y1,x2,y2); using \\pos values. override='" + + styleOverride + + "'"); + } + x = positionMatcher.group(1); + y = positionMatcher.group(2); + } else if (hasMove) { + x = moveMatcher.group(1); + y = moveMatcher.group(2); + } else { + return null; + } + return new PointF( + Float.parseFloat(Assertions.checkNotNull(x).trim()), + Float.parseFloat(Assertions.checkNotNull(y).trim())); + } + + @SsaAlignment + private static int parseAlignmentOverride(String braceContents) { + Matcher matcher = ALIGNMENT_OVERRIDE_PATTERN.matcher(braceContents); + return matcher.find() ? parseAlignment(matcher.group(1)) : SSA_ALIGNMENT_UNKNOWN; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java new file mode 100644 index 0000000000..fb0544156d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/SsaSubtitle.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Collections; +import java.util.List; + +/** + * A representation of an SSA/ASS subtitle. + */ +/* package */ final class SsaSubtitle implements Subtitle { + + private final List<List<Cue>> cues; + private final List<Long> cueTimesUs; + + /** + * @param cues The cues in the subtitle. + * @param cueTimesUs The cue times, in microseconds. + */ + public SsaSubtitle(List<List<Cue>> cues, List<Long> cueTimesUs) { + this.cues = cues; + this.cueTimesUs = cueTimesUs; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false); + return index < cueTimesUs.size() ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return cueTimesUs.size(); + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < cueTimesUs.size()); + return cueTimesUs.get(index); + } + + @Override + public List<Cue> getCues(long timeUs) { + int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); + if (index == -1) { + // timeUs is earlier than the start of the first cue. + return Collections.emptyList(); + } else { + return cues.get(index); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java new file mode 100644 index 0000000000..bc4b625d77 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ssa/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ssa; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java new file mode 100644 index 0000000000..36ebf6ead0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripDecoder.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip; + +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.LongArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A {@link SimpleSubtitleDecoder} for SubRip. + */ +public final class SubripDecoder extends SimpleSubtitleDecoder { + + // Fractional positions for use when alignment tags are present. + private static final float START_FRACTION = 0.08f; + private static final float END_FRACTION = 1 - START_FRACTION; + private static final float MID_FRACTION = 0.5f; + + private static final String TAG = "SubripDecoder"; + + // Some SRT files don't include hours or milliseconds in the timecode, so we use optional groups. + private static final String SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:,(\\d+))?"; + private static final Pattern SUBRIP_TIMING_LINE = + Pattern.compile("\\s*(" + SUBRIP_TIMECODE + ")\\s*-->\\s*(" + SUBRIP_TIMECODE + ")\\s*"); + + // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. + private static final Pattern SUBRIP_TAG_PATTERN = Pattern.compile("\\{\\\\.*?\\}"); + private static final String SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}"; + + // Alignment tags for SSA V4+. + private static final String ALIGN_BOTTOM_LEFT = "{\\an1}"; + private static final String ALIGN_BOTTOM_MID = "{\\an2}"; + private static final String ALIGN_BOTTOM_RIGHT = "{\\an3}"; + private static final String ALIGN_MID_LEFT = "{\\an4}"; + private static final String ALIGN_MID_MID = "{\\an5}"; + private static final String ALIGN_MID_RIGHT = "{\\an6}"; + private static final String ALIGN_TOP_LEFT = "{\\an7}"; + private static final String ALIGN_TOP_MID = "{\\an8}"; + private static final String ALIGN_TOP_RIGHT = "{\\an9}"; + + private final StringBuilder textBuilder; + private final ArrayList<String> tags; + + public SubripDecoder() { + super("SubripDecoder"); + textBuilder = new StringBuilder(); + tags = new ArrayList<>(); + } + + @Override + protected Subtitle decode(byte[] bytes, int length, boolean reset) { + ArrayList<Cue> cues = new ArrayList<>(); + LongArray cueTimesUs = new LongArray(); + ParsableByteArray subripData = new ParsableByteArray(bytes, length); + + @Nullable String currentLine; + while ((currentLine = subripData.readLine()) != null) { + if (currentLine.length() == 0) { + // Skip blank lines. + continue; + } + + // Parse the index line as a sanity check. + try { + Integer.parseInt(currentLine); + } catch (NumberFormatException e) { + Log.w(TAG, "Skipping invalid index: " + currentLine); + continue; + } + + // Read and parse the timing line. + currentLine = subripData.readLine(); + if (currentLine == null) { + Log.w(TAG, "Unexpected end"); + break; + } + + Matcher matcher = SUBRIP_TIMING_LINE.matcher(currentLine); + if (matcher.matches()) { + cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 1)); + cueTimesUs.add(parseTimecode(matcher, /* groupOffset= */ 6)); + } else { + Log.w(TAG, "Skipping invalid timing: " + currentLine); + continue; + } + + // Read and parse the text and tags. + textBuilder.setLength(0); + tags.clear(); + currentLine = subripData.readLine(); + while (!TextUtils.isEmpty(currentLine)) { + if (textBuilder.length() > 0) { + textBuilder.append("<br>"); + } + textBuilder.append(processLine(currentLine, tags)); + currentLine = subripData.readLine(); + } + + Spanned text = Html.fromHtml(textBuilder.toString()); + + @Nullable String alignmentTag = null; + for (int i = 0; i < tags.size(); i++) { + String tag = tags.get(i); + if (tag.matches(SUBRIP_ALIGNMENT_TAG)) { + alignmentTag = tag; + // Subsequent alignment tags should be ignored. + break; + } + } + cues.add(buildCue(text, alignmentTag)); + cues.add(Cue.EMPTY); + } + + Cue[] cuesArray = new Cue[cues.size()]; + cues.toArray(cuesArray); + long[] cueTimesUsArray = cueTimesUs.toArray(); + return new SubripSubtitle(cuesArray, cueTimesUsArray); + } + + /** + * Trims and removes tags from the given line. The removed tags are added to {@code tags}. + * + * @param line The line to process. + * @param tags A list to which removed tags will be added. + * @return The processed line. + */ + private String processLine(String line, ArrayList<String> tags) { + line = line.trim(); + + int removedCharacterCount = 0; + StringBuilder processedLine = new StringBuilder(line); + Matcher matcher = SUBRIP_TAG_PATTERN.matcher(line); + while (matcher.find()) { + String tag = matcher.group(); + tags.add(tag); + int start = matcher.start() - removedCharacterCount; + int tagLength = tag.length(); + processedLine.replace(start, /* end= */ start + tagLength, /* str= */ ""); + removedCharacterCount += tagLength; + } + + return processedLine.toString(); + } + + /** + * Build a {@link Cue} based on the given text and alignment tag. + * + * @param text The text. + * @param alignmentTag The alignment tag, or {@code null} if no alignment tag is available. + * @return Built cue + */ + private Cue buildCue(Spanned text, @Nullable String alignmentTag) { + if (alignmentTag == null) { + return new Cue(text); + } + + // Horizontal alignment. + @Cue.AnchorType int positionAnchor; + switch (alignmentTag) { + case ALIGN_BOTTOM_LEFT: + case ALIGN_MID_LEFT: + case ALIGN_TOP_LEFT: + positionAnchor = Cue.ANCHOR_TYPE_START; + break; + case ALIGN_BOTTOM_RIGHT: + case ALIGN_MID_RIGHT: + case ALIGN_TOP_RIGHT: + positionAnchor = Cue.ANCHOR_TYPE_END; + break; + case ALIGN_BOTTOM_MID: + case ALIGN_MID_MID: + case ALIGN_TOP_MID: + default: + positionAnchor = Cue.ANCHOR_TYPE_MIDDLE; + break; + } + + // Vertical alignment. + @Cue.AnchorType int lineAnchor; + switch (alignmentTag) { + case ALIGN_BOTTOM_LEFT: + case ALIGN_BOTTOM_MID: + case ALIGN_BOTTOM_RIGHT: + lineAnchor = Cue.ANCHOR_TYPE_END; + break; + case ALIGN_TOP_LEFT: + case ALIGN_TOP_MID: + case ALIGN_TOP_RIGHT: + lineAnchor = Cue.ANCHOR_TYPE_START; + break; + case ALIGN_MID_LEFT: + case ALIGN_MID_MID: + case ALIGN_MID_RIGHT: + default: + lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; + break; + } + + return new Cue( + text, + /* textAlignment= */ null, + getFractionalPositionForAnchorType(lineAnchor), + Cue.LINE_TYPE_FRACTION, + lineAnchor, + getFractionalPositionForAnchorType(positionAnchor), + positionAnchor, + Cue.DIMEN_UNSET); + } + + private static long parseTimecode(Matcher matcher, int groupOffset) { + @Nullable String hours = matcher.group(groupOffset + 1); + long timestampMs = hours != null ? Long.parseLong(hours) * 60 * 60 * 1000 : 0; + timestampMs += Long.parseLong(matcher.group(groupOffset + 2)) * 60 * 1000; + timestampMs += Long.parseLong(matcher.group(groupOffset + 3)) * 1000; + @Nullable String millis = matcher.group(groupOffset + 4); + if (millis != null) { + timestampMs += Long.parseLong(millis); + } + return timestampMs * 1000; + } + + /* package */ static float getFractionalPositionForAnchorType(@Cue.AnchorType int anchorType) { + switch (anchorType) { + case Cue.ANCHOR_TYPE_START: + return SubripDecoder.START_FRACTION; + case Cue.ANCHOR_TYPE_MIDDLE: + return SubripDecoder.MID_FRACTION; + case Cue.ANCHOR_TYPE_END: + return SubripDecoder.END_FRACTION; + case Cue.TYPE_UNSET: + default: + // Should never happen. + throw new IllegalArgumentException(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java new file mode 100644 index 0000000000..d011f5d7c5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/SubripSubtitle.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Collections; +import java.util.List; + +/** + * A representation of a SubRip subtitle. + */ +/* package */ final class SubripSubtitle implements Subtitle { + + private final Cue[] cues; + private final long[] cueTimesUs; + + /** + * @param cues The cues in the subtitle. + * @param cueTimesUs The cue times, in microseconds. + */ + public SubripSubtitle(Cue[] cues, long[] cueTimesUs) { + this.cues = cues; + this.cueTimesUs = cueTimesUs; + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(cueTimesUs, timeUs, false, false); + return index < cueTimesUs.length ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return cueTimesUs.length; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < cueTimesUs.length); + return cueTimesUs[index]; + } + + @Override + public List<Cue> getCues(long timeUs) { + int index = Util.binarySearchFloor(cueTimesUs, timeUs, true, false); + if (index == -1 || cues[index] == Cue.EMPTY) { + // timeUs is earlier than the start of the first cue, or we have an empty cue. + return Collections.emptyList(); + } else { + return Collections.singletonList(cues[index]); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java new file mode 100644 index 0000000000..fb990cb748 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/subrip/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.subrip; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java new file mode 100644 index 0000000000..502281c2de --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlDecoder.java @@ -0,0 +1,756 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import android.text.Layout; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ColorParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.XmlPullParserUtil; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; +import org.xmlpull.v1.XmlPullParserFactory; + +/** + * A {@link SimpleSubtitleDecoder} for TTML supporting the DFXP presentation profile. Features + * supported by this decoder are: + * + * <ul> + * <li>content + * <li>core + * <li>presentation + * <li>profile + * <li>structure + * <li>time-offset + * <li>timing + * <li>tickRate + * <li>time-clock-with-frames + * <li>time-clock + * <li>time-offset-with-frames + * <li>time-offset-with-ticks + * <li>cell-resolution + * </ul> + * + * @see <a href="http://www.w3.org/TR/ttaf1-dfxp/">TTML specification</a> + */ +public final class TtmlDecoder extends SimpleSubtitleDecoder { + + private static final String TAG = "TtmlDecoder"; + + private static final String TTP = "http://www.w3.org/ns/ttml#parameter"; + + private static final String ATTR_BEGIN = "begin"; + private static final String ATTR_DURATION = "dur"; + private static final String ATTR_END = "end"; + private static final String ATTR_STYLE = "style"; + private static final String ATTR_REGION = "region"; + private static final String ATTR_IMAGE = "backgroundImage"; + + private static final Pattern CLOCK_TIME = + Pattern.compile("^([0-9][0-9]+):([0-9][0-9]):([0-9][0-9])" + + "(?:(\\.[0-9]+)|:([0-9][0-9])(?:\\.([0-9]+))?)?$"); + private static final Pattern OFFSET_TIME = + Pattern.compile("^([0-9]+(?:\\.[0-9]+)?)(h|m|s|ms|f|t)$"); + private static final Pattern FONT_SIZE = Pattern.compile("^(([0-9]*.)?[0-9]+)(px|em|%)$"); + private static final Pattern PERCENTAGE_COORDINATES = + Pattern.compile("^(\\d+\\.?\\d*?)% (\\d+\\.?\\d*?)%$"); + private static final Pattern PIXEL_COORDINATES = + Pattern.compile("^(\\d+\\.?\\d*?)px (\\d+\\.?\\d*?)px$"); + private static final Pattern CELL_RESOLUTION = Pattern.compile("^(\\d+) (\\d+)$"); + + private static final int DEFAULT_FRAME_RATE = 30; + + private static final FrameAndTickRate DEFAULT_FRAME_AND_TICK_RATE = + new FrameAndTickRate(DEFAULT_FRAME_RATE, 1, 1); + private static final CellResolution DEFAULT_CELL_RESOLUTION = + new CellResolution(/* columns= */ 32, /* rows= */ 15); + + private final XmlPullParserFactory xmlParserFactory; + + public TtmlDecoder() { + super("TtmlDecoder"); + try { + xmlParserFactory = XmlPullParserFactory.newInstance(); + xmlParserFactory.setNamespaceAware(true); + } catch (XmlPullParserException e) { + throw new RuntimeException("Couldn't create XmlPullParserFactory instance", e); + } + } + + @Override + protected Subtitle decode(byte[] bytes, int length, boolean reset) + throws SubtitleDecoderException { + try { + XmlPullParser xmlParser = xmlParserFactory.newPullParser(); + Map<String, TtmlStyle> globalStyles = new HashMap<>(); + Map<String, TtmlRegion> regionMap = new HashMap<>(); + Map<String, String> imageMap = new HashMap<>(); + regionMap.put(TtmlNode.ANONYMOUS_REGION_ID, new TtmlRegion(null)); + ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes, 0, length); + xmlParser.setInput(inputStream, null); + TtmlSubtitle ttmlSubtitle = null; + ArrayDeque<TtmlNode> nodeStack = new ArrayDeque<>(); + int unsupportedNodeDepth = 0; + int eventType = xmlParser.getEventType(); + FrameAndTickRate frameAndTickRate = DEFAULT_FRAME_AND_TICK_RATE; + CellResolution cellResolution = DEFAULT_CELL_RESOLUTION; + TtsExtent ttsExtent = null; + while (eventType != XmlPullParser.END_DOCUMENT) { + TtmlNode parent = nodeStack.peek(); + if (unsupportedNodeDepth == 0) { + String name = xmlParser.getName(); + if (eventType == XmlPullParser.START_TAG) { + if (TtmlNode.TAG_TT.equals(name)) { + frameAndTickRate = parseFrameAndTickRates(xmlParser); + cellResolution = parseCellResolution(xmlParser, DEFAULT_CELL_RESOLUTION); + ttsExtent = parseTtsExtent(xmlParser); + } + if (!isSupportedTag(name)) { + Log.i(TAG, "Ignoring unsupported tag: " + xmlParser.getName()); + unsupportedNodeDepth++; + } else if (TtmlNode.TAG_HEAD.equals(name)) { + parseHeader(xmlParser, globalStyles, cellResolution, ttsExtent, regionMap, imageMap); + } else { + try { + TtmlNode node = parseNode(xmlParser, parent, regionMap, frameAndTickRate); + nodeStack.push(node); + if (parent != null) { + parent.addChild(node); + } + } catch (SubtitleDecoderException e) { + Log.w(TAG, "Suppressing parser error", e); + // Treat the node (and by extension, all of its children) as unsupported. + unsupportedNodeDepth++; + } + } + } else if (eventType == XmlPullParser.TEXT) { + parent.addChild(TtmlNode.buildTextNode(xmlParser.getText())); + } else if (eventType == XmlPullParser.END_TAG) { + if (xmlParser.getName().equals(TtmlNode.TAG_TT)) { + ttmlSubtitle = new TtmlSubtitle(nodeStack.peek(), globalStyles, regionMap, imageMap); + } + nodeStack.pop(); + } + } else { + if (eventType == XmlPullParser.START_TAG) { + unsupportedNodeDepth++; + } else if (eventType == XmlPullParser.END_TAG) { + unsupportedNodeDepth--; + } + } + xmlParser.next(); + eventType = xmlParser.getEventType(); + } + return ttmlSubtitle; + } catch (XmlPullParserException xppe) { + throw new SubtitleDecoderException("Unable to decode source", xppe); + } catch (IOException e) { + throw new IllegalStateException("Unexpected error when reading input.", e); + } + } + + private FrameAndTickRate parseFrameAndTickRates(XmlPullParser xmlParser) + throws SubtitleDecoderException { + int frameRate = DEFAULT_FRAME_RATE; + String frameRateString = xmlParser.getAttributeValue(TTP, "frameRate"); + if (frameRateString != null) { + frameRate = Integer.parseInt(frameRateString); + } + + float frameRateMultiplier = 1; + String frameRateMultiplierString = xmlParser.getAttributeValue(TTP, "frameRateMultiplier"); + if (frameRateMultiplierString != null) { + String[] parts = Util.split(frameRateMultiplierString, " "); + if (parts.length != 2) { + throw new SubtitleDecoderException("frameRateMultiplier doesn't have 2 parts"); + } + float numerator = Integer.parseInt(parts[0]); + float denominator = Integer.parseInt(parts[1]); + frameRateMultiplier = numerator / denominator; + } + + int subFrameRate = DEFAULT_FRAME_AND_TICK_RATE.subFrameRate; + String subFrameRateString = xmlParser.getAttributeValue(TTP, "subFrameRate"); + if (subFrameRateString != null) { + subFrameRate = Integer.parseInt(subFrameRateString); + } + + int tickRate = DEFAULT_FRAME_AND_TICK_RATE.tickRate; + String tickRateString = xmlParser.getAttributeValue(TTP, "tickRate"); + if (tickRateString != null) { + tickRate = Integer.parseInt(tickRateString); + } + return new FrameAndTickRate(frameRate * frameRateMultiplier, subFrameRate, tickRate); + } + + private CellResolution parseCellResolution(XmlPullParser xmlParser, CellResolution defaultValue) + throws SubtitleDecoderException { + String cellResolution = xmlParser.getAttributeValue(TTP, "cellResolution"); + if (cellResolution == null) { + return defaultValue; + } + + Matcher cellResolutionMatcher = CELL_RESOLUTION.matcher(cellResolution); + if (!cellResolutionMatcher.matches()) { + Log.w(TAG, "Ignoring malformed cell resolution: " + cellResolution); + return defaultValue; + } + try { + int columns = Integer.parseInt(cellResolutionMatcher.group(1)); + int rows = Integer.parseInt(cellResolutionMatcher.group(2)); + if (columns == 0 || rows == 0) { + throw new SubtitleDecoderException("Invalid cell resolution " + columns + " " + rows); + } + return new CellResolution(columns, rows); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed cell resolution: " + cellResolution); + return defaultValue; + } + } + + private TtsExtent parseTtsExtent(XmlPullParser xmlParser) { + String ttsExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); + if (ttsExtent == null) { + return null; + } + + Matcher extentMatcher = PIXEL_COORDINATES.matcher(ttsExtent); + if (!extentMatcher.matches()) { + Log.w(TAG, "Ignoring non-pixel tts extent: " + ttsExtent); + return null; + } + try { + int width = Integer.parseInt(extentMatcher.group(1)); + int height = Integer.parseInt(extentMatcher.group(2)); + return new TtsExtent(width, height); + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring malformed tts extent: " + ttsExtent); + return null; + } + } + + private Map<String, TtmlStyle> parseHeader( + XmlPullParser xmlParser, + Map<String, TtmlStyle> globalStyles, + CellResolution cellResolution, + TtsExtent ttsExtent, + Map<String, TtmlRegion> globalRegions, + Map<String, String> imageMap) + throws IOException, XmlPullParserException { + do { + xmlParser.next(); + if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_STYLE)) { + String parentStyleId = XmlPullParserUtil.getAttributeValue(xmlParser, ATTR_STYLE); + TtmlStyle style = parseStyleAttributes(xmlParser, new TtmlStyle()); + if (parentStyleId != null) { + for (String id : parseStyleIds(parentStyleId)) { + style.chain(globalStyles.get(id)); + } + } + if (style.getId() != null) { + globalStyles.put(style.getId(), style); + } + } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_REGION)) { + TtmlRegion ttmlRegion = parseRegionAttributes(xmlParser, cellResolution, ttsExtent); + if (ttmlRegion != null) { + globalRegions.put(ttmlRegion.id, ttmlRegion); + } + } else if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_METADATA)) { + parseMetadata(xmlParser, imageMap); + } + } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_HEAD)); + return globalStyles; + } + + private void parseMetadata(XmlPullParser xmlParser, Map<String, String> imageMap) + throws IOException, XmlPullParserException { + do { + xmlParser.next(); + if (XmlPullParserUtil.isStartTag(xmlParser, TtmlNode.TAG_IMAGE)) { + String id = XmlPullParserUtil.getAttributeValue(xmlParser, "id"); + if (id != null) { + String encodedBitmapData = xmlParser.nextText(); + imageMap.put(id, encodedBitmapData); + } + } + } while (!XmlPullParserUtil.isEndTag(xmlParser, TtmlNode.TAG_METADATA)); + } + + /** + * Parses a region declaration. + * + * <p>Supports both percentage and pixel defined regions. In case of pixel defined regions the + * passed {@code ttsExtent} is used as a reference window to convert the pixel values to + * fractions. In case of missing tts:extent the pixel defined regions can't be parsed, and null is + * returned. + */ + private TtmlRegion parseRegionAttributes( + XmlPullParser xmlParser, CellResolution cellResolution, TtsExtent ttsExtent) { + String regionId = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_ID); + if (regionId == null) { + return null; + } + + float position; + float line; + + String regionOrigin = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_ORIGIN); + if (regionOrigin != null) { + Matcher originPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionOrigin); + Matcher originPixelMatcher = PIXEL_COORDINATES.matcher(regionOrigin); + if (originPercentageMatcher.matches()) { + try { + position = Float.parseFloat(originPercentageMatcher.group(1)) / 100f; + line = Float.parseFloat(originPercentageMatcher.group(2)) / 100f; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); + return null; + } + } else if (originPixelMatcher.matches()) { + if (ttsExtent == null) { + Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); + return null; + } + try { + int width = Integer.parseInt(originPixelMatcher.group(1)); + int height = Integer.parseInt(originPixelMatcher.group(2)); + // Convert pixel values to fractions. + position = width / (float) ttsExtent.width; + line = height / (float) ttsExtent.height; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed origin: " + regionOrigin); + return null; + } + } else { + Log.w(TAG, "Ignoring region with unsupported origin: " + regionOrigin); + return null; + } + } else { + Log.w(TAG, "Ignoring region without an origin"); + return null; + // TODO: Should default to top left as below in this case, but need to fix + // https://github.com/google/ExoPlayer/issues/2953 first. + // Origin is omitted. Default to top left. + // position = 0; + // line = 0; + } + + float width; + float height; + String regionExtent = XmlPullParserUtil.getAttributeValue(xmlParser, TtmlNode.ATTR_TTS_EXTENT); + if (regionExtent != null) { + Matcher extentPercentageMatcher = PERCENTAGE_COORDINATES.matcher(regionExtent); + Matcher extentPixelMatcher = PIXEL_COORDINATES.matcher(regionExtent); + if (extentPercentageMatcher.matches()) { + try { + width = Float.parseFloat(extentPercentageMatcher.group(1)) / 100f; + height = Float.parseFloat(extentPercentageMatcher.group(2)) / 100f; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); + return null; + } + } else if (extentPixelMatcher.matches()) { + if (ttsExtent == null) { + Log.w(TAG, "Ignoring region with missing tts:extent: " + regionOrigin); + return null; + } + try { + int extentWidth = Integer.parseInt(extentPixelMatcher.group(1)); + int extentHeight = Integer.parseInt(extentPixelMatcher.group(2)); + // Convert pixel values to fractions. + width = extentWidth / (float) ttsExtent.width; + height = extentHeight / (float) ttsExtent.height; + } catch (NumberFormatException e) { + Log.w(TAG, "Ignoring region with malformed extent: " + regionOrigin); + return null; + } + } else { + Log.w(TAG, "Ignoring region with unsupported extent: " + regionOrigin); + return null; + } + } else { + Log.w(TAG, "Ignoring region without an extent"); + return null; + // TODO: Should default to extent of parent as below in this case, but need to fix + // https://github.com/google/ExoPlayer/issues/2953 first. + // Extent is omitted. Default to extent of parent. + // width = 1; + // height = 1; + } + + @Cue.AnchorType int lineAnchor = Cue.ANCHOR_TYPE_START; + String displayAlign = XmlPullParserUtil.getAttributeValue(xmlParser, + TtmlNode.ATTR_TTS_DISPLAY_ALIGN); + if (displayAlign != null) { + switch (Util.toLowerInvariant(displayAlign)) { + case "center": + lineAnchor = Cue.ANCHOR_TYPE_MIDDLE; + line += height / 2; + break; + case "after": + lineAnchor = Cue.ANCHOR_TYPE_END; + line += height; + break; + default: + // Default "before" case. Do nothing. + break; + } + } + + float regionTextHeight = 1.0f / cellResolution.rows; + return new TtmlRegion( + regionId, + position, + line, + /* lineType= */ Cue.LINE_TYPE_FRACTION, + lineAnchor, + width, + height, + /* textSizeType= */ Cue.TEXT_SIZE_TYPE_FRACTIONAL_IGNORE_PADDING, + /* textSize= */ regionTextHeight); + } + + private String[] parseStyleIds(String parentStyleIds) { + parentStyleIds = parentStyleIds.trim(); + return parentStyleIds.isEmpty() ? new String[0] : Util.split(parentStyleIds, "\\s+"); + } + + private TtmlStyle parseStyleAttributes(XmlPullParser parser, TtmlStyle style) { + int attributeCount = parser.getAttributeCount(); + for (int i = 0; i < attributeCount; i++) { + String attributeValue = parser.getAttributeValue(i); + switch (parser.getAttributeName(i)) { + case TtmlNode.ATTR_ID: + if (TtmlNode.TAG_STYLE.equals(parser.getName())) { + style = createIfNull(style).setId(attributeValue); + } + break; + case TtmlNode.ATTR_TTS_BACKGROUND_COLOR: + style = createIfNull(style); + try { + style.setBackgroundColor(ColorParser.parseTtmlColor(attributeValue)); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Failed parsing background value: " + attributeValue); + } + break; + case TtmlNode.ATTR_TTS_COLOR: + style = createIfNull(style); + try { + style.setFontColor(ColorParser.parseTtmlColor(attributeValue)); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Failed parsing color value: " + attributeValue); + } + break; + case TtmlNode.ATTR_TTS_FONT_FAMILY: + style = createIfNull(style).setFontFamily(attributeValue); + break; + case TtmlNode.ATTR_TTS_FONT_SIZE: + try { + style = createIfNull(style); + parseFontSize(attributeValue, style); + } catch (SubtitleDecoderException e) { + Log.w(TAG, "Failed parsing fontSize value: " + attributeValue); + } + break; + case TtmlNode.ATTR_TTS_FONT_WEIGHT: + style = createIfNull(style).setBold( + TtmlNode.BOLD.equalsIgnoreCase(attributeValue)); + break; + case TtmlNode.ATTR_TTS_FONT_STYLE: + style = createIfNull(style).setItalic( + TtmlNode.ITALIC.equalsIgnoreCase(attributeValue)); + break; + case TtmlNode.ATTR_TTS_TEXT_ALIGN: + switch (Util.toLowerInvariant(attributeValue)) { + case TtmlNode.LEFT: + style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_NORMAL); + break; + case TtmlNode.START: + style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_NORMAL); + break; + case TtmlNode.RIGHT: + style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_OPPOSITE); + break; + case TtmlNode.END: + style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_OPPOSITE); + break; + case TtmlNode.CENTER: + style = createIfNull(style).setTextAlign(Layout.Alignment.ALIGN_CENTER); + break; + } + break; + case TtmlNode.ATTR_TTS_TEXT_DECORATION: + switch (Util.toLowerInvariant(attributeValue)) { + case TtmlNode.LINETHROUGH: + style = createIfNull(style).setLinethrough(true); + break; + case TtmlNode.NO_LINETHROUGH: + style = createIfNull(style).setLinethrough(false); + break; + case TtmlNode.UNDERLINE: + style = createIfNull(style).setUnderline(true); + break; + case TtmlNode.NO_UNDERLINE: + style = createIfNull(style).setUnderline(false); + break; + } + break; + default: + // ignore + break; + } + } + return style; + } + + private TtmlStyle createIfNull(TtmlStyle style) { + return style == null ? new TtmlStyle() : style; + } + + private TtmlNode parseNode(XmlPullParser parser, TtmlNode parent, + Map<String, TtmlRegion> regionMap, FrameAndTickRate frameAndTickRate) + throws SubtitleDecoderException { + long duration = C.TIME_UNSET; + long startTime = C.TIME_UNSET; + long endTime = C.TIME_UNSET; + String regionId = TtmlNode.ANONYMOUS_REGION_ID; + String imageId = null; + String[] styleIds = null; + int attributeCount = parser.getAttributeCount(); + TtmlStyle style = parseStyleAttributes(parser, null); + for (int i = 0; i < attributeCount; i++) { + String attr = parser.getAttributeName(i); + String value = parser.getAttributeValue(i); + switch (attr) { + case ATTR_BEGIN: + startTime = parseTimeExpression(value, frameAndTickRate); + break; + case ATTR_END: + endTime = parseTimeExpression(value, frameAndTickRate); + break; + case ATTR_DURATION: + duration = parseTimeExpression(value, frameAndTickRate); + break; + case ATTR_STYLE: + // IDREFS: potentially multiple space delimited ids + String[] ids = parseStyleIds(value); + if (ids.length > 0) { + styleIds = ids; + } + break; + case ATTR_REGION: + if (regionMap.containsKey(value)) { + // If the region has not been correctly declared or does not define a position, we use + // the anonymous region. + regionId = value; + } + break; + case ATTR_IMAGE: + // Parse URI reference only if refers to an element in the same document (it must start + // with '#'). Resolving URIs from external sources is not supported. + if (value.startsWith("#")) { + imageId = value.substring(1); + } + break; + default: + // Do nothing. + break; + } + } + if (parent != null && parent.startTimeUs != C.TIME_UNSET) { + if (startTime != C.TIME_UNSET) { + startTime += parent.startTimeUs; + } + if (endTime != C.TIME_UNSET) { + endTime += parent.startTimeUs; + } + } + if (endTime == C.TIME_UNSET) { + if (duration != C.TIME_UNSET) { + // Infer the end time from the duration. + endTime = startTime + duration; + } else if (parent != null && parent.endTimeUs != C.TIME_UNSET) { + // If the end time remains unspecified, then it should be inherited from the parent. + endTime = parent.endTimeUs; + } + } + return TtmlNode.buildNode( + parser.getName(), startTime, endTime, style, styleIds, regionId, imageId); + } + + private static boolean isSupportedTag(String tag) { + return tag.equals(TtmlNode.TAG_TT) + || tag.equals(TtmlNode.TAG_HEAD) + || tag.equals(TtmlNode.TAG_BODY) + || tag.equals(TtmlNode.TAG_DIV) + || tag.equals(TtmlNode.TAG_P) + || tag.equals(TtmlNode.TAG_SPAN) + || tag.equals(TtmlNode.TAG_BR) + || tag.equals(TtmlNode.TAG_STYLE) + || tag.equals(TtmlNode.TAG_STYLING) + || tag.equals(TtmlNode.TAG_LAYOUT) + || tag.equals(TtmlNode.TAG_REGION) + || tag.equals(TtmlNode.TAG_METADATA) + || tag.equals(TtmlNode.TAG_IMAGE) + || tag.equals(TtmlNode.TAG_DATA) + || tag.equals(TtmlNode.TAG_INFORMATION); + } + + private static void parseFontSize(String expression, TtmlStyle out) throws + SubtitleDecoderException { + String[] expressions = Util.split(expression, "\\s+"); + Matcher matcher; + if (expressions.length == 1) { + matcher = FONT_SIZE.matcher(expression); + } else if (expressions.length == 2){ + matcher = FONT_SIZE.matcher(expressions[1]); + Log.w(TAG, "Multiple values in fontSize attribute. Picking the second value for vertical font" + + " size and ignoring the first."); + } else { + throw new SubtitleDecoderException("Invalid number of entries for fontSize: " + + expressions.length + "."); + } + + if (matcher.matches()) { + String unit = matcher.group(3); + switch (unit) { + case "px": + out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_PIXEL); + break; + case "em": + out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_EM); + break; + case "%": + out.setFontSizeUnit(TtmlStyle.FONT_SIZE_UNIT_PERCENT); + break; + default: + throw new SubtitleDecoderException("Invalid unit for fontSize: '" + unit + "'."); + } + out.setFontSize(Float.valueOf(matcher.group(1))); + } else { + throw new SubtitleDecoderException("Invalid expression for fontSize: '" + expression + "'."); + } + } + + /** + * Parses a time expression, returning the parsed timestamp. + * <p> + * For the format of a time expression, see: + * <a href="http://www.w3.org/TR/ttaf1-dfxp/#timing-value-timeExpression">timeExpression</a> + * + * @param time A string that includes the time expression. + * @param frameAndTickRate The effective frame and tick rates of the stream. + * @return The parsed timestamp in microseconds. + * @throws SubtitleDecoderException If the given string does not contain a valid time expression. + */ + private static long parseTimeExpression(String time, FrameAndTickRate frameAndTickRate) + throws SubtitleDecoderException { + Matcher matcher = CLOCK_TIME.matcher(time); + if (matcher.matches()) { + String hours = matcher.group(1); + double durationSeconds = Long.parseLong(hours) * 3600; + String minutes = matcher.group(2); + durationSeconds += Long.parseLong(minutes) * 60; + String seconds = matcher.group(3); + durationSeconds += Long.parseLong(seconds); + String fraction = matcher.group(4); + durationSeconds += (fraction != null) ? Double.parseDouble(fraction) : 0; + String frames = matcher.group(5); + durationSeconds += (frames != null) + ? Long.parseLong(frames) / frameAndTickRate.effectiveFrameRate : 0; + String subframes = matcher.group(6); + durationSeconds += (subframes != null) + ? ((double) Long.parseLong(subframes)) / frameAndTickRate.subFrameRate + / frameAndTickRate.effectiveFrameRate + : 0; + return (long) (durationSeconds * C.MICROS_PER_SECOND); + } + matcher = OFFSET_TIME.matcher(time); + if (matcher.matches()) { + String timeValue = matcher.group(1); + double offsetSeconds = Double.parseDouble(timeValue); + String unit = matcher.group(2); + switch (unit) { + case "h": + offsetSeconds *= 3600; + break; + case "m": + offsetSeconds *= 60; + break; + case "s": + // Do nothing. + break; + case "ms": + offsetSeconds /= 1000; + break; + case "f": + offsetSeconds /= frameAndTickRate.effectiveFrameRate; + break; + case "t": + offsetSeconds /= frameAndTickRate.tickRate; + break; + } + return (long) (offsetSeconds * C.MICROS_PER_SECOND); + } + throw new SubtitleDecoderException("Malformed time expression: " + time); + } + + private static final class FrameAndTickRate { + final float effectiveFrameRate; + final int subFrameRate; + final int tickRate; + + FrameAndTickRate(float effectiveFrameRate, int subFrameRate, int tickRate) { + this.effectiveFrameRate = effectiveFrameRate; + this.subFrameRate = subFrameRate; + this.tickRate = tickRate; + } + } + + /** Represents the cell resolution for a TTML file. */ + private static final class CellResolution { + final int columns; + final int rows; + + CellResolution(int columns, int rows) { + this.columns = columns; + this.rows = rows; + } + } + + /** Represents the tts:extent for a TTML file. */ + private static final class TtsExtent { + final int width; + final int height; + + TtsExtent(int width, int height) { + this.width = width; + this.height = height; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java new file mode 100644 index 0000000000..16d0f28f6b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlNode.java @@ -0,0 +1,399 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.text.SpannableStringBuilder; +import android.util.Base64; +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * A package internal representation of TTML node. + */ +/* package */ final class TtmlNode { + + public static final String TAG_TT = "tt"; + public static final String TAG_HEAD = "head"; + public static final String TAG_BODY = "body"; + public static final String TAG_DIV = "div"; + public static final String TAG_P = "p"; + public static final String TAG_SPAN = "span"; + public static final String TAG_BR = "br"; + public static final String TAG_STYLE = "style"; + public static final String TAG_STYLING = "styling"; + public static final String TAG_LAYOUT = "layout"; + public static final String TAG_REGION = "region"; + public static final String TAG_METADATA = "metadata"; + public static final String TAG_IMAGE = "image"; + public static final String TAG_DATA = "data"; + public static final String TAG_INFORMATION = "information"; + + public static final String ANONYMOUS_REGION_ID = ""; + public static final String ATTR_ID = "id"; + public static final String ATTR_TTS_ORIGIN = "origin"; + public static final String ATTR_TTS_EXTENT = "extent"; + public static final String ATTR_TTS_DISPLAY_ALIGN = "displayAlign"; + public static final String ATTR_TTS_BACKGROUND_COLOR = "backgroundColor"; + public static final String ATTR_TTS_FONT_STYLE = "fontStyle"; + public static final String ATTR_TTS_FONT_SIZE = "fontSize"; + public static final String ATTR_TTS_FONT_FAMILY = "fontFamily"; + public static final String ATTR_TTS_FONT_WEIGHT = "fontWeight"; + public static final String ATTR_TTS_COLOR = "color"; + public static final String ATTR_TTS_TEXT_DECORATION = "textDecoration"; + public static final String ATTR_TTS_TEXT_ALIGN = "textAlign"; + + public static final String LINETHROUGH = "linethrough"; + public static final String NO_LINETHROUGH = "nolinethrough"; + public static final String UNDERLINE = "underline"; + public static final String NO_UNDERLINE = "nounderline"; + public static final String ITALIC = "italic"; + public static final String BOLD = "bold"; + + public static final String LEFT = "left"; + public static final String CENTER = "center"; + public static final String RIGHT = "right"; + public static final String START = "start"; + public static final String END = "end"; + + @Nullable public final String tag; + @Nullable public final String text; + public final boolean isTextNode; + public final long startTimeUs; + public final long endTimeUs; + @Nullable public final TtmlStyle style; + @Nullable private final String[] styleIds; + public final String regionId; + @Nullable public final String imageId; + + private final HashMap<String, Integer> nodeStartsByRegion; + private final HashMap<String, Integer> nodeEndsByRegion; + + private List<TtmlNode> children; + + public static TtmlNode buildTextNode(String text) { + return new TtmlNode( + /* tag= */ null, + TtmlRenderUtil.applyTextElementSpacePolicy(text), + /* startTimeUs= */ C.TIME_UNSET, + /* endTimeUs= */ C.TIME_UNSET, + /* style= */ null, + /* styleIds= */ null, + ANONYMOUS_REGION_ID, + /* imageId= */ null); + } + + public static TtmlNode buildNode( + @Nullable String tag, + long startTimeUs, + long endTimeUs, + @Nullable TtmlStyle style, + @Nullable String[] styleIds, + String regionId, + @Nullable String imageId) { + return new TtmlNode( + tag, /* text= */ null, startTimeUs, endTimeUs, style, styleIds, regionId, imageId); + } + + private TtmlNode( + @Nullable String tag, + @Nullable String text, + long startTimeUs, + long endTimeUs, + @Nullable TtmlStyle style, + @Nullable String[] styleIds, + String regionId, + @Nullable String imageId) { + this.tag = tag; + this.text = text; + this.imageId = imageId; + this.style = style; + this.styleIds = styleIds; + this.isTextNode = text != null; + this.startTimeUs = startTimeUs; + this.endTimeUs = endTimeUs; + this.regionId = Assertions.checkNotNull(regionId); + nodeStartsByRegion = new HashMap<>(); + nodeEndsByRegion = new HashMap<>(); + } + + public boolean isActive(long timeUs) { + return (startTimeUs == C.TIME_UNSET && endTimeUs == C.TIME_UNSET) + || (startTimeUs <= timeUs && endTimeUs == C.TIME_UNSET) + || (startTimeUs == C.TIME_UNSET && timeUs < endTimeUs) + || (startTimeUs <= timeUs && timeUs < endTimeUs); + } + + public void addChild(TtmlNode child) { + if (children == null) { + children = new ArrayList<>(); + } + children.add(child); + } + + public TtmlNode getChild(int index) { + if (children == null) { + throw new IndexOutOfBoundsException(); + } + return children.get(index); + } + + public int getChildCount() { + return children == null ? 0 : children.size(); + } + + public long[] getEventTimesUs() { + TreeSet<Long> eventTimeSet = new TreeSet<>(); + getEventTimes(eventTimeSet, false); + long[] eventTimes = new long[eventTimeSet.size()]; + int i = 0; + for (long eventTimeUs : eventTimeSet) { + eventTimes[i++] = eventTimeUs; + } + return eventTimes; + } + + private void getEventTimes(TreeSet<Long> out, boolean descendsPNode) { + boolean isPNode = TAG_P.equals(tag); + boolean isDivNode = TAG_DIV.equals(tag); + if (descendsPNode || isPNode || (isDivNode && imageId != null)) { + if (startTimeUs != C.TIME_UNSET) { + out.add(startTimeUs); + } + if (endTimeUs != C.TIME_UNSET) { + out.add(endTimeUs); + } + } + if (children == null) { + return; + } + for (int i = 0; i < children.size(); i++) { + children.get(i).getEventTimes(out, descendsPNode || isPNode); + } + } + + public String[] getStyleIds() { + return styleIds; + } + + public List<Cue> getCues( + long timeUs, + Map<String, TtmlStyle> globalStyles, + Map<String, TtmlRegion> regionMap, + Map<String, String> imageMap) { + + List<Pair<String, String>> regionImageOutputs = new ArrayList<>(); + traverseForImage(timeUs, regionId, regionImageOutputs); + + TreeMap<String, SpannableStringBuilder> regionTextOutputs = new TreeMap<>(); + traverseForText(timeUs, false, regionId, regionTextOutputs); + traverseForStyle(timeUs, globalStyles, regionTextOutputs); + + List<Cue> cues = new ArrayList<>(); + + // Create image based cues. + for (Pair<String, String> regionImagePair : regionImageOutputs) { + String encodedBitmapData = imageMap.get(regionImagePair.second); + if (encodedBitmapData == null) { + // Image reference points to an invalid image. Do nothing. + continue; + } + + byte[] bitmapData = Base64.decode(encodedBitmapData, Base64.DEFAULT); + Bitmap bitmap = BitmapFactory.decodeByteArray(bitmapData, /* offset= */ 0, bitmapData.length); + TtmlRegion region = regionMap.get(regionImagePair.first); + + cues.add( + new Cue( + bitmap, + region.position, + Cue.ANCHOR_TYPE_START, + region.line, + region.lineAnchor, + region.width, + region.height)); + } + + // Create text based cues. + for (Entry<String, SpannableStringBuilder> entry : regionTextOutputs.entrySet()) { + TtmlRegion region = regionMap.get(entry.getKey()); + cues.add( + new Cue( + cleanUpText(entry.getValue()), + /* textAlignment= */ null, + region.line, + region.lineType, + region.lineAnchor, + region.position, + /* positionAnchor= */ Cue.TYPE_UNSET, + region.width, + region.textSizeType, + region.textSize)); + } + + return cues; + } + + private void traverseForImage( + long timeUs, String inheritedRegion, List<Pair<String, String>> regionImageList) { + String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; + if (isActive(timeUs) && TAG_DIV.equals(tag) && imageId != null) { + regionImageList.add(new Pair<>(resolvedRegionId, imageId)); + return; + } + for (int i = 0; i < getChildCount(); ++i) { + getChild(i).traverseForImage(timeUs, resolvedRegionId, regionImageList); + } + } + + private void traverseForText( + long timeUs, + boolean descendsPNode, + String inheritedRegion, + Map<String, SpannableStringBuilder> regionOutputs) { + nodeStartsByRegion.clear(); + nodeEndsByRegion.clear(); + if (TAG_METADATA.equals(tag)) { + // Ignore metadata tag. + return; + } + + String resolvedRegionId = ANONYMOUS_REGION_ID.equals(regionId) ? inheritedRegion : regionId; + + if (isTextNode && descendsPNode) { + getRegionOutput(resolvedRegionId, regionOutputs).append(text); + } else if (TAG_BR.equals(tag) && descendsPNode) { + getRegionOutput(resolvedRegionId, regionOutputs).append('\n'); + } else if (isActive(timeUs)) { + // This is a container node, which can contain zero or more children. + for (Entry<String, SpannableStringBuilder> entry : regionOutputs.entrySet()) { + nodeStartsByRegion.put(entry.getKey(), entry.getValue().length()); + } + + boolean isPNode = TAG_P.equals(tag); + for (int i = 0; i < getChildCount(); i++) { + getChild(i).traverseForText(timeUs, descendsPNode || isPNode, resolvedRegionId, + regionOutputs); + } + if (isPNode) { + TtmlRenderUtil.endParagraph(getRegionOutput(resolvedRegionId, regionOutputs)); + } + + for (Entry<String, SpannableStringBuilder> entry : regionOutputs.entrySet()) { + nodeEndsByRegion.put(entry.getKey(), entry.getValue().length()); + } + } + } + + private static SpannableStringBuilder getRegionOutput( + String resolvedRegionId, Map<String, SpannableStringBuilder> regionOutputs) { + if (!regionOutputs.containsKey(resolvedRegionId)) { + regionOutputs.put(resolvedRegionId, new SpannableStringBuilder()); + } + return regionOutputs.get(resolvedRegionId); + } + + private void traverseForStyle( + long timeUs, + Map<String, TtmlStyle> globalStyles, + Map<String, SpannableStringBuilder> regionOutputs) { + if (!isActive(timeUs)) { + return; + } + for (Entry<String, Integer> entry : nodeEndsByRegion.entrySet()) { + String regionId = entry.getKey(); + int start = nodeStartsByRegion.containsKey(regionId) ? nodeStartsByRegion.get(regionId) : 0; + int end = entry.getValue(); + if (start != end) { + SpannableStringBuilder regionOutput = regionOutputs.get(regionId); + applyStyleToOutput(globalStyles, regionOutput, start, end); + } + } + for (int i = 0; i < getChildCount(); ++i) { + getChild(i).traverseForStyle(timeUs, globalStyles, regionOutputs); + } + } + + private void applyStyleToOutput( + Map<String, TtmlStyle> globalStyles, + SpannableStringBuilder regionOutput, + int start, + int end) { + TtmlStyle resolvedStyle = TtmlRenderUtil.resolveStyle(style, styleIds, globalStyles); + if (resolvedStyle != null) { + TtmlRenderUtil.applyStylesToSpan(regionOutput, start, end, resolvedStyle); + } + } + + private SpannableStringBuilder cleanUpText(SpannableStringBuilder builder) { + // Having joined the text elements, we need to do some final cleanup on the result. + // 1. Collapse multiple consecutive spaces into a single space. + int builderLength = builder.length(); + for (int i = 0; i < builderLength; i++) { + if (builder.charAt(i) == ' ') { + int j = i + 1; + while (j < builder.length() && builder.charAt(j) == ' ') { + j++; + } + int spacesToDelete = j - (i + 1); + if (spacesToDelete > 0) { + builder.delete(i, i + spacesToDelete); + builderLength -= spacesToDelete; + } + } + } + // 2. Remove any spaces from the start of each line. + if (builderLength > 0 && builder.charAt(0) == ' ') { + builder.delete(0, 1); + builderLength--; + } + for (int i = 0; i < builderLength - 1; i++) { + if (builder.charAt(i) == '\n' && builder.charAt(i + 1) == ' ') { + builder.delete(i + 1, i + 2); + builderLength--; + } + } + // 3. Remove any spaces from the end of each line. + if (builderLength > 0 && builder.charAt(builderLength - 1) == ' ') { + builder.delete(builderLength - 1, builderLength); + builderLength--; + } + for (int i = 0; i < builderLength - 1; i++) { + if (builder.charAt(i) == ' ' && builder.charAt(i + 1) == '\n') { + builder.delete(i, i + 1); + builderLength--; + } + } + // 4. Trim a trailing newline, if there is one. + if (builderLength > 0 && builder.charAt(builderLength - 1) == '\n') { + builder.delete(builderLength - 1, builderLength); + /*builderLength--;*/ + } + return builder; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java new file mode 100644 index 0000000000..d14e547d49 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRegion.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; + +/** + * Represents a TTML Region. + */ +/* package */ final class TtmlRegion { + + public final String id; + public final float position; + public final float line; + public final @Cue.LineType int lineType; + public final @Cue.AnchorType int lineAnchor; + public final float width; + public final float height; + public final @Cue.TextSizeType int textSizeType; + public final float textSize; + + public TtmlRegion(String id) { + this( + id, + /* position= */ Cue.DIMEN_UNSET, + /* line= */ Cue.DIMEN_UNSET, + /* lineType= */ Cue.TYPE_UNSET, + /* lineAnchor= */ Cue.TYPE_UNSET, + /* width= */ Cue.DIMEN_UNSET, + /* height= */ Cue.DIMEN_UNSET, + /* textSizeType= */ Cue.TYPE_UNSET, + /* textSize= */ Cue.DIMEN_UNSET); + } + + public TtmlRegion( + String id, + float position, + float line, + @Cue.LineType int lineType, + @Cue.AnchorType int lineAnchor, + float width, + float height, + int textSizeType, + float textSize) { + this.id = id; + this.position = position; + this.line = line; + this.lineType = lineType; + this.lineAnchor = lineAnchor; + this.width = width; + this.height = height; + this.textSizeType = textSizeType; + this.textSize = textSize; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java new file mode 100644 index 0000000000..f2387b6282 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlRenderUtil.java @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.AlignmentSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.text.style.UnderlineSpan; +import java.util.Map; + +/** + * Package internal utility class to render styled <code>TtmlNode</code>s. + */ +/* package */ final class TtmlRenderUtil { + + public static TtmlStyle resolveStyle(TtmlStyle style, String[] styleIds, + Map<String, TtmlStyle> globalStyles) { + if (style == null && styleIds == null) { + // No styles at all. + return null; + } else if (style == null && styleIds.length == 1) { + // Only one single referential style present. + return globalStyles.get(styleIds[0]); + } else if (style == null && styleIds.length > 1) { + // Only multiple referential styles present. + TtmlStyle chainedStyle = new TtmlStyle(); + for (String id : styleIds) { + chainedStyle.chain(globalStyles.get(id)); + } + return chainedStyle; + } else if (style != null && styleIds != null && styleIds.length == 1) { + // Merge a single referential style into inline style. + return style.chain(globalStyles.get(styleIds[0])); + } else if (style != null && styleIds != null && styleIds.length > 1) { + // Merge multiple referential styles into inline style. + for (String id : styleIds) { + style.chain(globalStyles.get(id)); + } + return style; + } + // Only inline styles available. + return style; + } + + public static void applyStylesToSpan(SpannableStringBuilder builder, + int start, int end, TtmlStyle style) { + + if (style.getStyle() != TtmlStyle.UNSPECIFIED) { + builder.setSpan(new StyleSpan(style.getStyle()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.isLinethrough()) { + builder.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.isUnderline()) { + builder.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.hasFontColor()) { + builder.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.hasBackgroundColor()) { + builder.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.getFontFamily() != null) { + builder.setSpan(new TypefaceSpan(style.getFontFamily()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.getTextAlign() != null) { + builder.setSpan(new AlignmentSpan.Standard(style.getTextAlign()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + switch (style.getFontSizeUnit()) { + case TtmlStyle.FONT_SIZE_UNIT_PIXEL: + builder.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.FONT_SIZE_UNIT_EM: + builder.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.FONT_SIZE_UNIT_PERCENT: + builder.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TtmlStyle.UNSPECIFIED: + // Do nothing. + break; + } + } + + /** + * Called when the end of a paragraph is encountered. Adds a newline if there are one or more + * non-space characters since the previous newline. + * + * @param builder The builder. + */ + /* package */ static void endParagraph(SpannableStringBuilder builder) { + int position = builder.length() - 1; + while (position >= 0 && builder.charAt(position) == ' ') { + position--; + } + if (position >= 0 && builder.charAt(position) != '\n') { + builder.append('\n'); + } + } + + /** + * Applies the appropriate space policy to the given text element. + * + * @param in The text element to which the policy should be applied. + * @return The result of applying the policy to the text element. + */ + /* package */ static String applyTextElementSpacePolicy(String in) { + // Removes carriage return followed by line feed. See: http://www.w3.org/TR/xml/#sec-line-ends + String out = in.replaceAll("\r\n", "\n"); + // Apply suppress-at-line-break="auto" and + // white-space-treatment="ignore-if-surrounding-linefeed" + out = out.replaceAll(" *\n *", "\n"); + // Apply linefeed-treatment="treat-as-space" + out = out.replaceAll("\n", " "); + // Apply white-space-collapse="true" + out = out.replaceAll("[ \t\\x0B\f\r]+", " "); + return out; + } + + private TtmlRenderUtil() {} + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java new file mode 100644 index 0000000000..57faaecb69 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlStyle.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import android.graphics.Typeface; +import android.text.Layout; +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Style object of a <code>TtmlNode</code> + */ +/* package */ final class TtmlStyle { + + public static final int UNSPECIFIED = -1; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {UNSPECIFIED, STYLE_NORMAL, STYLE_BOLD, STYLE_ITALIC, STYLE_BOLD_ITALIC}) + public @interface StyleFlags {} + + public static final int STYLE_NORMAL = Typeface.NORMAL; + public static final int STYLE_BOLD = Typeface.BOLD; + public static final int STYLE_ITALIC = Typeface.ITALIC; + public static final int STYLE_BOLD_ITALIC = Typeface.BOLD_ITALIC; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNSPECIFIED, FONT_SIZE_UNIT_PIXEL, FONT_SIZE_UNIT_EM, FONT_SIZE_UNIT_PERCENT}) + public @interface FontSizeUnit {} + + public static final int FONT_SIZE_UNIT_PIXEL = 1; + public static final int FONT_SIZE_UNIT_EM = 2; + public static final int FONT_SIZE_UNIT_PERCENT = 3; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNSPECIFIED, OFF, ON}) + private @interface OptionalBoolean {} + + private static final int OFF = 0; + private static final int ON = 1; + + private String fontFamily; + private int fontColor; + private boolean hasFontColor; + private int backgroundColor; + private boolean hasBackgroundColor; + @OptionalBoolean private int linethrough; + @OptionalBoolean private int underline; + @OptionalBoolean private int bold; + @OptionalBoolean private int italic; + @FontSizeUnit private int fontSizeUnit; + private float fontSize; + private String id; + private TtmlStyle inheritableStyle; + private Layout.Alignment textAlign; + + public TtmlStyle() { + linethrough = UNSPECIFIED; + underline = UNSPECIFIED; + bold = UNSPECIFIED; + italic = UNSPECIFIED; + fontSizeUnit = UNSPECIFIED; + } + + /** + * Returns the style or {@link #UNSPECIFIED} when no style information is given. + * + * @return {@link #UNSPECIFIED}, {@link #STYLE_NORMAL}, {@link #STYLE_BOLD}, {@link #STYLE_BOLD} + * or {@link #STYLE_BOLD_ITALIC}. + */ + @StyleFlags public int getStyle() { + if (bold == UNSPECIFIED && italic == UNSPECIFIED) { + return UNSPECIFIED; + } + return (bold == ON ? STYLE_BOLD : STYLE_NORMAL) + | (italic == ON ? STYLE_ITALIC : STYLE_NORMAL); + } + + public boolean isLinethrough() { + return linethrough == ON; + } + + public TtmlStyle setLinethrough(boolean linethrough) { + Assertions.checkState(inheritableStyle == null); + this.linethrough = linethrough ? ON : OFF; + return this; + } + + public boolean isUnderline() { + return underline == ON; + } + + public TtmlStyle setUnderline(boolean underline) { + Assertions.checkState(inheritableStyle == null); + this.underline = underline ? ON : OFF; + return this; + } + + public TtmlStyle setBold(boolean bold) { + Assertions.checkState(inheritableStyle == null); + this.bold = bold ? ON : OFF; + return this; + } + + public TtmlStyle setItalic(boolean italic) { + Assertions.checkState(inheritableStyle == null); + this.italic = italic ? ON : OFF; + return this; + } + + public String getFontFamily() { + return fontFamily; + } + + public TtmlStyle setFontFamily(String fontFamily) { + Assertions.checkState(inheritableStyle == null); + this.fontFamily = fontFamily; + return this; + } + + public int getFontColor() { + if (!hasFontColor) { + throw new IllegalStateException("Font color has not been defined."); + } + return fontColor; + } + + public TtmlStyle setFontColor(int fontColor) { + Assertions.checkState(inheritableStyle == null); + this.fontColor = fontColor; + hasFontColor = true; + return this; + } + + public boolean hasFontColor() { + return hasFontColor; + } + + public int getBackgroundColor() { + if (!hasBackgroundColor) { + throw new IllegalStateException("Background color has not been defined."); + } + return backgroundColor; + } + + public TtmlStyle setBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + hasBackgroundColor = true; + return this; + } + + public boolean hasBackgroundColor() { + return hasBackgroundColor; + } + + /** + * Inherits from an ancestor style. Properties like <i>tts:backgroundColor</i> which + * are not inheritable are not inherited as well as properties which are already set locally + * are never overridden. + * + * @param ancestor the ancestor style to inherit from + */ + public TtmlStyle inherit(TtmlStyle ancestor) { + return inherit(ancestor, false); + } + + /** + * Chains this style to referential style. Local properties which are already set + * are never overridden. + * + * @param ancestor the referential style to inherit from + */ + public TtmlStyle chain(TtmlStyle ancestor) { + return inherit(ancestor, true); + } + + private TtmlStyle inherit(TtmlStyle ancestor, boolean chaining) { + if (ancestor != null) { + if (!hasFontColor && ancestor.hasFontColor) { + setFontColor(ancestor.fontColor); + } + if (bold == UNSPECIFIED) { + bold = ancestor.bold; + } + if (italic == UNSPECIFIED) { + italic = ancestor.italic; + } + if (fontFamily == null) { + fontFamily = ancestor.fontFamily; + } + if (linethrough == UNSPECIFIED) { + linethrough = ancestor.linethrough; + } + if (underline == UNSPECIFIED) { + underline = ancestor.underline; + } + if (textAlign == null) { + textAlign = ancestor.textAlign; + } + if (fontSizeUnit == UNSPECIFIED) { + fontSizeUnit = ancestor.fontSizeUnit; + fontSize = ancestor.fontSize; + } + // attributes not inherited as of http://www.w3.org/TR/ttml1/ + if (chaining && !hasBackgroundColor && ancestor.hasBackgroundColor) { + setBackgroundColor(ancestor.backgroundColor); + } + } + return this; + } + + public TtmlStyle setId(String id) { + this.id = id; + return this; + } + + public String getId() { + return id; + } + + public Layout.Alignment getTextAlign() { + return textAlign; + } + + public TtmlStyle setTextAlign(Layout.Alignment textAlign) { + this.textAlign = textAlign; + return this; + } + + public TtmlStyle setFontSize(float fontSize) { + this.fontSize = fontSize; + return this; + } + + public TtmlStyle setFontSizeUnit(int fontSizeUnit) { + this.fontSizeUnit = fontSizeUnit; + return this; + } + + @FontSizeUnit public int getFontSizeUnit() { + return fontSizeUnit; + } + + public float getFontSize() { + return fontSize; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java new file mode 100644 index 0000000000..52bd389818 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/TtmlSubtitle.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A representation of a TTML subtitle. + */ +/* package */ final class TtmlSubtitle implements Subtitle { + + private final TtmlNode root; + private final long[] eventTimesUs; + private final Map<String, TtmlStyle> globalStyles; + private final Map<String, TtmlRegion> regionMap; + private final Map<String, String> imageMap; + + public TtmlSubtitle( + TtmlNode root, + Map<String, TtmlStyle> globalStyles, + Map<String, TtmlRegion> regionMap, + Map<String, String> imageMap) { + this.root = root; + this.regionMap = regionMap; + this.imageMap = imageMap; + this.globalStyles = + globalStyles != null ? Collections.unmodifiableMap(globalStyles) : Collections.emptyMap(); + this.eventTimesUs = root.getEventTimesUs(); + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(eventTimesUs, timeUs, false, false); + return index < eventTimesUs.length ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return eventTimesUs.length; + } + + @Override + public long getEventTime(int index) { + return eventTimesUs[index]; + } + + @VisibleForTesting + /* package */ TtmlNode getRoot() { + return root; + } + + @Override + public List<Cue> getCues(long timeUs) { + return root.getCues(timeUs, globalStyles, regionMap, imageMap); + } + + @VisibleForTesting + /* package */ Map<String, TtmlStyle> getGlobalStyles() { + return globalStyles; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java new file mode 100644 index 0000000000..e6e7a5a8e3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/ttml/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.ttml; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java new file mode 100644 index 0000000000..a6b9ab5c63 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gDecoder.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g; + +import android.graphics.Color; +import android.graphics.Typeface; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.text.style.UnderlineSpan; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.charset.Charset; +import java.util.List; + +/** + * A {@link SimpleSubtitleDecoder} for tx3g. + * <p> + * Currently supports parsing of a single text track with embedded styles. + */ +public final class Tx3gDecoder extends SimpleSubtitleDecoder { + + private static final char BOM_UTF16_BE = '\uFEFF'; + private static final char BOM_UTF16_LE = '\uFFFE'; + + private static final int TYPE_STYL = 0x7374796c; + private static final int TYPE_TBOX = 0x74626f78; + private static final String TX3G_SERIF = "Serif"; + + private static final int SIZE_ATOM_HEADER = 8; + private static final int SIZE_SHORT = 2; + private static final int SIZE_BOM_UTF16 = 2; + private static final int SIZE_STYLE_RECORD = 12; + + private static final int FONT_FACE_BOLD = 0x0001; + private static final int FONT_FACE_ITALIC = 0x0002; + private static final int FONT_FACE_UNDERLINE = 0x0004; + + private static final int SPAN_PRIORITY_LOW = 0xFF << Spanned.SPAN_PRIORITY_SHIFT; + private static final int SPAN_PRIORITY_HIGH = 0; + + private static final int DEFAULT_FONT_FACE = 0; + private static final int DEFAULT_COLOR = Color.WHITE; + private static final String DEFAULT_FONT_FAMILY = C.SANS_SERIF_NAME; + private static final float DEFAULT_VERTICAL_PLACEMENT = 0.85f; + + private final ParsableByteArray parsableByteArray; + + private boolean customVerticalPlacement; + private int defaultFontFace; + private int defaultColorRgba; + private String defaultFontFamily; + private float defaultVerticalPlacement; + private int calculatedVideoTrackHeight; + + /** + * Sets up a new {@link Tx3gDecoder} with default values. + * + * @param initializationData Sample description atom ('stsd') data with default subtitle styles. + */ + public Tx3gDecoder(List<byte[]> initializationData) { + super("Tx3gDecoder"); + parsableByteArray = new ParsableByteArray(); + + if (initializationData != null && initializationData.size() == 1 + && (initializationData.get(0).length == 48 || initializationData.get(0).length == 53)) { + byte[] initializationBytes = initializationData.get(0); + defaultFontFace = initializationBytes[24]; + defaultColorRgba = ((initializationBytes[26] & 0xFF) << 24) + | ((initializationBytes[27] & 0xFF) << 16) + | ((initializationBytes[28] & 0xFF) << 8) + | (initializationBytes[29] & 0xFF); + String fontFamily = + Util.fromUtf8Bytes(initializationBytes, 43, initializationBytes.length - 43); + defaultFontFamily = TX3G_SERIF.equals(fontFamily) ? C.SERIF_NAME : C.SANS_SERIF_NAME; + //font size (initializationBytes[25]) is 5% of video height + calculatedVideoTrackHeight = 20 * initializationBytes[25]; + customVerticalPlacement = (initializationBytes[0] & 0x20) != 0; + if (customVerticalPlacement) { + int requestedVerticalPlacement = ((initializationBytes[10] & 0xFF) << 8) + | (initializationBytes[11] & 0xFF); + defaultVerticalPlacement = (float) requestedVerticalPlacement / calculatedVideoTrackHeight; + defaultVerticalPlacement = Util.constrainValue(defaultVerticalPlacement, 0.0f, 0.95f); + } else { + defaultVerticalPlacement = DEFAULT_VERTICAL_PLACEMENT; + } + } else { + defaultFontFace = DEFAULT_FONT_FACE; + defaultColorRgba = DEFAULT_COLOR; + defaultFontFamily = DEFAULT_FONT_FAMILY; + customVerticalPlacement = false; + defaultVerticalPlacement = DEFAULT_VERTICAL_PLACEMENT; + } + } + + @Override + protected Subtitle decode(byte[] bytes, int length, boolean reset) + throws SubtitleDecoderException { + parsableByteArray.reset(bytes, length); + String cueTextString = readSubtitleText(parsableByteArray); + if (cueTextString.isEmpty()) { + return Tx3gSubtitle.EMPTY; + } + // Attach default styles. + SpannableStringBuilder cueText = new SpannableStringBuilder(cueTextString); + attachFontFace(cueText, defaultFontFace, DEFAULT_FONT_FACE, 0, cueText.length(), + SPAN_PRIORITY_LOW); + attachColor(cueText, defaultColorRgba, DEFAULT_COLOR, 0, cueText.length(), + SPAN_PRIORITY_LOW); + attachFontFamily(cueText, defaultFontFamily, DEFAULT_FONT_FAMILY, 0, cueText.length(), + SPAN_PRIORITY_LOW); + float verticalPlacement = defaultVerticalPlacement; + // Find and attach additional styles. + while (parsableByteArray.bytesLeft() >= SIZE_ATOM_HEADER) { + int position = parsableByteArray.getPosition(); + int atomSize = parsableByteArray.readInt(); + int atomType = parsableByteArray.readInt(); + if (atomType == TYPE_STYL) { + assertTrue(parsableByteArray.bytesLeft() >= SIZE_SHORT); + int styleRecordCount = parsableByteArray.readUnsignedShort(); + for (int i = 0; i < styleRecordCount; i++) { + applyStyleRecord(parsableByteArray, cueText); + } + } else if (atomType == TYPE_TBOX && customVerticalPlacement) { + assertTrue(parsableByteArray.bytesLeft() >= SIZE_SHORT); + int requestedVerticalPlacement = parsableByteArray.readUnsignedShort(); + verticalPlacement = (float) requestedVerticalPlacement / calculatedVideoTrackHeight; + verticalPlacement = Util.constrainValue(verticalPlacement, 0.0f, 0.95f); + } + parsableByteArray.setPosition(position + atomSize); + } + return new Tx3gSubtitle( + new Cue( + cueText, + /* textAlignment= */ null, + verticalPlacement, + Cue.LINE_TYPE_FRACTION, + Cue.ANCHOR_TYPE_START, + Cue.DIMEN_UNSET, + Cue.TYPE_UNSET, + Cue.DIMEN_UNSET)); + } + + private static String readSubtitleText(ParsableByteArray parsableByteArray) + throws SubtitleDecoderException { + assertTrue(parsableByteArray.bytesLeft() >= SIZE_SHORT); + int textLength = parsableByteArray.readUnsignedShort(); + if (textLength == 0) { + return ""; + } + if (parsableByteArray.bytesLeft() >= SIZE_BOM_UTF16) { + char firstChar = parsableByteArray.peekChar(); + if (firstChar == BOM_UTF16_BE || firstChar == BOM_UTF16_LE) { + return parsableByteArray.readString(textLength, Charset.forName(C.UTF16_NAME)); + } + } + return parsableByteArray.readString(textLength, Charset.forName(C.UTF8_NAME)); + } + + private void applyStyleRecord(ParsableByteArray parsableByteArray, + SpannableStringBuilder cueText) throws SubtitleDecoderException { + assertTrue(parsableByteArray.bytesLeft() >= SIZE_STYLE_RECORD); + int start = parsableByteArray.readUnsignedShort(); + int end = parsableByteArray.readUnsignedShort(); + parsableByteArray.skipBytes(2); // font identifier + int fontFace = parsableByteArray.readUnsignedByte(); + parsableByteArray.skipBytes(1); // font size + int colorRgba = parsableByteArray.readInt(); + attachFontFace(cueText, fontFace, defaultFontFace, start, end, SPAN_PRIORITY_HIGH); + attachColor(cueText, colorRgba, defaultColorRgba, start, end, SPAN_PRIORITY_HIGH); + } + + private static void attachFontFace(SpannableStringBuilder cueText, int fontFace, + int defaultFontFace, int start, int end, int spanPriority) { + if (fontFace != defaultFontFace) { + final int flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority; + boolean isBold = (fontFace & FONT_FACE_BOLD) != 0; + boolean isItalic = (fontFace & FONT_FACE_ITALIC) != 0; + if (isBold) { + if (isItalic) { + cueText.setSpan(new StyleSpan(Typeface.BOLD_ITALIC), start, end, flags); + } else { + cueText.setSpan(new StyleSpan(Typeface.BOLD), start, end, flags); + } + } else if (isItalic) { + cueText.setSpan(new StyleSpan(Typeface.ITALIC), start, end, flags); + } + boolean isUnderlined = (fontFace & FONT_FACE_UNDERLINE) != 0; + if (isUnderlined) { + cueText.setSpan(new UnderlineSpan(), start, end, flags); + } + if (!isUnderlined && !isBold && !isItalic) { + cueText.setSpan(new StyleSpan(Typeface.NORMAL), start, end, flags); + } + } + } + + private static void attachColor(SpannableStringBuilder cueText, int colorRgba, + int defaultColorRgba, int start, int end, int spanPriority) { + if (colorRgba != defaultColorRgba) { + int colorArgb = ((colorRgba & 0xFF) << 24) | (colorRgba >>> 8); + cueText.setSpan(new ForegroundColorSpan(colorArgb), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority); + } + } + + @SuppressWarnings("ReferenceEquality") + private static void attachFontFamily(SpannableStringBuilder cueText, String fontFamily, + String defaultFontFamily, int start, int end, int spanPriority) { + if (fontFamily != defaultFontFamily) { + cueText.setSpan(new TypefaceSpan(fontFamily), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | spanPriority); + } + } + + private static void assertTrue(boolean checkValue) throws SubtitleDecoderException { + if (!checkValue) { + throw new SubtitleDecoderException("Unexpected subtitle format."); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java new file mode 100644 index 0000000000..93bc6034d1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/Tx3gSubtitle.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Collections; +import java.util.List; + +/** + * A representation of a tx3g subtitle. + */ +/* package */ final class Tx3gSubtitle implements Subtitle { + + public static final Tx3gSubtitle EMPTY = new Tx3gSubtitle(); + + private final List<Cue> cues; + + public Tx3gSubtitle(Cue cue) { + this.cues = Collections.singletonList(cue); + } + + private Tx3gSubtitle() { + this.cues = Collections.emptyList(); + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + return timeUs < 0 ? 0 : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return 1; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index == 0); + return 0; + } + + @Override + public List<Cue> getCues(long timeUs) { + return timeUs >= 0 ? cues : Collections.emptyList(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java new file mode 100644 index 0000000000..7bac8c12b6 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/tx3g/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.tx3g; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java new file mode 100644 index 0000000000..3337cc3481 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/CssParser.java @@ -0,0 +1,347 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ColorParser; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Provides a CSS parser for STYLE blocks in Webvtt files. Supports only a subset of the CSS + * features. + */ +/* package */ final class CssParser { + + private static final String PROPERTY_BGCOLOR = "background-color"; + private static final String PROPERTY_FONT_FAMILY = "font-family"; + private static final String PROPERTY_FONT_WEIGHT = "font-weight"; + private static final String PROPERTY_TEXT_DECORATION = "text-decoration"; + private static final String VALUE_BOLD = "bold"; + private static final String VALUE_UNDERLINE = "underline"; + private static final String RULE_START = "{"; + private static final String RULE_END = "}"; + private static final String PROPERTY_FONT_STYLE = "font-style"; + private static final String VALUE_ITALIC = "italic"; + + private static final Pattern VOICE_NAME_PATTERN = Pattern.compile("\\[voice=\"([^\"]*)\"\\]"); + + // Temporary utility data structures. + private final ParsableByteArray styleInput; + private final StringBuilder stringBuilder; + + public CssParser() { + styleInput = new ParsableByteArray(); + stringBuilder = new StringBuilder(); + } + + /** + * Takes a CSS style block and consumes up to the first empty line. Attempts to parse the contents + * of the style block and returns a list of {@link WebvttCssStyle} instances if successful. If + * parsing fails, it returns a list including only the styles which have been successfully parsed + * up to the style rule which was malformed. + * + * @param input The input from which the style block should be read. + * @return A list of {@link WebvttCssStyle}s that represents the parsed block, or a list + * containing the styles up to the parsing failure. + */ + public List<WebvttCssStyle> parseBlock(ParsableByteArray input) { + stringBuilder.setLength(0); + int initialInputPosition = input.getPosition(); + skipStyleBlock(input); + styleInput.reset(input.data, input.getPosition()); + styleInput.setPosition(initialInputPosition); + + List<WebvttCssStyle> styles = new ArrayList<>(); + String selector; + while ((selector = parseSelector(styleInput, stringBuilder)) != null) { + if (!RULE_START.equals(parseNextToken(styleInput, stringBuilder))) { + return styles; + } + WebvttCssStyle style = new WebvttCssStyle(); + applySelectorToStyle(style, selector); + String token = null; + boolean blockEndFound = false; + while (!blockEndFound) { + int position = styleInput.getPosition(); + token = parseNextToken(styleInput, stringBuilder); + blockEndFound = token == null || RULE_END.equals(token); + if (!blockEndFound) { + styleInput.setPosition(position); + parseStyleDeclaration(styleInput, style, stringBuilder); + } + } + // Check that the style rule ended correctly. + if (RULE_END.equals(token)) { + styles.add(style); + } + } + return styles; + } + + /** + * Returns a string containing the selector. The input is expected to have the form {@code + * ::cue(tag#id.class1.class2[voice="someone"]}, where every element is optional. + * + * @param input From which the selector is obtained. + * @return A string containing the target, empty string if the selector is universal (targets all + * cues) or null if an error was encountered. + */ + @Nullable + private static String parseSelector(ParsableByteArray input, StringBuilder stringBuilder) { + skipWhitespaceAndComments(input); + if (input.bytesLeft() < 5) { + return null; + } + String cueSelector = input.readString(5); + if (!"::cue".equals(cueSelector)) { + return null; + } + int position = input.getPosition(); + String token = parseNextToken(input, stringBuilder); + if (token == null) { + return null; + } + if (RULE_START.equals(token)) { + input.setPosition(position); + return ""; + } + String target = null; + if ("(".equals(token)) { + target = readCueTarget(input); + } + token = parseNextToken(input, stringBuilder); + if (!")".equals(token)) { + return null; + } + return target; + } + + /** + * Reads the contents of ::cue() and returns it as a string. + */ + private static String readCueTarget(ParsableByteArray input) { + int position = input.getPosition(); + int limit = input.limit(); + boolean cueTargetEndFound = false; + while (position < limit && !cueTargetEndFound) { + char c = (char) input.data[position++]; + cueTargetEndFound = c == ')'; + } + return input.readString(--position - input.getPosition()).trim(); + // --offset to return ')' to the input. + } + + private static void parseStyleDeclaration(ParsableByteArray input, WebvttCssStyle style, + StringBuilder stringBuilder) { + skipWhitespaceAndComments(input); + String property = parseIdentifier(input, stringBuilder); + if ("".equals(property)) { + return; + } + if (!":".equals(parseNextToken(input, stringBuilder))) { + return; + } + skipWhitespaceAndComments(input); + String value = parsePropertyValue(input, stringBuilder); + if (value == null || "".equals(value)) { + return; + } + int position = input.getPosition(); + String token = parseNextToken(input, stringBuilder); + if (";".equals(token)) { + // The style declaration is well formed. + } else if (RULE_END.equals(token)) { + // The style declaration is well formed and we can go on, but the closing bracket had to be + // fed back. + input.setPosition(position); + } else { + // The style declaration is not well formed. + return; + } + // At this point we have a presumably valid declaration, we need to parse it and fill the style. + if ("color".equals(property)) { + style.setFontColor(ColorParser.parseCssColor(value)); + } else if (PROPERTY_BGCOLOR.equals(property)) { + style.setBackgroundColor(ColorParser.parseCssColor(value)); + } else if (PROPERTY_TEXT_DECORATION.equals(property)) { + if (VALUE_UNDERLINE.equals(value)) { + style.setUnderline(true); + } + } else if (PROPERTY_FONT_FAMILY.equals(property)) { + style.setFontFamily(value); + } else if (PROPERTY_FONT_WEIGHT.equals(property)) { + if (VALUE_BOLD.equals(value)) { + style.setBold(true); + } + } else if (PROPERTY_FONT_STYLE.equals(property)) { + if (VALUE_ITALIC.equals(value)) { + style.setItalic(true); + } + } + // TODO: Fill remaining supported styles. + } + + // Visible for testing. + /* package */ static void skipWhitespaceAndComments(ParsableByteArray input) { + boolean skipping = true; + while (input.bytesLeft() > 0 && skipping) { + skipping = maybeSkipWhitespace(input) || maybeSkipComment(input); + } + } + + // Visible for testing. + @Nullable + /* package */ static String parseNextToken(ParsableByteArray input, StringBuilder stringBuilder) { + skipWhitespaceAndComments(input); + if (input.bytesLeft() == 0) { + return null; + } + String identifier = parseIdentifier(input, stringBuilder); + if (!"".equals(identifier)) { + return identifier; + } + // We found a delimiter. + return "" + (char) input.readUnsignedByte(); + } + + private static boolean maybeSkipWhitespace(ParsableByteArray input) { + switch(peekCharAtPosition(input, input.getPosition())) { + case '\t': + case '\r': + case '\n': + case '\f': + case ' ': + input.skipBytes(1); + return true; + default: + return false; + } + } + + // Visible for testing. + /* package */ static void skipStyleBlock(ParsableByteArray input) { + // The style block cannot contain empty lines, so we assume the input ends when a empty line + // is found. + String line; + do { + line = input.readLine(); + } while (!TextUtils.isEmpty(line)); + } + + private static char peekCharAtPosition(ParsableByteArray input, int position) { + return (char) input.data[position]; + } + + @Nullable + private static String parsePropertyValue(ParsableByteArray input, StringBuilder stringBuilder) { + StringBuilder expressionBuilder = new StringBuilder(); + String token; + int position; + boolean expressionEndFound = false; + // TODO: Add support for "Strings in quotes with spaces". + while (!expressionEndFound) { + position = input.getPosition(); + token = parseNextToken(input, stringBuilder); + if (token == null) { + // Syntax error. + return null; + } + if (RULE_END.equals(token) || ";".equals(token)) { + input.setPosition(position); + expressionEndFound = true; + } else { + expressionBuilder.append(token); + } + } + return expressionBuilder.toString(); + } + + private static boolean maybeSkipComment(ParsableByteArray input) { + int position = input.getPosition(); + int limit = input.limit(); + byte[] data = input.data; + if (position + 2 <= limit && data[position++] == '/' && data[position++] == '*') { + while (position + 1 < limit) { + char skippedChar = (char) data[position++]; + if (skippedChar == '*') { + if (((char) data[position]) == '/') { + position++; + limit = position; + } + } + } + input.skipBytes(limit - input.getPosition()); + return true; + } + return false; + } + + private static String parseIdentifier(ParsableByteArray input, StringBuilder stringBuilder) { + stringBuilder.setLength(0); + int position = input.getPosition(); + int limit = input.limit(); + boolean identifierEndFound = false; + while (position < limit && !identifierEndFound) { + char c = (char) input.data[position]; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '#' + || c == '-' || c == '.' || c == '_') { + position++; + stringBuilder.append(c); + } else { + identifierEndFound = true; + } + } + input.skipBytes(position - input.getPosition()); + return stringBuilder.toString(); + } + + /** + * Sets the target of a {@link WebvttCssStyle} by splitting a selector of the form + * {@code ::cue(tag#id.class1.class2[voice="someone"]}, where every element is optional. + */ + private void applySelectorToStyle(WebvttCssStyle style, String selector) { + if ("".equals(selector)) { + return; // Universal selector. + } + int voiceStartIndex = selector.indexOf('['); + if (voiceStartIndex != -1) { + Matcher matcher = VOICE_NAME_PATTERN.matcher(selector.substring(voiceStartIndex)); + if (matcher.matches()) { + style.setTargetVoice(matcher.group(1)); + } + selector = selector.substring(0, voiceStartIndex); + } + String[] classDivision = Util.split(selector, "\\."); + String tagAndIdDivision = classDivision[0]; + int idPrefixIndex = tagAndIdDivision.indexOf('#'); + if (idPrefixIndex != -1) { + style.setTargetTagName(tagAndIdDivision.substring(0, idPrefixIndex)); + style.setTargetId(tagAndIdDivision.substring(idPrefixIndex + 1)); // We discard the '#'. + } else { + style.setTargetTagName(tagAndIdDivision); + } + if (classDivision.length > 1) { + style.setTargetClasses(Util.nullSafeArrayCopyOfRange(classDivision, 1, classDivision.length)); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java new file mode 100644 index 0000000000..3df35c789b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttDecoder.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** A {@link SimpleSubtitleDecoder} for Webvtt embedded in a Mp4 container file. */ +@SuppressWarnings("ConstantField") +public final class Mp4WebvttDecoder extends SimpleSubtitleDecoder { + + private static final int BOX_HEADER_SIZE = 8; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_payl = 0x7061796c; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_sttg = 0x73747467; + + @SuppressWarnings("ConstantCaseForConstants") + private static final int TYPE_vttc = 0x76747463; + + private final ParsableByteArray sampleData; + private final WebvttCue.Builder builder; + + public Mp4WebvttDecoder() { + super("Mp4WebvttDecoder"); + sampleData = new ParsableByteArray(); + builder = new WebvttCue.Builder(); + } + + @Override + protected Subtitle decode(byte[] bytes, int length, boolean reset) + throws SubtitleDecoderException { + // Webvtt in Mp4 samples have boxes inside of them, so we have to do a traditional box parsing: + // first 4 bytes size and then 4 bytes type. + sampleData.reset(bytes, length); + List<Cue> resultingCueList = new ArrayList<>(); + while (sampleData.bytesLeft() > 0) { + if (sampleData.bytesLeft() < BOX_HEADER_SIZE) { + throw new SubtitleDecoderException("Incomplete Mp4Webvtt Top Level box header found."); + } + int boxSize = sampleData.readInt(); + int boxType = sampleData.readInt(); + if (boxType == TYPE_vttc) { + resultingCueList.add(parseVttCueBox(sampleData, builder, boxSize - BOX_HEADER_SIZE)); + } else { + // Peers of the VTTCueBox are still not supported and are skipped. + sampleData.skipBytes(boxSize - BOX_HEADER_SIZE); + } + } + return new Mp4WebvttSubtitle(resultingCueList); + } + + private static Cue parseVttCueBox(ParsableByteArray sampleData, WebvttCue.Builder builder, + int remainingCueBoxBytes) throws SubtitleDecoderException { + builder.reset(); + while (remainingCueBoxBytes > 0) { + if (remainingCueBoxBytes < BOX_HEADER_SIZE) { + throw new SubtitleDecoderException("Incomplete vtt cue box header found."); + } + int boxSize = sampleData.readInt(); + int boxType = sampleData.readInt(); + remainingCueBoxBytes -= BOX_HEADER_SIZE; + int payloadLength = boxSize - BOX_HEADER_SIZE; + String boxPayload = + Util.fromUtf8Bytes(sampleData.data, sampleData.getPosition(), payloadLength); + sampleData.skipBytes(payloadLength); + remainingCueBoxBytes -= payloadLength; + if (boxType == TYPE_sttg) { + WebvttCueParser.parseCueSettingsList(boxPayload, builder); + } else if (boxType == TYPE_payl) { + WebvttCueParser.parseCueText(null, boxPayload.trim(), builder, Collections.emptyList()); + } else { + // Other VTTCueBox children are still not supported and are ignored. + } + } + return builder.build(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java new file mode 100644 index 0000000000..545e8b2511 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/Mp4WebvttSubtitle.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.Collections; +import java.util.List; + +/** + * Representation of a Webvtt subtitle embedded in a MP4 container file. + */ +/* package */ final class Mp4WebvttSubtitle implements Subtitle { + + private final List<Cue> cues; + + public Mp4WebvttSubtitle(List<Cue> cueList) { + cues = Collections.unmodifiableList(cueList); + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + return timeUs < 0 ? 0 : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return 1; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index == 0); + return 0; + } + + @Override + public List<Cue> getCues(long timeUs) { + return timeUs >= 0 ? cues : Collections.emptyList(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java new file mode 100644 index 0000000000..da37cfbdf3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCssStyle.java @@ -0,0 +1,329 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import android.graphics.Typeface; +import android.text.Layout; +import android.text.TextUtils; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; + +/** + * Style object of a Css style block in a Webvtt file. + * + * @see <a href="https://w3c.github.io/webvtt/#applying-css-properties">W3C specification - Apply + * CSS properties</a> + */ +public final class WebvttCssStyle { + + public static final int UNSPECIFIED = -1; + + /** + * Style flag enum. Possible flag values are {@link #UNSPECIFIED}, {@link #STYLE_NORMAL}, {@link + * #STYLE_BOLD}, {@link #STYLE_ITALIC} and {@link #STYLE_BOLD_ITALIC}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {UNSPECIFIED, STYLE_NORMAL, STYLE_BOLD, STYLE_ITALIC, STYLE_BOLD_ITALIC}) + public @interface StyleFlags {} + + public static final int STYLE_NORMAL = Typeface.NORMAL; + public static final int STYLE_BOLD = Typeface.BOLD; + public static final int STYLE_ITALIC = Typeface.ITALIC; + public static final int STYLE_BOLD_ITALIC = Typeface.BOLD_ITALIC; + + /** + * Font size unit enum. One of {@link #UNSPECIFIED}, {@link #FONT_SIZE_UNIT_PIXEL}, {@link + * #FONT_SIZE_UNIT_EM} or {@link #FONT_SIZE_UNIT_PERCENT}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNSPECIFIED, FONT_SIZE_UNIT_PIXEL, FONT_SIZE_UNIT_EM, FONT_SIZE_UNIT_PERCENT}) + public @interface FontSizeUnit {} + + public static final int FONT_SIZE_UNIT_PIXEL = 1; + public static final int FONT_SIZE_UNIT_EM = 2; + public static final int FONT_SIZE_UNIT_PERCENT = 3; + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({UNSPECIFIED, OFF, ON}) + private @interface OptionalBoolean {} + + private static final int OFF = 0; + private static final int ON = 1; + + // Selector properties. + private String targetId; + private String targetTag; + private List<String> targetClasses; + private String targetVoice; + + // Style properties. + @Nullable private String fontFamily; + private int fontColor; + private boolean hasFontColor; + private int backgroundColor; + private boolean hasBackgroundColor; + @OptionalBoolean private int linethrough; + @OptionalBoolean private int underline; + @OptionalBoolean private int bold; + @OptionalBoolean private int italic; + @FontSizeUnit private int fontSizeUnit; + private float fontSize; + @Nullable private Layout.Alignment textAlign; + + // Calling reset() is forbidden because `this` isn't initialized. This can be safely suppressed + // because reset() only assigns fields, it doesn't read any. + @SuppressWarnings("nullness:method.invocation.invalid") + public WebvttCssStyle() { + reset(); + } + + @EnsuresNonNull({"targetId", "targetTag", "targetClasses", "targetVoice"}) + public void reset() { + targetId = ""; + targetTag = ""; + targetClasses = Collections.emptyList(); + targetVoice = ""; + fontFamily = null; + hasFontColor = false; + hasBackgroundColor = false; + linethrough = UNSPECIFIED; + underline = UNSPECIFIED; + bold = UNSPECIFIED; + italic = UNSPECIFIED; + fontSizeUnit = UNSPECIFIED; + textAlign = null; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + public void setTargetTagName(String targetTag) { + this.targetTag = targetTag; + } + + public void setTargetClasses(String[] targetClasses) { + this.targetClasses = Arrays.asList(targetClasses); + } + + public void setTargetVoice(String targetVoice) { + this.targetVoice = targetVoice; + } + + /** + * Returns a value in a score system compliant with the CSS Specificity rules. + * + * @see <a href="https://www.w3.org/TR/CSS2/cascade.html">CSS Cascading</a> + * <p>The score works as follows: + * <ul> + * <li>Id match adds 0x40000000 to the score. + * <li>Each class and voice match adds 4 to the score. + * <li>Tag matching adds 2 to the score. + * <li>Universal selector matching scores 1. + * </ul> + * + * @param id The id of the cue if present, {@code null} otherwise. + * @param tag Name of the tag, {@code null} if it refers to the entire cue. + * @param classes An array containing the classes the tag belongs to. Must not be null. + * @param voice Annotated voice if present, {@code null} otherwise. + * @return The score of the match, zero if there is no match. + */ + public int getSpecificityScore( + @Nullable String id, @Nullable String tag, String[] classes, @Nullable String voice) { + if (targetId.isEmpty() && targetTag.isEmpty() && targetClasses.isEmpty() + && targetVoice.isEmpty()) { + // The selector is universal. It matches with the minimum score if and only if the given + // element is a whole cue. + return TextUtils.isEmpty(tag) ? 1 : 0; + } + int score = 0; + score = updateScoreForMatch(score, targetId, id, 0x40000000); + score = updateScoreForMatch(score, targetTag, tag, 2); + score = updateScoreForMatch(score, targetVoice, voice, 4); + if (score == -1 || !Arrays.asList(classes).containsAll(targetClasses)) { + return 0; + } else { + score += targetClasses.size() * 4; + } + return score; + } + + /** + * Returns the style or {@link #UNSPECIFIED} when no style information is given. + * + * @return {@link #UNSPECIFIED}, {@link #STYLE_NORMAL}, {@link #STYLE_BOLD}, {@link #STYLE_BOLD} + * or {@link #STYLE_BOLD_ITALIC}. + */ + @StyleFlags public int getStyle() { + if (bold == UNSPECIFIED && italic == UNSPECIFIED) { + return UNSPECIFIED; + } + return (bold == ON ? STYLE_BOLD : STYLE_NORMAL) + | (italic == ON ? STYLE_ITALIC : STYLE_NORMAL); + } + + public boolean isLinethrough() { + return linethrough == ON; + } + + public WebvttCssStyle setLinethrough(boolean linethrough) { + this.linethrough = linethrough ? ON : OFF; + return this; + } + + public boolean isUnderline() { + return underline == ON; + } + + public WebvttCssStyle setUnderline(boolean underline) { + this.underline = underline ? ON : OFF; + return this; + } + public WebvttCssStyle setBold(boolean bold) { + this.bold = bold ? ON : OFF; + return this; + } + + public WebvttCssStyle setItalic(boolean italic) { + this.italic = italic ? ON : OFF; + return this; + } + + @Nullable + public String getFontFamily() { + return fontFamily; + } + + public WebvttCssStyle setFontFamily(@Nullable String fontFamily) { + this.fontFamily = Util.toLowerInvariant(fontFamily); + return this; + } + + public int getFontColor() { + if (!hasFontColor) { + throw new IllegalStateException("Font color not defined"); + } + return fontColor; + } + + public WebvttCssStyle setFontColor(int color) { + this.fontColor = color; + hasFontColor = true; + return this; + } + + public boolean hasFontColor() { + return hasFontColor; + } + + public int getBackgroundColor() { + if (!hasBackgroundColor) { + throw new IllegalStateException("Background color not defined."); + } + return backgroundColor; + } + + public WebvttCssStyle setBackgroundColor(int backgroundColor) { + this.backgroundColor = backgroundColor; + hasBackgroundColor = true; + return this; + } + + public boolean hasBackgroundColor() { + return hasBackgroundColor; + } + + @Nullable + public Layout.Alignment getTextAlign() { + return textAlign; + } + + public WebvttCssStyle setTextAlign(@Nullable Layout.Alignment textAlign) { + this.textAlign = textAlign; + return this; + } + + public WebvttCssStyle setFontSize(float fontSize) { + this.fontSize = fontSize; + return this; + } + + public WebvttCssStyle setFontSizeUnit(short unit) { + this.fontSizeUnit = unit; + return this; + } + + @FontSizeUnit public int getFontSizeUnit() { + return fontSizeUnit; + } + + public float getFontSize() { + return fontSize; + } + + public void cascadeFrom(WebvttCssStyle style) { + if (style.hasFontColor) { + setFontColor(style.fontColor); + } + if (style.bold != UNSPECIFIED) { + bold = style.bold; + } + if (style.italic != UNSPECIFIED) { + italic = style.italic; + } + if (style.fontFamily != null) { + fontFamily = style.fontFamily; + } + if (linethrough == UNSPECIFIED) { + linethrough = style.linethrough; + } + if (underline == UNSPECIFIED) { + underline = style.underline; + } + if (textAlign == null) { + textAlign = style.textAlign; + } + if (fontSizeUnit == UNSPECIFIED) { + fontSizeUnit = style.fontSizeUnit; + fontSize = style.fontSize; + } + if (style.hasBackgroundColor) { + setBackgroundColor(style.backgroundColor); + } + } + + private static int updateScoreForMatch( + int currentScore, String target, @Nullable String actual, int score) { + if (target.isEmpty() || currentScore == -1) { + return currentScore; + } + return target.equals(actual) ? currentScore + score : -1; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java new file mode 100644 index 0000000000..af701d8f54 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCue.java @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import static java.lang.annotation.RetentionPolicy.SOURCE; + +import android.text.Layout.Alignment; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; + +/** A representation of a WebVTT cue. */ +public final class WebvttCue extends Cue { + + private static final float DEFAULT_POSITION = 0.5f; + + public final long startTime; + public final long endTime; + + private WebvttCue( + long startTime, + long endTime, + CharSequence text, + @Nullable Alignment textAlignment, + float line, + @Cue.LineType int lineType, + @Cue.AnchorType int lineAnchor, + float position, + @Cue.AnchorType int positionAnchor, + float width) { + super(text, textAlignment, line, lineType, lineAnchor, position, positionAnchor, width); + this.startTime = startTime; + this.endTime = endTime; + } + + /** + * Returns whether or not this cue should be placed in the default position and rolled-up with + * the other "normal" cues. + * + * @return Whether this cue should be placed in the default position. + */ + public boolean isNormalCue() { + return (line == DIMEN_UNSET && position == DEFAULT_POSITION); + } + + /** Builder for WebVTT cues. */ + @SuppressWarnings("hiding") + public static class Builder { + + /** + * Valid values for {@link #setTextAlignment(int)}. + * + * <p>We use a custom list (and not {@link Alignment} directly) in order to include both {@code + * START}/{@code LEFT} and {@code END}/{@code RIGHT}. The distinction is important for {@link + * #derivePosition(int)}. + * + * <p>These correspond to the valid values for the 'align' cue setting in the <a + * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment">WebVTT spec</a>. + */ + @Documented + @Retention(SOURCE) + @IntDef({ + TEXT_ALIGNMENT_START, + TEXT_ALIGNMENT_CENTER, + TEXT_ALIGNMENT_END, + TEXT_ALIGNMENT_LEFT, + TEXT_ALIGNMENT_RIGHT + }) + public @interface TextAlignment {} + /** + * See WebVTT's <a + * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-start-alignment">align:start</a>. + */ + public static final int TEXT_ALIGNMENT_START = 1; + + /** + * See WebVTT's <a + * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-center-alignment">align:center</a>. + */ + public static final int TEXT_ALIGNMENT_CENTER = 2; + + /** + * See WebVTT's <a href="https://www.w3.org/TR/webvtt1/#webvtt-cue-end-alignment">align:end</a>. + */ + public static final int TEXT_ALIGNMENT_END = 3; + + /** + * See WebVTT's <a href="https://www.w3.org/TR/webvtt1/#webvtt-cue-left-alignment">align:left</a>. + */ + public static final int TEXT_ALIGNMENT_LEFT = 4; + + /** + * See WebVTT's <a + * href="https://www.w3.org/TR/webvtt1/#webvtt-cue-right-alignment">align:right</a>. + */ + public static final int TEXT_ALIGNMENT_RIGHT = 5; + + private static final String TAG = "WebvttCueBuilder"; + + private long startTime; + private long endTime; + @Nullable private CharSequence text; + @TextAlignment private int textAlignment; + private float line; + // Equivalent to WebVTT's snap-to-lines flag: + // https://www.w3.org/TR/webvtt1/#webvtt-cue-snap-to-lines-flag + @LineType private int lineType; + @AnchorType private int lineAnchor; + private float position; + @AnchorType private int positionAnchor; + private float width; + + // Initialization methods + + // Calling reset() is forbidden because `this` isn't initialized. This can be safely + // suppressed because reset() only assigns fields, it doesn't read any. + @SuppressWarnings("nullness:method.invocation.invalid") + public Builder() { + reset(); + } + + public void reset() { + startTime = 0; + endTime = 0; + text = null; + // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment + textAlignment = TEXT_ALIGNMENT_CENTER; + line = Cue.DIMEN_UNSET; + // Defaults to NUMBER (true): https://www.w3.org/TR/webvtt1/#webvtt-cue-snap-to-lines-flag + lineType = Cue.LINE_TYPE_NUMBER; + // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-line-alignment + lineAnchor = Cue.ANCHOR_TYPE_START; + position = Cue.DIMEN_UNSET; + positionAnchor = Cue.TYPE_UNSET; + // Default: https://www.w3.org/TR/webvtt1/#webvtt-cue-size + width = 1.0f; + } + + // Construction methods. + + public WebvttCue build() { + line = computeLine(line, lineType); + + if (position == Cue.DIMEN_UNSET) { + position = derivePosition(textAlignment); + } + + if (positionAnchor == Cue.TYPE_UNSET) { + positionAnchor = derivePositionAnchor(textAlignment); + } + + width = Math.min(width, deriveMaxSize(positionAnchor, position)); + + return new WebvttCue( + startTime, + endTime, + Assertions.checkNotNull(text), + convertTextAlignment(textAlignment), + line, + lineType, + lineAnchor, + position, + positionAnchor, + width); + } + + public Builder setStartTime(long time) { + startTime = time; + return this; + } + + public Builder setEndTime(long time) { + endTime = time; + return this; + } + + public Builder setText(CharSequence text) { + this.text = text; + return this; + } + + public Builder setTextAlignment(@TextAlignment int textAlignment) { + this.textAlignment = textAlignment; + return this; + } + + public Builder setLine(float line) { + this.line = line; + return this; + } + + public Builder setLineType(@LineType int lineType) { + this.lineType = lineType; + return this; + } + + public Builder setLineAnchor(@AnchorType int lineAnchor) { + this.lineAnchor = lineAnchor; + return this; + } + + public Builder setPosition(float position) { + this.position = position; + return this; + } + + public Builder setPositionAnchor(@AnchorType int positionAnchor) { + this.positionAnchor = positionAnchor; + return this; + } + + public Builder setWidth(float width) { + this.width = width; + return this; + } + + // https://www.w3.org/TR/webvtt1/#webvtt-cue-line + private static float computeLine(float line, @LineType int lineType) { + if (line != Cue.DIMEN_UNSET + && lineType == Cue.LINE_TYPE_FRACTION + && (line < 0.0f || line > 1.0f)) { + return 1.0f; // Step 1 + } else if (line != Cue.DIMEN_UNSET) { + // Step 2: Do nothing, line is already correct. + return line; + } else if (lineType == Cue.LINE_TYPE_FRACTION) { + return 1.0f; // Step 3 + } else { + // Steps 4 - 10 (stacking multiple simultaneous cues) are handled by WebvttSubtitle#getCues + // and WebvttCue#isNormalCue. + return DIMEN_UNSET; + } + } + + // https://www.w3.org/TR/webvtt1/#webvtt-cue-position + private static float derivePosition(@TextAlignment int textAlignment) { + switch (textAlignment) { + case TEXT_ALIGNMENT_LEFT: + return 0.0f; + case TEXT_ALIGNMENT_RIGHT: + return 1.0f; + case TEXT_ALIGNMENT_START: + case TEXT_ALIGNMENT_CENTER: + case TEXT_ALIGNMENT_END: + default: + return DEFAULT_POSITION; + } + } + + // https://www.w3.org/TR/webvtt1/#webvtt-cue-position-alignment + @AnchorType + private static int derivePositionAnchor(@TextAlignment int textAlignment) { + switch (textAlignment) { + case TEXT_ALIGNMENT_LEFT: + case TEXT_ALIGNMENT_START: + return Cue.ANCHOR_TYPE_START; + case TEXT_ALIGNMENT_RIGHT: + case TEXT_ALIGNMENT_END: + return Cue.ANCHOR_TYPE_END; + case TEXT_ALIGNMENT_CENTER: + default: + return Cue.ANCHOR_TYPE_MIDDLE; + } + } + + @Nullable + private static Alignment convertTextAlignment(@TextAlignment int textAlignment) { + switch (textAlignment) { + case TEXT_ALIGNMENT_START: + case TEXT_ALIGNMENT_LEFT: + return Alignment.ALIGN_NORMAL; + case TEXT_ALIGNMENT_CENTER: + return Alignment.ALIGN_CENTER; + case TEXT_ALIGNMENT_END: + case TEXT_ALIGNMENT_RIGHT: + return Alignment.ALIGN_OPPOSITE; + default: + Log.w(TAG, "Unknown textAlignment: " + textAlignment); + return null; + } + } + + // Step 2 here: https://www.w3.org/TR/webvtt1/#processing-cue-settings + private static float deriveMaxSize(@AnchorType int positionAnchor, float position) { + switch (positionAnchor) { + case Cue.ANCHOR_TYPE_START: + return 1.0f - position; + case Cue.ANCHOR_TYPE_END: + return position; + case Cue.ANCHOR_TYPE_MIDDLE: + if (position <= 0.5f) { + return position * 2; + } else { + return (1.0f - position) * 2; + } + case Cue.TYPE_UNSET: + default: + throw new IllegalStateException(String.valueOf(positionAnchor)); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java new file mode 100644 index 0000000000..b370e67792 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttCueParser.java @@ -0,0 +1,550 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import android.graphics.Typeface; +import android.text.Layout; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.AbsoluteSizeSpan; +import android.text.style.AlignmentSpan; +import android.text.style.BackgroundColorSpan; +import android.text.style.ForegroundColorSpan; +import android.text.style.RelativeSizeSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; +import android.text.style.UnderlineSpan; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Parser for WebVTT cues. (https://w3c.github.io/webvtt/#cues) */ +public final class WebvttCueParser { + + public static final Pattern CUE_HEADER_PATTERN = Pattern + .compile("^(\\S+)\\s+-->\\s+(\\S+)(.*)?$"); + + private static final Pattern CUE_SETTING_PATTERN = Pattern.compile("(\\S+?):(\\S+)"); + + private static final char CHAR_LESS_THAN = '<'; + private static final char CHAR_GREATER_THAN = '>'; + private static final char CHAR_SLASH = '/'; + private static final char CHAR_AMPERSAND = '&'; + private static final char CHAR_SEMI_COLON = ';'; + private static final char CHAR_SPACE = ' '; + + private static final String ENTITY_LESS_THAN = "lt"; + private static final String ENTITY_GREATER_THAN = "gt"; + private static final String ENTITY_AMPERSAND = "amp"; + private static final String ENTITY_NON_BREAK_SPACE = "nbsp"; + + private static final String TAG_BOLD = "b"; + private static final String TAG_ITALIC = "i"; + private static final String TAG_UNDERLINE = "u"; + private static final String TAG_CLASS = "c"; + private static final String TAG_VOICE = "v"; + private static final String TAG_LANG = "lang"; + + private static final int STYLE_BOLD = Typeface.BOLD; + private static final int STYLE_ITALIC = Typeface.ITALIC; + + private static final String TAG = "WebvttCueParser"; + + private final StringBuilder textBuilder; + + public WebvttCueParser() { + textBuilder = new StringBuilder(); + } + + /** + * Parses the next valid WebVTT cue in a parsable array, including timestamps, settings and text. + * + * @param webvttData Parsable WebVTT file data. + * @param builder Builder for WebVTT Cues (output parameter). + * @param styles List of styles defined by the CSS style blocks preceding the cues. + * @return Whether a valid Cue was found. + */ + public boolean parseCue( + ParsableByteArray webvttData, WebvttCue.Builder builder, List<WebvttCssStyle> styles) { + @Nullable String firstLine = webvttData.readLine(); + if (firstLine == null) { + return false; + } + Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(firstLine); + if (cueHeaderMatcher.matches()) { + // We have found the timestamps in the first line. No id present. + return parseCue(null, cueHeaderMatcher, webvttData, builder, textBuilder, styles); + } + // The first line is not the timestamps, but could be the cue id. + @Nullable String secondLine = webvttData.readLine(); + if (secondLine == null) { + return false; + } + cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(secondLine); + if (cueHeaderMatcher.matches()) { + // We can do the rest of the parsing, including the id. + return parseCue(firstLine.trim(), cueHeaderMatcher, webvttData, builder, textBuilder, + styles); + } + return false; + } + + /** + * Parses a string containing a list of cue settings. + * + * @param cueSettingsList String containing the settings for a given cue. + * @param builder The {@link WebvttCue.Builder} where incremental construction takes place. + */ + /* package */ static void parseCueSettingsList(String cueSettingsList, + WebvttCue.Builder builder) { + // Parse the cue settings list. + Matcher cueSettingMatcher = CUE_SETTING_PATTERN.matcher(cueSettingsList); + while (cueSettingMatcher.find()) { + String name = cueSettingMatcher.group(1); + String value = cueSettingMatcher.group(2); + try { + if ("line".equals(name)) { + parseLineAttribute(value, builder); + } else if ("align".equals(name)) { + builder.setTextAlignment(parseTextAlignment(value)); + } else if ("position".equals(name)) { + parsePositionAttribute(value, builder); + } else if ("size".equals(name)) { + builder.setWidth(WebvttParserUtil.parsePercentage(value)); + } else { + Log.w(TAG, "Unknown cue setting " + name + ":" + value); + } + } catch (NumberFormatException e) { + Log.w(TAG, "Skipping bad cue setting: " + cueSettingMatcher.group()); + } + } + } + + /** + * Parses the text payload of a WebVTT Cue and applies modifications on {@link WebvttCue.Builder}. + * + * @param id Id of the cue, {@code null} if it is not present. + * @param markup The markup text to be parsed. + * @param styles List of styles defined by the CSS style blocks preceding the cues. + * @param builder Output builder. + */ + /* package */ static void parseCueText( + @Nullable String id, String markup, WebvttCue.Builder builder, List<WebvttCssStyle> styles) { + SpannableStringBuilder spannedText = new SpannableStringBuilder(); + ArrayDeque<StartTag> startTagStack = new ArrayDeque<>(); + List<StyleMatch> scratchStyleMatches = new ArrayList<>(); + int pos = 0; + while (pos < markup.length()) { + char curr = markup.charAt(pos); + switch (curr) { + case CHAR_LESS_THAN: + if (pos + 1 >= markup.length()) { + pos++; + break; // avoid ArrayOutOfBoundsException + } + int ltPos = pos; + boolean isClosingTag = markup.charAt(ltPos + 1) == CHAR_SLASH; + pos = findEndOfTag(markup, ltPos + 1); + boolean isVoidTag = markup.charAt(pos - 2) == CHAR_SLASH; + String fullTagExpression = markup.substring(ltPos + (isClosingTag ? 2 : 1), + isVoidTag ? pos - 2 : pos - 1); + if (fullTagExpression.trim().isEmpty()) { + continue; + } + String tagName = getTagName(fullTagExpression); + if (!isSupportedTag(tagName)) { + continue; + } + if (isClosingTag) { + StartTag startTag; + do { + if (startTagStack.isEmpty()) { + break; + } + startTag = startTagStack.pop(); + applySpansForTag(id, startTag, spannedText, styles, scratchStyleMatches); + } while(!startTag.name.equals(tagName)); + } else if (!isVoidTag) { + startTagStack.push(StartTag.buildStartTag(fullTagExpression, spannedText.length())); + } + break; + case CHAR_AMPERSAND: + int semiColonEndIndex = markup.indexOf(CHAR_SEMI_COLON, pos + 1); + int spaceEndIndex = markup.indexOf(CHAR_SPACE, pos + 1); + int entityEndIndex = semiColonEndIndex == -1 ? spaceEndIndex + : (spaceEndIndex == -1 ? semiColonEndIndex + : Math.min(semiColonEndIndex, spaceEndIndex)); + if (entityEndIndex != -1) { + applyEntity(markup.substring(pos + 1, entityEndIndex), spannedText); + if (entityEndIndex == spaceEndIndex) { + spannedText.append(" "); + } + pos = entityEndIndex + 1; + } else { + spannedText.append(curr); + pos++; + } + break; + default: + spannedText.append(curr); + pos++; + break; + } + } + // apply unclosed tags + while (!startTagStack.isEmpty()) { + applySpansForTag(id, startTagStack.pop(), spannedText, styles, scratchStyleMatches); + } + applySpansForTag(id, StartTag.buildWholeCueVirtualTag(), spannedText, styles, + scratchStyleMatches); + builder.setText(spannedText); + } + + private static boolean parseCue( + @Nullable String id, + Matcher cueHeaderMatcher, + ParsableByteArray webvttData, + WebvttCue.Builder builder, + StringBuilder textBuilder, + List<WebvttCssStyle> styles) { + try { + // Parse the cue start and end times. + builder.setStartTime(WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(1))) + .setEndTime(WebvttParserUtil.parseTimestampUs(cueHeaderMatcher.group(2))); + } catch (NumberFormatException e) { + Log.w(TAG, "Skipping cue with bad header: " + cueHeaderMatcher.group()); + return false; + } + + parseCueSettingsList(cueHeaderMatcher.group(3), builder); + + // Parse the cue text. + textBuilder.setLength(0); + for (String line = webvttData.readLine(); + !TextUtils.isEmpty(line); + line = webvttData.readLine()) { + if (textBuilder.length() > 0) { + textBuilder.append("\n"); + } + textBuilder.append(line.trim()); + } + parseCueText(id, textBuilder.toString(), builder, styles); + return true; + } + + // Internal methods + + private static void parseLineAttribute(String s, WebvttCue.Builder builder) { + int commaIndex = s.indexOf(','); + if (commaIndex != -1) { + builder.setLineAnchor(parsePositionAnchor(s.substring(commaIndex + 1))); + s = s.substring(0, commaIndex); + } + if (s.endsWith("%")) { + builder.setLine(WebvttParserUtil.parsePercentage(s)).setLineType(Cue.LINE_TYPE_FRACTION); + } else { + int lineNumber = Integer.parseInt(s); + if (lineNumber < 0) { + // WebVTT defines line -1 as last visible row when lineAnchor is ANCHOR_TYPE_START, where-as + // Cue defines it to be the first row that's not visible. + lineNumber--; + } + builder.setLine(lineNumber).setLineType(Cue.LINE_TYPE_NUMBER); + } + } + + private static void parsePositionAttribute(String s, WebvttCue.Builder builder) { + int commaIndex = s.indexOf(','); + if (commaIndex != -1) { + builder.setPositionAnchor(parsePositionAnchor(s.substring(commaIndex + 1))); + s = s.substring(0, commaIndex); + } + builder.setPosition(WebvttParserUtil.parsePercentage(s)); + } + + @Cue.AnchorType + private static int parsePositionAnchor(String s) { + switch (s) { + case "start": + return Cue.ANCHOR_TYPE_START; + case "center": + case "middle": + return Cue.ANCHOR_TYPE_MIDDLE; + case "end": + return Cue.ANCHOR_TYPE_END; + default: + Log.w(TAG, "Invalid anchor value: " + s); + return Cue.TYPE_UNSET; + } + } + + @WebvttCue.Builder.TextAlignment + private static int parseTextAlignment(String s) { + switch (s) { + case "start": + return WebvttCue.Builder.TEXT_ALIGNMENT_START; + case "left": + return WebvttCue.Builder.TEXT_ALIGNMENT_LEFT; + case "center": + case "middle": + return WebvttCue.Builder.TEXT_ALIGNMENT_CENTER; + case "end": + return WebvttCue.Builder.TEXT_ALIGNMENT_END; + case "right": + return WebvttCue.Builder.TEXT_ALIGNMENT_RIGHT; + default: + Log.w(TAG, "Invalid alignment value: " + s); + // Default value: https://www.w3.org/TR/webvtt1/#webvtt-cue-text-alignment + return WebvttCue.Builder.TEXT_ALIGNMENT_CENTER; + } + } + + /** + * Find end of tag (>). The position returned is the position of the > plus one (exclusive). + * + * @param markup The WebVTT cue markup to be parsed. + * @param startPos The position from where to start searching for the end of tag. + * @return The position of the end of tag plus 1 (one). + */ + private static int findEndOfTag(String markup, int startPos) { + int index = markup.indexOf(CHAR_GREATER_THAN, startPos); + return index == -1 ? markup.length() : index + 1; + } + + private static void applyEntity(String entity, SpannableStringBuilder spannedText) { + switch (entity) { + case ENTITY_LESS_THAN: + spannedText.append('<'); + break; + case ENTITY_GREATER_THAN: + spannedText.append('>'); + break; + case ENTITY_NON_BREAK_SPACE: + spannedText.append(' '); + break; + case ENTITY_AMPERSAND: + spannedText.append('&'); + break; + default: + Log.w(TAG, "ignoring unsupported entity: '&" + entity + ";'"); + break; + } + } + + private static boolean isSupportedTag(String tagName) { + switch (tagName) { + case TAG_BOLD: + case TAG_CLASS: + case TAG_ITALIC: + case TAG_LANG: + case TAG_UNDERLINE: + case TAG_VOICE: + return true; + default: + return false; + } + } + + private static void applySpansForTag( + @Nullable String cueId, + StartTag startTag, + SpannableStringBuilder text, + List<WebvttCssStyle> styles, + List<StyleMatch> scratchStyleMatches) { + int start = startTag.position; + int end = text.length(); + switch(startTag.name) { + case TAG_BOLD: + text.setSpan(new StyleSpan(STYLE_BOLD), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TAG_ITALIC: + text.setSpan(new StyleSpan(STYLE_ITALIC), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TAG_UNDERLINE: + text.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case TAG_CLASS: + case TAG_LANG: + case TAG_VOICE: + case "": // Case of the "whole cue" virtual tag. + break; + default: + return; + } + scratchStyleMatches.clear(); + getApplicableStyles(styles, cueId, startTag, scratchStyleMatches); + int styleMatchesCount = scratchStyleMatches.size(); + for (int i = 0; i < styleMatchesCount; i++) { + applyStyleToText(text, scratchStyleMatches.get(i).style, start, end); + } + } + + private static void applyStyleToText(SpannableStringBuilder spannedText, WebvttCssStyle style, + int start, int end) { + if (style == null) { + return; + } + if (style.getStyle() != WebvttCssStyle.UNSPECIFIED) { + spannedText.setSpan(new StyleSpan(style.getStyle()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.isLinethrough()) { + spannedText.setSpan(new StrikethroughSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.isUnderline()) { + spannedText.setSpan(new UnderlineSpan(), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.hasFontColor()) { + spannedText.setSpan(new ForegroundColorSpan(style.getFontColor()), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.hasBackgroundColor()) { + spannedText.setSpan(new BackgroundColorSpan(style.getBackgroundColor()), start, end, + Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + if (style.getFontFamily() != null) { + spannedText.setSpan(new TypefaceSpan(style.getFontFamily()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + Layout.Alignment textAlign = style.getTextAlign(); + if (textAlign != null) { + spannedText.setSpan( + new AlignmentSpan.Standard(textAlign), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + switch (style.getFontSizeUnit()) { + case WebvttCssStyle.FONT_SIZE_UNIT_PIXEL: + spannedText.setSpan(new AbsoluteSizeSpan((int) style.getFontSize(), true), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.FONT_SIZE_UNIT_EM: + spannedText.setSpan(new RelativeSizeSpan(style.getFontSize()), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.FONT_SIZE_UNIT_PERCENT: + spannedText.setSpan(new RelativeSizeSpan(style.getFontSize() / 100), start, end, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + break; + case WebvttCssStyle.UNSPECIFIED: + // Do nothing. + break; + } + } + + /** + * Returns the tag name for the given tag contents. + * + * @param tagExpression Characters between &lt: and &gt; of a start or end tag. + * @return The name of tag. + */ + private static String getTagName(String tagExpression) { + tagExpression = tagExpression.trim(); + Assertions.checkArgument(!tagExpression.isEmpty()); + return Util.splitAtFirst(tagExpression, "[ \\.]")[0]; + } + + private static void getApplicableStyles( + List<WebvttCssStyle> declaredStyles, + @Nullable String id, + StartTag tag, + List<StyleMatch> output) { + int styleCount = declaredStyles.size(); + for (int i = 0; i < styleCount; i++) { + WebvttCssStyle style = declaredStyles.get(i); + int score = style.getSpecificityScore(id, tag.name, tag.classes, tag.voice); + if (score > 0) { + output.add(new StyleMatch(score, style)); + } + } + Collections.sort(output); + } + + private static final class StyleMatch implements Comparable<StyleMatch> { + + public final int score; + public final WebvttCssStyle style; + + public StyleMatch(int score, WebvttCssStyle style) { + this.score = score; + this.style = style; + } + + @Override + public int compareTo(@NonNull StyleMatch another) { + return this.score - another.score; + } + + } + + private static final class StartTag { + + private static final String[] NO_CLASSES = new String[0]; + + public final String name; + public final int position; + public final String voice; + public final String[] classes; + + private StartTag(String name, int position, String voice, String[] classes) { + this.position = position; + this.name = name; + this.voice = voice; + this.classes = classes; + } + + public static StartTag buildStartTag(String fullTagExpression, int position) { + fullTagExpression = fullTagExpression.trim(); + Assertions.checkArgument(!fullTagExpression.isEmpty()); + int voiceStartIndex = fullTagExpression.indexOf(" "); + String voice; + if (voiceStartIndex == -1) { + voice = ""; + } else { + voice = fullTagExpression.substring(voiceStartIndex).trim(); + fullTagExpression = fullTagExpression.substring(0, voiceStartIndex); + } + String[] nameAndClasses = Util.split(fullTagExpression, "\\."); + String name = nameAndClasses[0]; + String[] classes; + if (nameAndClasses.length > 1) { + classes = Util.nullSafeArrayCopyOfRange(nameAndClasses, 1, nameAndClasses.length); + } else { + classes = NO_CLASSES; + } + return new StartTag(name, position, voice, classes); + } + + public static StartTag buildWholeCueVirtualTag() { + return new StartTag("", 0, "", new String[0]); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java new file mode 100644 index 0000000000..a70a49e82e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttDecoder.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import android.text.TextUtils; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SimpleSubtitleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.SubtitleDecoderException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.List; + +/** + * A {@link SimpleSubtitleDecoder} for WebVTT. + * <p> + * @see <a href="http://dev.w3.org/html5/webvtt">WebVTT specification</a> + */ +public final class WebvttDecoder extends SimpleSubtitleDecoder { + + private static final int EVENT_NONE = -1; + private static final int EVENT_END_OF_FILE = 0; + private static final int EVENT_COMMENT = 1; + private static final int EVENT_STYLE_BLOCK = 2; + private static final int EVENT_CUE = 3; + + private static final String COMMENT_START = "NOTE"; + private static final String STYLE_START = "STYLE"; + + private final WebvttCueParser cueParser; + private final ParsableByteArray parsableWebvttData; + private final WebvttCue.Builder webvttCueBuilder; + private final CssParser cssParser; + private final List<WebvttCssStyle> definedStyles; + + public WebvttDecoder() { + super("WebvttDecoder"); + cueParser = new WebvttCueParser(); + parsableWebvttData = new ParsableByteArray(); + webvttCueBuilder = new WebvttCue.Builder(); + cssParser = new CssParser(); + definedStyles = new ArrayList<>(); + } + + @Override + protected Subtitle decode(byte[] bytes, int length, boolean reset) + throws SubtitleDecoderException { + parsableWebvttData.reset(bytes, length); + // Initialization for consistent starting state. + webvttCueBuilder.reset(); + definedStyles.clear(); + + // Validate the first line of the header, and skip the remainder. + try { + WebvttParserUtil.validateWebvttHeaderLine(parsableWebvttData); + } catch (ParserException e) { + throw new SubtitleDecoderException(e); + } + while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {} + + int event; + ArrayList<WebvttCue> subtitles = new ArrayList<>(); + while ((event = getNextEvent(parsableWebvttData)) != EVENT_END_OF_FILE) { + if (event == EVENT_COMMENT) { + skipComment(parsableWebvttData); + } else if (event == EVENT_STYLE_BLOCK) { + if (!subtitles.isEmpty()) { + throw new SubtitleDecoderException("A style block was found after the first cue."); + } + parsableWebvttData.readLine(); // Consume the "STYLE" header. + definedStyles.addAll(cssParser.parseBlock(parsableWebvttData)); + } else if (event == EVENT_CUE) { + if (cueParser.parseCue(parsableWebvttData, webvttCueBuilder, definedStyles)) { + subtitles.add(webvttCueBuilder.build()); + webvttCueBuilder.reset(); + } + } + } + return new WebvttSubtitle(subtitles); + } + + /** + * Positions the input right before the next event, and returns the kind of event found. Does not + * consume any data from such event, if any. + * + * @return The kind of event found. + */ + private static int getNextEvent(ParsableByteArray parsableWebvttData) { + int foundEvent = EVENT_NONE; + int currentInputPosition = 0; + while (foundEvent == EVENT_NONE) { + currentInputPosition = parsableWebvttData.getPosition(); + String line = parsableWebvttData.readLine(); + if (line == null) { + foundEvent = EVENT_END_OF_FILE; + } else if (STYLE_START.equals(line)) { + foundEvent = EVENT_STYLE_BLOCK; + } else if (line.startsWith(COMMENT_START)) { + foundEvent = EVENT_COMMENT; + } else { + foundEvent = EVENT_CUE; + } + } + parsableWebvttData.setPosition(currentInputPosition); + return foundEvent; + } + + private static void skipComment(ParsableByteArray parsableWebvttData) { + while (!TextUtils.isEmpty(parsableWebvttData.readLine())) {} + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java new file mode 100644 index 0000000000..b87d014de0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttParserUtil.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utility methods for parsing WebVTT data. + */ +public final class WebvttParserUtil { + + private static final Pattern COMMENT = Pattern.compile("^NOTE([ \t].*)?$"); + private static final String WEBVTT_HEADER = "WEBVTT"; + + private WebvttParserUtil() {} + + /** + * Reads and validates the first line of a WebVTT file. + * + * @param input The input from which the line should be read. + * @throws ParserException If the line isn't the start of a valid WebVTT file. + */ + public static void validateWebvttHeaderLine(ParsableByteArray input) throws ParserException { + int startPosition = input.getPosition(); + if (!isWebvttHeaderLine(input)) { + input.setPosition(startPosition); + throw new ParserException("Expected WEBVTT. Got " + input.readLine()); + } + } + + /** + * Returns whether the given input is the first line of a WebVTT file. + * + * @param input The input from which the line should be read. + */ + public static boolean isWebvttHeaderLine(ParsableByteArray input) { + @Nullable String line = input.readLine(); + return line != null && line.startsWith(WEBVTT_HEADER); + } + + /** + * Parses a WebVTT timestamp. + * + * @param timestamp The timestamp string. + * @return The parsed timestamp in microseconds. + * @throws NumberFormatException If the timestamp could not be parsed. + */ + public static long parseTimestampUs(String timestamp) throws NumberFormatException { + long value = 0; + String[] parts = Util.splitAtFirst(timestamp, "\\."); + String[] subparts = Util.split(parts[0], ":"); + for (String subpart : subparts) { + value = (value * 60) + Long.parseLong(subpart); + } + value *= 1000; + if (parts.length == 2) { + value += Long.parseLong(parts[1]); + } + return value * 1000; + } + + /** + * Parses a percentage string. + * + * @param s The percentage string. + * @return The parsed value, where 1.0 represents 100%. + * @throws NumberFormatException If the percentage could not be parsed. + */ + public static float parsePercentage(String s) throws NumberFormatException { + if (!s.endsWith("%")) { + throw new NumberFormatException("Percentages must end with %"); + } + return Float.parseFloat(s.substring(0, s.length() - 1)) / 100; + } + + /** + * Reads lines up to and including the next WebVTT cue header. + * + * @param input The input from which lines should be read. + * @return A {@link Matcher} for the WebVTT cue header, or null if the end of the input was + * reached without a cue header being found. In the case that a cue header is found, groups 1, + * 2 and 3 of the returned matcher contain the start time, end time and settings list. + */ + @Nullable + public static Matcher findNextCueHeader(ParsableByteArray input) { + @Nullable String line; + while ((line = input.readLine()) != null) { + if (COMMENT.matcher(line).matches()) { + // Skip until the end of the comment block. + while ((line = input.readLine()) != null && !line.isEmpty()) {} + } else { + Matcher cueHeaderMatcher = WebvttCueParser.CUE_HEADER_PATTERN.matcher(line); + if (cueHeaderMatcher.matches()) { + return cueHeaderMatcher; + } + } + } + return null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java new file mode 100644 index 0000000000..558c699eba --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/WebvttSubtitle.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import android.text.SpannableStringBuilder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Cue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.text.Subtitle; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * A representation of a WebVTT subtitle. + */ +/* package */ final class WebvttSubtitle implements Subtitle { + + private final List<WebvttCue> cues; + private final int numCues; + private final long[] cueTimesUs; + private final long[] sortedCueTimesUs; + + /** + * @param cues A list of the cues in this subtitle. + */ + public WebvttSubtitle(List<WebvttCue> cues) { + this.cues = cues; + numCues = cues.size(); + cueTimesUs = new long[2 * numCues]; + for (int cueIndex = 0; cueIndex < numCues; cueIndex++) { + WebvttCue cue = cues.get(cueIndex); + int arrayIndex = cueIndex * 2; + cueTimesUs[arrayIndex] = cue.startTime; + cueTimesUs[arrayIndex + 1] = cue.endTime; + } + sortedCueTimesUs = Arrays.copyOf(cueTimesUs, cueTimesUs.length); + Arrays.sort(sortedCueTimesUs); + } + + @Override + public int getNextEventTimeIndex(long timeUs) { + int index = Util.binarySearchCeil(sortedCueTimesUs, timeUs, false, false); + return index < sortedCueTimesUs.length ? index : C.INDEX_UNSET; + } + + @Override + public int getEventTimeCount() { + return sortedCueTimesUs.length; + } + + @Override + public long getEventTime(int index) { + Assertions.checkArgument(index >= 0); + Assertions.checkArgument(index < sortedCueTimesUs.length); + return sortedCueTimesUs[index]; + } + + @Override + public List<Cue> getCues(long timeUs) { + List<Cue> list = new ArrayList<>(); + WebvttCue firstNormalCue = null; + SpannableStringBuilder normalCueTextBuilder = null; + + for (int i = 0; i < numCues; i++) { + if ((cueTimesUs[i * 2] <= timeUs) && (timeUs < cueTimesUs[i * 2 + 1])) { + WebvttCue cue = cues.get(i); + // TODO(ibaker): Replace this with a closer implementation of the WebVTT spec (keeping + // individual cues, but tweaking their `line` value): + // https://www.w3.org/TR/webvtt1/#cue-computed-line + if (cue.isNormalCue()) { + // we want to merge all of the normal cues into a single cue to ensure they are drawn + // correctly (i.e. don't overlap) and to emulate roll-up, but only if there are multiple + // normal cues, otherwise we can just append the single normal cue + if (firstNormalCue == null) { + firstNormalCue = cue; + } else if (normalCueTextBuilder == null) { + normalCueTextBuilder = new SpannableStringBuilder(); + normalCueTextBuilder + .append(Assertions.checkNotNull(firstNormalCue.text)) + .append("\n") + .append(Assertions.checkNotNull(cue.text)); + } else { + normalCueTextBuilder.append("\n").append(Assertions.checkNotNull(cue.text)); + } + } else { + list.add(cue); + } + } + } + if (normalCueTextBuilder != null) { + // there were multiple normal cues, so create a new cue with all of the text + list.add(new WebvttCue.Builder().setText(normalCueTextBuilder).build()); + } else if (firstNormalCue != null) { + // there was only a single normal cue, so just add it to the list + list.add(firstNormalCue); + } + return list; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java new file mode 100644 index 0000000000..e2c014d539 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/text/webvtt/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.text.webvtt; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java new file mode 100644 index 0000000000..33f8606e9b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java @@ -0,0 +1,761 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SimpleExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A bandwidth based adaptive {@link TrackSelection}, whose selected track is updated to be the one + * of highest quality given the current network conditions and the state of the buffer. + */ +public class AdaptiveTrackSelection extends BaseTrackSelection { + + /** Factory for {@link AdaptiveTrackSelection} instances. */ + public static class Factory implements TrackSelection.Factory { + + @Nullable private final BandwidthMeter bandwidthMeter; + private final int minDurationForQualityIncreaseMs; + private final int maxDurationForQualityDecreaseMs; + private final int minDurationToRetainAfterDiscardMs; + private final float bandwidthFraction; + private final float bufferedFractionToLiveEdgeForQualityIncrease; + private final long minTimeBetweenBufferReevaluationMs; + private final Clock clock; + + /** Creates an adaptive track selection factory with default parameters. */ + public Factory() { + this( + DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + DEFAULT_BANDWIDTH_FRACTION, + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); + } + + /** + * @deprecated Use {@link #Factory()} instead. Custom bandwidth meter should be directly passed + * to the player in {@link SimpleExoPlayer.Builder}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public Factory(BandwidthMeter bandwidthMeter) { + this( + bandwidthMeter, + DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + DEFAULT_BANDWIDTH_FRACTION, + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); + } + + /** + * Creates an adaptive track selection factory. + * + * @param minDurationForQualityIncreaseMs The minimum duration of buffered data required for the + * selected track to switch to one of higher quality. + * @param maxDurationForQualityDecreaseMs The maximum duration of buffered data required for the + * selected track to switch to one of lower quality. + * @param minDurationToRetainAfterDiscardMs When switching to a track of significantly higher + * quality, the selection may indicate that media already buffered at the lower quality can + * be discarded to speed up the switch. This is the minimum duration of media that must be + * retained at the lower quality. + * @param bandwidthFraction The fraction of the available bandwidth that the selection should + * consider available for use. Setting to a value less than 1 is recommended to account for + * inaccuracies in the bandwidth estimator. + */ + public Factory( + int minDurationForQualityIncreaseMs, + int maxDurationForQualityDecreaseMs, + int minDurationToRetainAfterDiscardMs, + float bandwidthFraction) { + this( + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bandwidthFraction, + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); + } + + /** + * @deprecated Use {@link #Factory(int, int, int, float)} instead. Custom bandwidth meter should + * be directly passed to the player in {@link SimpleExoPlayer.Builder}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public Factory( + BandwidthMeter bandwidthMeter, + int minDurationForQualityIncreaseMs, + int maxDurationForQualityDecreaseMs, + int minDurationToRetainAfterDiscardMs, + float bandwidthFraction) { + this( + bandwidthMeter, + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bandwidthFraction, + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); + } + + /** + * Creates an adaptive track selection factory. + * + * @param minDurationForQualityIncreaseMs The minimum duration of buffered data required for the + * selected track to switch to one of higher quality. + * @param maxDurationForQualityDecreaseMs The maximum duration of buffered data required for the + * selected track to switch to one of lower quality. + * @param minDurationToRetainAfterDiscardMs When switching to a track of significantly higher + * quality, the selection may indicate that media already buffered at the lower quality can + * be discarded to speed up the switch. This is the minimum duration of media that must be + * retained at the lower quality. + * @param bandwidthFraction The fraction of the available bandwidth that the selection should + * consider available for use. Setting to a value less than 1 is recommended to account for + * inaccuracies in the bandwidth estimator. + * @param bufferedFractionToLiveEdgeForQualityIncrease For live streaming, the fraction of the + * duration from current playback position to the live edge that has to be buffered before + * the selected track can be switched to one of higher quality. This parameter is only + * applied when the playback position is closer to the live edge than {@code + * minDurationForQualityIncreaseMs}, which would otherwise prevent switching to a higher + * quality from happening. + * @param minTimeBetweenBufferReevaluationMs The track selection may periodically reevaluate its + * buffer and discard some chunks of lower quality to improve the playback quality if + * network conditions have changed. This is the minimum duration between 2 consecutive + * buffer reevaluation calls. + * @param clock A {@link Clock}. + */ + @SuppressWarnings("deprecation") + public Factory( + int minDurationForQualityIncreaseMs, + int maxDurationForQualityDecreaseMs, + int minDurationToRetainAfterDiscardMs, + float bandwidthFraction, + float bufferedFractionToLiveEdgeForQualityIncrease, + long minTimeBetweenBufferReevaluationMs, + Clock clock) { + this( + /* bandwidthMeter= */ null, + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bandwidthFraction, + bufferedFractionToLiveEdgeForQualityIncrease, + minTimeBetweenBufferReevaluationMs, + clock); + } + + /** + * @deprecated Use {@link #Factory(int, int, int, float, float, long, Clock)} instead. Custom + * bandwidth meter should be directly passed to the player in {@link + * SimpleExoPlayer.Builder}. + */ + @Deprecated + public Factory( + @Nullable BandwidthMeter bandwidthMeter, + int minDurationForQualityIncreaseMs, + int maxDurationForQualityDecreaseMs, + int minDurationToRetainAfterDiscardMs, + float bandwidthFraction, + float bufferedFractionToLiveEdgeForQualityIncrease, + long minTimeBetweenBufferReevaluationMs, + Clock clock) { + this.bandwidthMeter = bandwidthMeter; + this.minDurationForQualityIncreaseMs = minDurationForQualityIncreaseMs; + this.maxDurationForQualityDecreaseMs = maxDurationForQualityDecreaseMs; + this.minDurationToRetainAfterDiscardMs = minDurationToRetainAfterDiscardMs; + this.bandwidthFraction = bandwidthFraction; + this.bufferedFractionToLiveEdgeForQualityIncrease = + bufferedFractionToLiveEdgeForQualityIncrease; + this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; + this.clock = clock; + } + + @Override + public final @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + if (this.bandwidthMeter != null) { + bandwidthMeter = this.bandwidthMeter; + } + TrackSelection[] selections = new TrackSelection[definitions.length]; + int totalFixedBandwidth = 0; + for (int i = 0; i < definitions.length; i++) { + Definition definition = definitions[i]; + if (definition != null && definition.tracks.length == 1) { + // Make fixed selections first to know their total bandwidth. + selections[i] = + new FixedTrackSelection( + definition.group, definition.tracks[0], definition.reason, definition.data); + int trackBitrate = definition.group.getFormat(definition.tracks[0]).bitrate; + if (trackBitrate != Format.NO_VALUE) { + totalFixedBandwidth += trackBitrate; + } + } + } + List<AdaptiveTrackSelection> adaptiveSelections = new ArrayList<>(); + for (int i = 0; i < definitions.length; i++) { + Definition definition = definitions[i]; + if (definition != null && definition.tracks.length > 1) { + AdaptiveTrackSelection adaptiveSelection = + createAdaptiveTrackSelection( + definition.group, bandwidthMeter, definition.tracks, totalFixedBandwidth); + adaptiveSelections.add(adaptiveSelection); + selections[i] = adaptiveSelection; + } + } + if (adaptiveSelections.size() > 1) { + long[][] adaptiveTrackBitrates = new long[adaptiveSelections.size()][]; + for (int i = 0; i < adaptiveSelections.size(); i++) { + AdaptiveTrackSelection adaptiveSelection = adaptiveSelections.get(i); + adaptiveTrackBitrates[i] = new long[adaptiveSelection.length()]; + for (int j = 0; j < adaptiveSelection.length(); j++) { + adaptiveTrackBitrates[i][j] = + adaptiveSelection.getFormat(adaptiveSelection.length() - j - 1).bitrate; + } + } + long[][][] bandwidthCheckpoints = getAllocationCheckpoints(adaptiveTrackBitrates); + for (int i = 0; i < adaptiveSelections.size(); i++) { + adaptiveSelections + .get(i) + .experimental_setBandwidthAllocationCheckpoints(bandwidthCheckpoints[i]); + } + } + return selections; + } + + /** + * Creates a single adaptive selection for the given group, bandwidth meter and tracks. + * + * @param group The {@link TrackGroup}. + * @param bandwidthMeter A {@link BandwidthMeter} which can be used to select tracks. + * @param tracks The indices of the selected tracks in the track group. + * @param totalFixedTrackBandwidth The total bandwidth used by all non-adaptive tracks, in bits + * per second. + * @return An {@link AdaptiveTrackSelection} for the specified tracks. + */ + protected AdaptiveTrackSelection createAdaptiveTrackSelection( + TrackGroup group, + BandwidthMeter bandwidthMeter, + int[] tracks, + int totalFixedTrackBandwidth) { + return new AdaptiveTrackSelection( + group, + tracks, + new DefaultBandwidthProvider(bandwidthMeter, bandwidthFraction, totalFixedTrackBandwidth), + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bufferedFractionToLiveEdgeForQualityIncrease, + minTimeBetweenBufferReevaluationMs, + clock); + } + } + + public static final int DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS = 10000; + public static final int DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS = 25000; + public static final int DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS = 25000; + public static final float DEFAULT_BANDWIDTH_FRACTION = 0.7f; + public static final float DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE = 0.75f; + public static final long DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS = 2000; + + private final BandwidthProvider bandwidthProvider; + private final long minDurationForQualityIncreaseUs; + private final long maxDurationForQualityDecreaseUs; + private final long minDurationToRetainAfterDiscardUs; + private final float bufferedFractionToLiveEdgeForQualityIncrease; + private final long minTimeBetweenBufferReevaluationMs; + private final Clock clock; + + private float playbackSpeed; + private int selectedIndex; + private int reason; + private long lastBufferEvaluationMs; + + /** + * @param group The {@link TrackGroup}. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * empty. May be in any order. + * @param bandwidthMeter Provides an estimate of the currently available bandwidth. + */ + public AdaptiveTrackSelection(TrackGroup group, int[] tracks, + BandwidthMeter bandwidthMeter) { + this( + group, + tracks, + bandwidthMeter, + /* reservedBandwidth= */ 0, + DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + DEFAULT_BANDWIDTH_FRACTION, + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); + } + + /** + * @param group The {@link TrackGroup}. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * empty. May be in any order. + * @param bandwidthMeter Provides an estimate of the currently available bandwidth. + * @param reservedBandwidth The reserved bandwidth, which shouldn't be considered available for + * use, in bits per second. + * @param minDurationForQualityIncreaseMs The minimum duration of buffered data required for the + * selected track to switch to one of higher quality. + * @param maxDurationForQualityDecreaseMs The maximum duration of buffered data required for the + * selected track to switch to one of lower quality. + * @param minDurationToRetainAfterDiscardMs When switching to a track of significantly higher + * quality, the selection may indicate that media already buffered at the lower quality can be + * discarded to speed up the switch. This is the minimum duration of media that must be + * retained at the lower quality. + * @param bandwidthFraction The fraction of the available bandwidth that the selection should + * consider available for use. Setting to a value less than 1 is recommended to account for + * inaccuracies in the bandwidth estimator. + * @param bufferedFractionToLiveEdgeForQualityIncrease For live streaming, the fraction of the + * duration from current playback position to the live edge that has to be buffered before the + * selected track can be switched to one of higher quality. This parameter is only applied + * when the playback position is closer to the live edge than {@code + * minDurationForQualityIncreaseMs}, which would otherwise prevent switching to a higher + * quality from happening. + * @param minTimeBetweenBufferReevaluationMs The track selection may periodically reevaluate its + * buffer and discard some chunks of lower quality to improve the playback quality if network + * condition has changed. This is the minimum duration between 2 consecutive buffer + * reevaluation calls. + */ + public AdaptiveTrackSelection( + TrackGroup group, + int[] tracks, + BandwidthMeter bandwidthMeter, + long reservedBandwidth, + long minDurationForQualityIncreaseMs, + long maxDurationForQualityDecreaseMs, + long minDurationToRetainAfterDiscardMs, + float bandwidthFraction, + float bufferedFractionToLiveEdgeForQualityIncrease, + long minTimeBetweenBufferReevaluationMs, + Clock clock) { + this( + group, + tracks, + new DefaultBandwidthProvider(bandwidthMeter, bandwidthFraction, reservedBandwidth), + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bufferedFractionToLiveEdgeForQualityIncrease, + minTimeBetweenBufferReevaluationMs, + clock); + } + + private AdaptiveTrackSelection( + TrackGroup group, + int[] tracks, + BandwidthProvider bandwidthProvider, + long minDurationForQualityIncreaseMs, + long maxDurationForQualityDecreaseMs, + long minDurationToRetainAfterDiscardMs, + float bufferedFractionToLiveEdgeForQualityIncrease, + long minTimeBetweenBufferReevaluationMs, + Clock clock) { + super(group, tracks); + this.bandwidthProvider = bandwidthProvider; + this.minDurationForQualityIncreaseUs = minDurationForQualityIncreaseMs * 1000L; + this.maxDurationForQualityDecreaseUs = maxDurationForQualityDecreaseMs * 1000L; + this.minDurationToRetainAfterDiscardUs = minDurationToRetainAfterDiscardMs * 1000L; + this.bufferedFractionToLiveEdgeForQualityIncrease = + bufferedFractionToLiveEdgeForQualityIncrease; + this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; + this.clock = clock; + playbackSpeed = 1f; + reason = C.SELECTION_REASON_UNKNOWN; + lastBufferEvaluationMs = C.TIME_UNSET; + } + + /** + * Sets checkpoints to determine the allocation bandwidth based on the total bandwidth. + * + * @param allocationCheckpoints List of checkpoints. Each element must be a long[2], with [0] + * being the total bandwidth and [1] being the allocated bandwidth. + */ + public void experimental_setBandwidthAllocationCheckpoints(long[][] allocationCheckpoints) { + ((DefaultBandwidthProvider) bandwidthProvider) + .experimental_setBandwidthAllocationCheckpoints(allocationCheckpoints); + } + + @Override + public void enable() { + lastBufferEvaluationMs = C.TIME_UNSET; + } + + @Override + public void onPlaybackSpeed(float playbackSpeed) { + this.playbackSpeed = playbackSpeed; + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + long nowMs = clock.elapsedRealtime(); + + // Make initial selection + if (reason == C.SELECTION_REASON_UNKNOWN) { + reason = C.SELECTION_REASON_INITIAL; + selectedIndex = determineIdealSelectedIndex(nowMs); + return; + } + + // Stash the current selection, then make a new one. + int currentSelectedIndex = selectedIndex; + selectedIndex = determineIdealSelectedIndex(nowMs); + if (selectedIndex == currentSelectedIndex) { + return; + } + + if (!isBlacklisted(currentSelectedIndex, nowMs)) { + // Revert back to the current selection if conditions are not suitable for switching. + Format currentFormat = getFormat(currentSelectedIndex); + Format selectedFormat = getFormat(selectedIndex); + if (selectedFormat.bitrate > currentFormat.bitrate + && bufferedDurationUs < minDurationForQualityIncreaseUs(availableDurationUs)) { + // The selected track is a higher quality, but we have insufficient buffer to safely switch + // up. Defer switching up for now. + selectedIndex = currentSelectedIndex; + } else if (selectedFormat.bitrate < currentFormat.bitrate + && bufferedDurationUs >= maxDurationForQualityDecreaseUs) { + // The selected track is a lower quality, but we have sufficient buffer to defer switching + // down for now. + selectedIndex = currentSelectedIndex; + } + } + // If we adapted, update the trigger. + if (selectedIndex != currentSelectedIndex) { + reason = C.SELECTION_REASON_ADAPTIVE; + } + } + + @Override + public int getSelectedIndex() { + return selectedIndex; + } + + @Override + public int getSelectionReason() { + return reason; + } + + @Override + @Nullable + public Object getSelectionData() { + return null; + } + + @Override + public int evaluateQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue) { + long nowMs = clock.elapsedRealtime(); + if (!shouldEvaluateQueueSize(nowMs)) { + return queue.size(); + } + + lastBufferEvaluationMs = nowMs; + if (queue.isEmpty()) { + return 0; + } + + int queueSize = queue.size(); + MediaChunk lastChunk = queue.get(queueSize - 1); + long playoutBufferedDurationBeforeLastChunkUs = + Util.getPlayoutDurationForMediaDuration( + lastChunk.startTimeUs - playbackPositionUs, playbackSpeed); + long minDurationToRetainAfterDiscardUs = getMinDurationToRetainAfterDiscardUs(); + if (playoutBufferedDurationBeforeLastChunkUs < minDurationToRetainAfterDiscardUs) { + return queueSize; + } + int idealSelectedIndex = determineIdealSelectedIndex(nowMs); + Format idealFormat = getFormat(idealSelectedIndex); + // If the chunks contain video, discard from the first SD chunk beyond + // minDurationToRetainAfterDiscardUs whose resolution and bitrate are both lower than the ideal + // track. + for (int i = 0; i < queueSize; i++) { + MediaChunk chunk = queue.get(i); + Format format = chunk.trackFormat; + long mediaDurationBeforeThisChunkUs = chunk.startTimeUs - playbackPositionUs; + long playoutDurationBeforeThisChunkUs = + Util.getPlayoutDurationForMediaDuration(mediaDurationBeforeThisChunkUs, playbackSpeed); + if (playoutDurationBeforeThisChunkUs >= minDurationToRetainAfterDiscardUs + && format.bitrate < idealFormat.bitrate + && format.height != Format.NO_VALUE && format.height < 720 + && format.width != Format.NO_VALUE && format.width < 1280 + && format.height < idealFormat.height) { + return i; + } + } + return queueSize; + } + + /** + * Called when updating the selected track to determine whether a candidate track can be selected. + * + * @param format The {@link Format} of the candidate track. + * @param trackBitrate The estimated bitrate of the track. May differ from {@link Format#bitrate} + * if a more accurate estimate of the current track bitrate is available. + * @param playbackSpeed The current playback speed. + * @param effectiveBitrate The bitrate available to this selection. + * @return Whether this {@link Format} can be selected. + */ + @SuppressWarnings("unused") + protected boolean canSelectFormat( + Format format, int trackBitrate, float playbackSpeed, long effectiveBitrate) { + return Math.round(trackBitrate * playbackSpeed) <= effectiveBitrate; + } + + /** + * Called from {@link #evaluateQueueSize(long, List)} to determine whether an evaluation should be + * performed. + * + * @param nowMs The current value of {@link Clock#elapsedRealtime()}. + * @return Whether an evaluation should be performed. + */ + protected boolean shouldEvaluateQueueSize(long nowMs) { + return lastBufferEvaluationMs == C.TIME_UNSET + || nowMs - lastBufferEvaluationMs >= minTimeBetweenBufferReevaluationMs; + } + + /** + * Called from {@link #evaluateQueueSize(long, List)} to determine the minimum duration of buffer + * to retain after discarding chunks. + * + * @return The minimum duration of buffer to retain after discarding chunks, in microseconds. + */ + protected long getMinDurationToRetainAfterDiscardUs() { + return minDurationToRetainAfterDiscardUs; + } + + /** + * Computes the ideal selected index ignoring buffer health. + * + * @param nowMs The current time in the timebase of {@link Clock#elapsedRealtime()}, or {@link + * Long#MIN_VALUE} to ignore blacklisting. + */ + private int determineIdealSelectedIndex(long nowMs) { + long effectiveBitrate = bandwidthProvider.getAllocatedBandwidth(); + int lowestBitrateNonBlacklistedIndex = 0; + for (int i = 0; i < length; i++) { + if (nowMs == Long.MIN_VALUE || !isBlacklisted(i, nowMs)) { + Format format = getFormat(i); + if (canSelectFormat(format, format.bitrate, playbackSpeed, effectiveBitrate)) { + return i; + } else { + lowestBitrateNonBlacklistedIndex = i; + } + } + } + return lowestBitrateNonBlacklistedIndex; + } + + private long minDurationForQualityIncreaseUs(long availableDurationUs) { + boolean isAvailableDurationTooShort = availableDurationUs != C.TIME_UNSET + && availableDurationUs <= minDurationForQualityIncreaseUs; + return isAvailableDurationTooShort + ? (long) (availableDurationUs * bufferedFractionToLiveEdgeForQualityIncrease) + : minDurationForQualityIncreaseUs; + } + + /** Provides the allocated bandwidth. */ + private interface BandwidthProvider { + + /** Returns the allocated bitrate. */ + long getAllocatedBandwidth(); + } + + private static final class DefaultBandwidthProvider implements BandwidthProvider { + + private final BandwidthMeter bandwidthMeter; + private final float bandwidthFraction; + private final long reservedBandwidth; + + @Nullable private long[][] allocationCheckpoints; + + /* package */ + // the constructor does not initialize fields: allocationCheckpoints + @SuppressWarnings("nullness:initialization.fields.uninitialized") + DefaultBandwidthProvider( + BandwidthMeter bandwidthMeter, float bandwidthFraction, long reservedBandwidth) { + this.bandwidthMeter = bandwidthMeter; + this.bandwidthFraction = bandwidthFraction; + this.reservedBandwidth = reservedBandwidth; + } + + // unboxing a possibly-null reference allocationCheckpoints[nextIndex][0] + @SuppressWarnings("nullness:unboxing.of.nullable") + @Override + public long getAllocatedBandwidth() { + long totalBandwidth = (long) (bandwidthMeter.getBitrateEstimate() * bandwidthFraction); + long allocatableBandwidth = Math.max(0L, totalBandwidth - reservedBandwidth); + if (allocationCheckpoints == null) { + return allocatableBandwidth; + } + int nextIndex = 1; + while (nextIndex < allocationCheckpoints.length - 1 + && allocationCheckpoints[nextIndex][0] < allocatableBandwidth) { + nextIndex++; + } + long[] previous = allocationCheckpoints[nextIndex - 1]; + long[] next = allocationCheckpoints[nextIndex]; + float fractionBetweenCheckpoints = + (float) (allocatableBandwidth - previous[0]) / (next[0] - previous[0]); + return previous[1] + (long) (fractionBetweenCheckpoints * (next[1] - previous[1])); + } + + /* package */ void experimental_setBandwidthAllocationCheckpoints( + long[][] allocationCheckpoints) { + Assertions.checkArgument(allocationCheckpoints.length >= 2); + this.allocationCheckpoints = allocationCheckpoints; + } + } + + /** + * Returns allocation checkpoints for allocating bandwidth between multiple adaptive track + * selections. + * + * @param trackBitrates Array of [selectionIndex][trackIndex] -> trackBitrate. + * @return Array of allocation checkpoints [selectionIndex][checkpointIndex][2] with [0]=total + * bandwidth at checkpoint and [1]=allocated bandwidth at checkpoint. + */ + private static long[][][] getAllocationCheckpoints(long[][] trackBitrates) { + // Algorithm: + // 1. Use log bitrates to treat all resolution update steps equally. + // 2. Distribute switch points for each selection equally in the same [0.0-1.0] range. + // 3. Switch up one format at a time in the order of the switch points. + double[][] logBitrates = getLogArrayValues(trackBitrates); + double[][] switchPoints = getSwitchPoints(logBitrates); + + // There will be (count(switch point) + 3) checkpoints: + // [0] = all zero, [1] = minimum bitrates, [2-(end-1)] = up-switch points, + // [end] = extra point to set slope for additional bitrate. + int checkpointCount = countArrayElements(switchPoints) + 3; + long[][][] checkpoints = new long[logBitrates.length][checkpointCount][2]; + int[] currentSelection = new int[logBitrates.length]; + setCheckpointValues(checkpoints, /* checkpointIndex= */ 1, trackBitrates, currentSelection); + for (int checkpointIndex = 2; checkpointIndex < checkpointCount - 1; checkpointIndex++) { + int nextUpdateIndex = 0; + double nextUpdateSwitchPoint = Double.MAX_VALUE; + for (int i = 0; i < logBitrates.length; i++) { + if (currentSelection[i] + 1 == logBitrates[i].length) { + continue; + } + double switchPoint = switchPoints[i][currentSelection[i]]; + if (switchPoint < nextUpdateSwitchPoint) { + nextUpdateSwitchPoint = switchPoint; + nextUpdateIndex = i; + } + } + currentSelection[nextUpdateIndex]++; + setCheckpointValues(checkpoints, checkpointIndex, trackBitrates, currentSelection); + } + for (long[][] points : checkpoints) { + points[checkpointCount - 1][0] = 2 * points[checkpointCount - 2][0]; + points[checkpointCount - 1][1] = 2 * points[checkpointCount - 2][1]; + } + return checkpoints; + } + + /** Converts all input values to Math.log(value). */ + private static double[][] getLogArrayValues(long[][] values) { + double[][] logValues = new double[values.length][]; + for (int i = 0; i < values.length; i++) { + logValues[i] = new double[values[i].length]; + for (int j = 0; j < values[i].length; j++) { + logValues[i][j] = values[i][j] == Format.NO_VALUE ? 0 : Math.log(values[i][j]); + } + } + return logValues; + } + + /** + * Returns idealized switch points for each switch between consecutive track selection bitrates. + * + * @param logBitrates Log bitrates with [selectionCount][formatCount]. + * @return Linearly distributed switch points in the range of [0.0-1.0]. + */ + private static double[][] getSwitchPoints(double[][] logBitrates) { + double[][] switchPoints = new double[logBitrates.length][]; + for (int i = 0; i < logBitrates.length; i++) { + switchPoints[i] = new double[logBitrates[i].length - 1]; + if (switchPoints[i].length == 0) { + continue; + } + double totalBitrateDiff = logBitrates[i][logBitrates[i].length - 1] - logBitrates[i][0]; + for (int j = 0; j < logBitrates[i].length - 1; j++) { + double switchBitrate = 0.5 * (logBitrates[i][j] + logBitrates[i][j + 1]); + switchPoints[i][j] = + totalBitrateDiff == 0.0 ? 1.0 : (switchBitrate - logBitrates[i][0]) / totalBitrateDiff; + } + } + return switchPoints; + } + + /** Returns total number of elements in a 2D array. */ + private static int countArrayElements(double[][] array) { + int count = 0; + for (double[] subArray : array) { + count += subArray.length; + } + return count; + } + + /** + * Sets checkpoint bitrates. + * + * @param checkpoints Output checkpoints with [selectionIndex][checkpointIndex][2] where [0]=Total + * bitrate and [1]=Allocated bitrate. + * @param checkpointIndex The checkpoint index. + * @param trackBitrates The track bitrates with [selectionIndex][trackIndex]. + * @param selectedTracks The indices of selected tracks for each selection for this checkpoint. + */ + private static void setCheckpointValues( + long[][][] checkpoints, int checkpointIndex, long[][] trackBitrates, int[] selectedTracks) { + long totalBitrate = 0; + for (int i = 0; i < checkpoints.length; i++) { + checkpoints[i][checkpointIndex][1] = trackBitrates[i][selectedTracks[i]]; + totalBitrate += checkpoints[i][checkpointIndex][1]; + } + for (long[][] points : checkpoints) { + points[checkpointIndex][0] = totalBitrate; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java new file mode 100644 index 0000000000..d7e94cb561 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/BaseTrackSelection.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** + * An abstract base class suitable for most {@link TrackSelection} implementations. + */ +public abstract class BaseTrackSelection implements TrackSelection { + + /** + * The selected {@link TrackGroup}. + */ + protected final TrackGroup group; + /** + * The number of selected tracks within the {@link TrackGroup}. Always greater than zero. + */ + protected final int length; + /** + * The indices of the selected tracks in {@link #group}, in order of decreasing bandwidth. + */ + protected final int[] tracks; + + /** + * The {@link Format}s of the selected tracks, in order of decreasing bandwidth. + */ + private final Format[] formats; + /** + * Selected track blacklist timestamps, in order of decreasing bandwidth. + */ + private final long[] blacklistUntilTimes; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * null or empty. May be in any order. + */ + public BaseTrackSelection(TrackGroup group, int... tracks) { + Assertions.checkState(tracks.length > 0); + this.group = Assertions.checkNotNull(group); + this.length = tracks.length; + // Set the formats, sorted in order of decreasing bandwidth. + formats = new Format[length]; + for (int i = 0; i < tracks.length; i++) { + formats[i] = group.getFormat(tracks[i]); + } + Arrays.sort(formats, new DecreasingBandwidthComparator()); + // Set the format indices in the same order. + this.tracks = new int[length]; + for (int i = 0; i < length; i++) { + this.tracks[i] = group.indexOf(formats[i]); + } + blacklistUntilTimes = new long[length]; + } + + @Override + public void enable() { + // Do nothing. + } + + @Override + public void disable() { + // Do nothing. + } + + @Override + public final TrackGroup getTrackGroup() { + return group; + } + + @Override + public final int length() { + return tracks.length; + } + + @Override + public final Format getFormat(int index) { + return formats[index]; + } + + @Override + public final int getIndexInTrackGroup(int index) { + return tracks[index]; + } + + @Override + @SuppressWarnings("ReferenceEquality") + public final int indexOf(Format format) { + for (int i = 0; i < length; i++) { + if (formats[i] == format) { + return i; + } + } + return C.INDEX_UNSET; + } + + @Override + public final int indexOf(int indexInTrackGroup) { + for (int i = 0; i < length; i++) { + if (tracks[i] == indexInTrackGroup) { + return i; + } + } + return C.INDEX_UNSET; + } + + @Override + public final Format getSelectedFormat() { + return formats[getSelectedIndex()]; + } + + @Override + public final int getSelectedIndexInTrackGroup() { + return tracks[getSelectedIndex()]; + } + + @Override + public void onPlaybackSpeed(float playbackSpeed) { + // Do nothing. + } + + @Override + public int evaluateQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue) { + return queue.size(); + } + + @Override + public final boolean blacklist(int index, long blacklistDurationMs) { + long nowMs = SystemClock.elapsedRealtime(); + boolean canBlacklist = isBlacklisted(index, nowMs); + for (int i = 0; i < length && !canBlacklist; i++) { + canBlacklist = i != index && !isBlacklisted(i, nowMs); + } + if (!canBlacklist) { + return false; + } + blacklistUntilTimes[index] = + Math.max( + blacklistUntilTimes[index], + Util.addWithOverflowDefault(nowMs, blacklistDurationMs, Long.MAX_VALUE)); + return true; + } + + /** + * Returns whether the track at the specified index in the selection is blacklisted. + * + * @param index The index of the track in the selection. + * @param nowMs The current time in the timebase of {@link SystemClock#elapsedRealtime()}. + */ + protected final boolean isBlacklisted(int index, long nowMs) { + return blacklistUntilTimes[index] > nowMs; + } + + // Object overrides. + + @Override + public int hashCode() { + if (hashCode == 0) { + hashCode = 31 * System.identityHashCode(group) + Arrays.hashCode(tracks); + } + return hashCode; + } + + // Track groups are compared by identity not value, as distinct groups may have the same value. + @Override + @SuppressWarnings({"ReferenceEquality", "EqualsGetClass"}) + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + BaseTrackSelection other = (BaseTrackSelection) obj; + return group == other.group && Arrays.equals(tracks, other.tracks); + } + + /** + * Sorts {@link Format} objects in order of decreasing bandwidth. + */ + private static final class DecreasingBandwidthComparator implements Comparator<Format> { + + @Override + public int compare(Format a, Format b) { + return b.bitrate - a.bitrate; + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java new file mode 100644 index 0000000000..735889bfaa --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/BufferSizeAdaptationBuilder.java @@ -0,0 +1,494 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import android.util.Pair; +import androidx.annotation.Nullable; +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.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.LoadControl; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection.Definition; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Builder for a {@link TrackSelection.Factory} and {@link LoadControl} that implement buffer size + * based track adaptation. + */ +public final class BufferSizeAdaptationBuilder { + + /** Dynamic filter for formats, which is applied when selecting a new track. */ + public interface DynamicFormatFilter { + + /** Filter which allows all formats. */ + DynamicFormatFilter NO_FILTER = (format, trackBitrate, isInitialSelection) -> true; + + /** + * Called when updating the selected track to determine whether a candidate track is allowed. If + * no format is allowed or eligible, the lowest quality format will be used. + * + * @param format The {@link Format} of the candidate track. + * @param trackBitrate The estimated bitrate of the track. May differ from {@link + * Format#bitrate} if a more accurate estimate of the current track bitrate is available. + * @param isInitialSelection Whether this is for the initial track selection. + */ + boolean isFormatAllowed(Format format, int trackBitrate, boolean isInitialSelection); + } + + /** + * The default minimum duration of media that the player will attempt to ensure is buffered at all + * times, in milliseconds. + */ + public static final int DEFAULT_MIN_BUFFER_MS = 15000; + + /** + * The default maximum duration of media that the player will attempt to buffer, in milliseconds. + */ + public static final int DEFAULT_MAX_BUFFER_MS = 50000; + + /** + * The default duration of media that must be buffered for playback to start or resume following a + * user action such as a seek, in milliseconds. + */ + public static final int DEFAULT_BUFFER_FOR_PLAYBACK_MS = + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS; + + /** + * The default duration of media that must be buffered for playback to resume after a rebuffer, in + * milliseconds. A rebuffer is defined to be caused by buffer depletion rather than a user action. + */ + public static final int DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS = + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; + + /** + * The default offset the current duration of buffered media must deviate from the ideal duration + * of buffered media for the currently selected format, before the selected format is changed. + */ + public static final int DEFAULT_HYSTERESIS_BUFFER_MS = 5000; + + /** + * During start-up phase, the default fraction of the available bandwidth that the selection + * should consider available for use. Setting to a value less than 1 is recommended to account for + * inaccuracies in the bandwidth estimator. + */ + public static final float DEFAULT_START_UP_BANDWIDTH_FRACTION = + AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION; + + /** + * During start-up phase, the default minimum duration of buffered media required for the selected + * track to switch to one of higher quality based on measured bandwidth. + */ + public static final int DEFAULT_START_UP_MIN_BUFFER_FOR_QUALITY_INCREASE_MS = + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS; + + @Nullable private DefaultAllocator allocator; + private Clock clock; + private int minBufferMs; + private int maxBufferMs; + private int bufferForPlaybackMs; + private int bufferForPlaybackAfterRebufferMs; + private int hysteresisBufferMs; + private float startUpBandwidthFraction; + private int startUpMinBufferForQualityIncreaseMs; + private DynamicFormatFilter dynamicFormatFilter; + private boolean buildCalled; + + /** Creates builder with default values. */ + public BufferSizeAdaptationBuilder() { + clock = Clock.DEFAULT; + minBufferMs = DEFAULT_MIN_BUFFER_MS; + maxBufferMs = DEFAULT_MAX_BUFFER_MS; + bufferForPlaybackMs = DEFAULT_BUFFER_FOR_PLAYBACK_MS; + bufferForPlaybackAfterRebufferMs = DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS; + hysteresisBufferMs = DEFAULT_HYSTERESIS_BUFFER_MS; + startUpBandwidthFraction = DEFAULT_START_UP_BANDWIDTH_FRACTION; + startUpMinBufferForQualityIncreaseMs = DEFAULT_START_UP_MIN_BUFFER_FOR_QUALITY_INCREASE_MS; + dynamicFormatFilter = DynamicFormatFilter.NO_FILTER; + } + + /** + * Set the clock to use. Should only be set for testing purposes. + * + * @param clock The {@link Clock}. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #buildPlayerComponents()} has already been called. + */ + public BufferSizeAdaptationBuilder setClock(Clock clock) { + Assertions.checkState(!buildCalled); + this.clock = clock; + return this; + } + + /** + * Sets the {@link DefaultAllocator} used by the loader. + * + * @param allocator The {@link DefaultAllocator}. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #buildPlayerComponents()} has already been called. + */ + public BufferSizeAdaptationBuilder setAllocator(DefaultAllocator allocator) { + Assertions.checkState(!buildCalled); + this.allocator = allocator; + return this; + } + + /** + * Sets the buffer duration parameters. + * + * @param minBufferMs The minimum duration of media that the player will attempt to ensure is + * buffered at all times, in milliseconds. + * @param maxBufferMs The maximum duration of media that the player will attempt to buffer, in + * milliseconds. + * @param bufferForPlaybackMs The duration of media that must be buffered for playback to start or + * resume following a user action such as a seek, in milliseconds. + * @param bufferForPlaybackAfterRebufferMs The default duration of media that must be buffered for + * playback to resume after a rebuffer, in milliseconds. A rebuffer is defined to be caused by + * buffer depletion rather than a user action. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #buildPlayerComponents()} has already been called. + */ + public BufferSizeAdaptationBuilder setBufferDurationsMs( + int minBufferMs, + int maxBufferMs, + int bufferForPlaybackMs, + int bufferForPlaybackAfterRebufferMs) { + Assertions.checkState(!buildCalled); + this.minBufferMs = minBufferMs; + this.maxBufferMs = maxBufferMs; + this.bufferForPlaybackMs = bufferForPlaybackMs; + this.bufferForPlaybackAfterRebufferMs = bufferForPlaybackAfterRebufferMs; + return this; + } + + /** + * Sets the hysteresis buffer used to prevent repeated format switching. + * + * @param hysteresisBufferMs The offset the current duration of buffered media must deviate from + * the ideal duration of buffered media for the currently selected format, before the selected + * format is changed. This value must be smaller than {@code maxBufferMs - minBufferMs}. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #buildPlayerComponents()} has already been called. + */ + public BufferSizeAdaptationBuilder setHysteresisBufferMs(int hysteresisBufferMs) { + Assertions.checkState(!buildCalled); + this.hysteresisBufferMs = hysteresisBufferMs; + return this; + } + + /** + * Sets track selection parameters used during the start-up phase before the selection can be made + * purely on based on buffer size. During the start-up phase the selection is based on the current + * bandwidth estimate. + * + * @param bandwidthFraction The fraction of the available bandwidth that the selection should + * consider available for use. Setting to a value less than 1 is recommended to account for + * inaccuracies in the bandwidth estimator. + * @param minBufferForQualityIncreaseMs The minimum duration of buffered media required for the + * selected track to switch to one of higher quality. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #buildPlayerComponents()} has already been called. + */ + public BufferSizeAdaptationBuilder setStartUpTrackSelectionParameters( + float bandwidthFraction, int minBufferForQualityIncreaseMs) { + Assertions.checkState(!buildCalled); + this.startUpBandwidthFraction = bandwidthFraction; + this.startUpMinBufferForQualityIncreaseMs = minBufferForQualityIncreaseMs; + return this; + } + + /** + * Sets the {@link DynamicFormatFilter} to use when updating the selected track. + * + * @param dynamicFormatFilter The {@link DynamicFormatFilter}. + * @return This builder, for convenience. + * @throws IllegalStateException If {@link #buildPlayerComponents()} has already been called. + */ + public BufferSizeAdaptationBuilder setDynamicFormatFilter( + DynamicFormatFilter dynamicFormatFilter) { + Assertions.checkState(!buildCalled); + this.dynamicFormatFilter = dynamicFormatFilter; + return this; + } + + /** + * Builds player components for buffer size based track adaptation. + * + * @return A pair of a {@link TrackSelection.Factory} and a {@link LoadControl}, which should be + * used to construct the player. + */ + public Pair<TrackSelection.Factory, LoadControl> buildPlayerComponents() { + Assertions.checkArgument(hysteresisBufferMs < maxBufferMs - minBufferMs); + Assertions.checkState(!buildCalled); + buildCalled = true; + + DefaultLoadControl.Builder loadControlBuilder = + new DefaultLoadControl.Builder() + .setTargetBufferBytes(/* targetBufferBytes = */ Integer.MAX_VALUE) + .setBufferDurationsMs( + /* minBufferMs= */ maxBufferMs, + maxBufferMs, + bufferForPlaybackMs, + bufferForPlaybackAfterRebufferMs); + if (allocator != null) { + loadControlBuilder.setAllocator(allocator); + } + + TrackSelection.Factory trackSelectionFactory = + new TrackSelection.Factory() { + @Override + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> + new BufferSizeAdaptiveTrackSelection( + definition.group, + definition.tracks, + bandwidthMeter, + minBufferMs, + maxBufferMs, + hysteresisBufferMs, + startUpBandwidthFraction, + startUpMinBufferForQualityIncreaseMs, + dynamicFormatFilter, + clock)); + } + }; + + return Pair.create(trackSelectionFactory, loadControlBuilder.createDefaultLoadControl()); + } + + private static final class BufferSizeAdaptiveTrackSelection extends BaseTrackSelection { + + private static final int BITRATE_BLACKLISTED = Format.NO_VALUE; + + private final BandwidthMeter bandwidthMeter; + private final Clock clock; + private final DynamicFormatFilter dynamicFormatFilter; + private final int[] formatBitrates; + private final long minBufferUs; + private final long maxBufferUs; + private final long hysteresisBufferUs; + private final float startUpBandwidthFraction; + private final long startUpMinBufferForQualityIncreaseUs; + private final int minBitrate; + private final int maxBitrate; + private final double bitrateToBufferFunctionSlope; + private final double bitrateToBufferFunctionIntercept; + + private boolean isInSteadyState; + private int selectedIndex; + private int selectionReason; + private float playbackSpeed; + + private BufferSizeAdaptiveTrackSelection( + TrackGroup trackGroup, + int[] tracks, + BandwidthMeter bandwidthMeter, + int minBufferMs, + int maxBufferMs, + int hysteresisBufferMs, + float startUpBandwidthFraction, + int startUpMinBufferForQualityIncreaseMs, + DynamicFormatFilter dynamicFormatFilter, + Clock clock) { + super(trackGroup, tracks); + this.bandwidthMeter = bandwidthMeter; + this.minBufferUs = C.msToUs(minBufferMs); + this.maxBufferUs = C.msToUs(maxBufferMs); + this.hysteresisBufferUs = C.msToUs(hysteresisBufferMs); + this.startUpBandwidthFraction = startUpBandwidthFraction; + this.startUpMinBufferForQualityIncreaseUs = C.msToUs(startUpMinBufferForQualityIncreaseMs); + this.dynamicFormatFilter = dynamicFormatFilter; + this.clock = clock; + + formatBitrates = new int[length]; + maxBitrate = getFormat(/* index= */ 0).bitrate; + minBitrate = getFormat(/* index= */ length - 1).bitrate; + selectionReason = C.SELECTION_REASON_UNKNOWN; + playbackSpeed = 1.0f; + + // We use a log-linear function to map from bitrate to buffer size: + // buffer = slope * ln(bitrate) + intercept, + // with buffer(minBitrate) = minBuffer and buffer(maxBitrate) = maxBuffer - hysteresisBuffer. + bitrateToBufferFunctionSlope = + (maxBufferUs - hysteresisBufferUs - minBufferUs) + / Math.log((double) maxBitrate / minBitrate); + bitrateToBufferFunctionIntercept = + minBufferUs - bitrateToBufferFunctionSlope * Math.log(minBitrate); + } + + @Override + public void onPlaybackSpeed(float playbackSpeed) { + this.playbackSpeed = playbackSpeed; + } + + @Override + public void onDiscontinuity() { + isInSteadyState = false; + } + + @Override + public int getSelectedIndex() { + return selectedIndex; + } + + @Override + public int getSelectionReason() { + return selectionReason; + } + + @Override + @Nullable + public Object getSelectionData() { + return null; + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + updateFormatBitrates(/* nowMs= */ clock.elapsedRealtime()); + + // Make initial selection + if (selectionReason == C.SELECTION_REASON_UNKNOWN) { + selectionReason = C.SELECTION_REASON_INITIAL; + selectedIndex = selectIdealIndexUsingBandwidth(/* isInitialSelection= */ true); + return; + } + + long bufferUs = getCurrentPeriodBufferedDurationUs(playbackPositionUs, bufferedDurationUs); + int oldSelectedIndex = selectedIndex; + if (isInSteadyState) { + selectIndexSteadyState(bufferUs); + } else { + selectIndexStartUpPhase(bufferUs); + } + if (selectedIndex != oldSelectedIndex) { + selectionReason = C.SELECTION_REASON_ADAPTIVE; + } + } + + // Steady state. + + private void selectIndexSteadyState(long bufferUs) { + if (isOutsideHysteresis(bufferUs)) { + selectedIndex = selectIdealIndexUsingBufferSize(bufferUs); + } + } + + private boolean isOutsideHysteresis(long bufferUs) { + if (formatBitrates[selectedIndex] == BITRATE_BLACKLISTED) { + return true; + } + long targetBufferForCurrentBitrateUs = + getTargetBufferForBitrateUs(formatBitrates[selectedIndex]); + long bufferDiffUs = bufferUs - targetBufferForCurrentBitrateUs; + return Math.abs(bufferDiffUs) > hysteresisBufferUs; + } + + private int selectIdealIndexUsingBufferSize(long bufferUs) { + int lowestBitrateNonBlacklistedIndex = 0; + for (int i = 0; i < formatBitrates.length; i++) { + if (formatBitrates[i] != BITRATE_BLACKLISTED) { + if (getTargetBufferForBitrateUs(formatBitrates[i]) <= bufferUs + && dynamicFormatFilter.isFormatAllowed( + getFormat(i), formatBitrates[i], /* isInitialSelection= */ false)) { + return i; + } + lowestBitrateNonBlacklistedIndex = i; + } + } + return lowestBitrateNonBlacklistedIndex; + } + + // Startup. + + private void selectIndexStartUpPhase(long bufferUs) { + int startUpSelectedIndex = selectIdealIndexUsingBandwidth(/* isInitialSelection= */ false); + int steadyStateSelectedIndex = selectIdealIndexUsingBufferSize(bufferUs); + if (steadyStateSelectedIndex <= selectedIndex) { + // Switch to steady state if we have enough buffer to maintain current selection. + selectedIndex = steadyStateSelectedIndex; + isInSteadyState = true; + } else { + if (bufferUs < startUpMinBufferForQualityIncreaseUs + && startUpSelectedIndex < selectedIndex + && formatBitrates[selectedIndex] != BITRATE_BLACKLISTED) { + // Switching up from a non-blacklisted track is only allowed if we have enough buffer. + return; + } + selectedIndex = startUpSelectedIndex; + } + } + + private int selectIdealIndexUsingBandwidth(boolean isInitialSelection) { + long effectiveBitrate = + (long) (bandwidthMeter.getBitrateEstimate() * startUpBandwidthFraction); + int lowestBitrateNonBlacklistedIndex = 0; + for (int i = 0; i < formatBitrates.length; i++) { + if (formatBitrates[i] != BITRATE_BLACKLISTED) { + if (Math.round(formatBitrates[i] * playbackSpeed) <= effectiveBitrate + && dynamicFormatFilter.isFormatAllowed( + getFormat(i), formatBitrates[i], isInitialSelection)) { + return i; + } + lowestBitrateNonBlacklistedIndex = i; + } + } + return lowestBitrateNonBlacklistedIndex; + } + + // Utility methods. + + private void updateFormatBitrates(long nowMs) { + for (int i = 0; i < length; i++) { + if (nowMs == Long.MIN_VALUE || !isBlacklisted(i, nowMs)) { + formatBitrates[i] = getFormat(i).bitrate; + } else { + formatBitrates[i] = BITRATE_BLACKLISTED; + } + } + } + + private long getTargetBufferForBitrateUs(int bitrate) { + if (bitrate <= minBitrate) { + return minBufferUs; + } + if (bitrate >= maxBitrate) { + return maxBufferUs - hysteresisBufferUs; + } + return (int) + (bitrateToBufferFunctionSlope * Math.log(bitrate) + bitrateToBufferFunctionIntercept); + } + + private static long getCurrentPeriodBufferedDurationUs( + long playbackPositionUs, long bufferedDurationUs) { + return playbackPositionUs >= 0 ? bufferedDurationUs : playbackPositionUs + bufferedDurationUs; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java new file mode 100644 index 0000000000..549e5991b9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/DefaultTrackSelector.java @@ -0,0 +1,2827 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import android.content.Context; +import android.graphics.Point; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Pair; +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import androidx.annotation.Nullable; +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.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Renderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.AdaptiveSupport; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.Capabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.FormatSupport; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererConfiguration; +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.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.checkerframework.checker.initialization.qual.UnderInitialization; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A default {@link TrackSelector} suitable for most use cases. Track selections are made according + * to configurable {@link Parameters}, which can be set by calling {@link + * #setParameters(Parameters)}. + * + * <h3>Modifying parameters</h3> + * + * To modify only some aspects of the parameters currently used by a selector, it's possible to + * obtain a {@link ParametersBuilder} initialized with the current {@link Parameters}. The desired + * modifications can be made on the builder, and the resulting {@link Parameters} can then be built + * and set on the selector. For example the following code modifies the parameters to restrict video + * track selections to SD, and to select a German audio track if there is one: + * + * <pre>{@code + * // Build on the current parameters. + * Parameters currentParameters = trackSelector.getParameters(); + * // Build the resulting parameters. + * Parameters newParameters = currentParameters + * .buildUpon() + * .setMaxVideoSizeSd() + * .setPreferredAudioLanguage("deu") + * .build(); + * // Set the new parameters. + * trackSelector.setParameters(newParameters); + * }</pre> + * + * Convenience methods and chaining allow this to be written more concisely as: + * + * <pre>{@code + * trackSelector.setParameters( + * trackSelector + * .buildUponParameters() + * .setMaxVideoSizeSd() + * .setPreferredAudioLanguage("deu")); + * }</pre> + * + * Selection {@link Parameters} support many different options, some of which are described below. + * + * <h3>Selecting specific tracks</h3> + * + * Track selection overrides can be used to select specific tracks. To specify an override for a + * renderer, it's first necessary to obtain the tracks that have been mapped to it: + * + * <pre>{@code + * MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + * TrackGroupArray rendererTrackGroups = mappedTrackInfo == null ? null + * : mappedTrackInfo.getTrackGroups(rendererIndex); + * }</pre> + * + * If {@code rendererTrackGroups} is null then there aren't any currently mapped tracks, and so + * setting an override isn't possible. Note that a {@link Player.EventListener} registered on the + * player can be used to determine when the current tracks (and therefore the mapping) changes. If + * {@code rendererTrackGroups} is non-null then an override can be set. The next step is to query + * the properties of the available tracks to determine the {@code groupIndex} and the {@code + * trackIndices} within the group it that should be selected. The override can then be specified + * using {@link ParametersBuilder#setSelectionOverride}: + * + * <pre>{@code + * SelectionOverride selectionOverride = new SelectionOverride(groupIndex, trackIndices); + * trackSelector.setParameters( + * trackSelector + * .buildUponParameters() + * .setSelectionOverride(rendererIndex, rendererTrackGroups, selectionOverride)); + * }</pre> + * + * <h3>Constraint based track selection</h3> + * + * Whilst track selection overrides make it possible to select specific tracks, the recommended way + * of controlling which tracks are selected is by specifying constraints. For example consider the + * case of wanting to restrict video track selections to SD, and preferring German audio tracks. + * Track selection overrides could be used to select specific tracks meeting these criteria, however + * a simpler and more flexible approach is to specify these constraints directly: + * + * <pre>{@code + * trackSelector.setParameters( + * trackSelector + * .buildUponParameters() + * .setMaxVideoSizeSd() + * .setPreferredAudioLanguage("deu")); + * }</pre> + * + * There are several benefits to using constraint based track selection instead of specific track + * overrides: + * + * <ul> + * <li>You can specify constraints before knowing what tracks the media provides. This can + * simplify track selection code (e.g. you don't have to listen for changes in the available + * tracks before configuring the selector). + * <li>Constraints can be applied consistently across all periods in a complex piece of media, + * even if those periods contain different tracks. In contrast, a specific track override is + * only applied to periods whose tracks match those for which the override was set. + * </ul> + * + * <h3>Disabling renderers</h3> + * + * Renderers can be disabled using {@link ParametersBuilder#setRendererDisabled}. Disabling a + * renderer differs from setting a {@code null} override because the renderer is disabled + * unconditionally, whereas a {@code null} override is applied only when the track groups available + * to the renderer match the {@link TrackGroupArray} for which it was specified. + * + * <h3>Tunneling</h3> + * + * Tunneled playback can be enabled in cases where the combination of renderers and selected tracks + * support it. Tunneled playback is enabled by passing an audio session ID to {@link + * ParametersBuilder#setTunnelingAudioSessionId(int)}. + */ +public class DefaultTrackSelector extends MappingTrackSelector { + + /** + * A builder for {@link Parameters}. See the {@link Parameters} documentation for explanations of + * the parameters that can be configured using this builder. + */ + public static final class ParametersBuilder extends TrackSelectionParameters.Builder { + + // Video + private int maxVideoWidth; + private int maxVideoHeight; + private int maxVideoFrameRate; + private int maxVideoBitrate; + private boolean exceedVideoConstraintsIfNecessary; + private boolean allowVideoMixedMimeTypeAdaptiveness; + private boolean allowVideoNonSeamlessAdaptiveness; + private int viewportWidth; + private int viewportHeight; + private boolean viewportOrientationMayChange; + // Audio + private int maxAudioChannelCount; + private int maxAudioBitrate; + private boolean exceedAudioConstraintsIfNecessary; + private boolean allowAudioMixedMimeTypeAdaptiveness; + private boolean allowAudioMixedSampleRateAdaptiveness; + private boolean allowAudioMixedChannelCountAdaptiveness; + // General + private boolean forceLowestBitrate; + private boolean forceHighestSupportedBitrate; + private boolean exceedRendererCapabilitiesIfNecessary; + private int tunnelingAudioSessionId; + + private final SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> + selectionOverrides; + private final SparseBooleanArray rendererDisabledFlags; + + /** + * @deprecated {@link Context} constraints will not be set using this constructor. Use {@link + * #ParametersBuilder(Context)} instead. + */ + @Deprecated + @SuppressWarnings({"deprecation"}) + public ParametersBuilder() { + super(); + setInitialValuesWithoutContext(); + selectionOverrides = new SparseArray<>(); + rendererDisabledFlags = new SparseBooleanArray(); + } + + /** + * Creates a builder with default initial values. + * + * @param context Any context. + */ + + public ParametersBuilder(Context context) { + super(context); + setInitialValuesWithoutContext(); + selectionOverrides = new SparseArray<>(); + rendererDisabledFlags = new SparseBooleanArray(); + setViewportSizeToPhysicalDisplaySize(context, /* viewportOrientationMayChange= */ true); + } + + /** + * @param initialValues The {@link Parameters} from which the initial values of the builder are + * obtained. + */ + private ParametersBuilder(Parameters initialValues) { + super(initialValues); + // Video + maxVideoWidth = initialValues.maxVideoWidth; + maxVideoHeight = initialValues.maxVideoHeight; + maxVideoFrameRate = initialValues.maxVideoFrameRate; + maxVideoBitrate = initialValues.maxVideoBitrate; + exceedVideoConstraintsIfNecessary = initialValues.exceedVideoConstraintsIfNecessary; + allowVideoMixedMimeTypeAdaptiveness = initialValues.allowVideoMixedMimeTypeAdaptiveness; + allowVideoNonSeamlessAdaptiveness = initialValues.allowVideoNonSeamlessAdaptiveness; + viewportWidth = initialValues.viewportWidth; + viewportHeight = initialValues.viewportHeight; + viewportOrientationMayChange = initialValues.viewportOrientationMayChange; + // Audio + maxAudioChannelCount = initialValues.maxAudioChannelCount; + maxAudioBitrate = initialValues.maxAudioBitrate; + exceedAudioConstraintsIfNecessary = initialValues.exceedAudioConstraintsIfNecessary; + allowAudioMixedMimeTypeAdaptiveness = initialValues.allowAudioMixedMimeTypeAdaptiveness; + allowAudioMixedSampleRateAdaptiveness = initialValues.allowAudioMixedSampleRateAdaptiveness; + allowAudioMixedChannelCountAdaptiveness = + initialValues.allowAudioMixedChannelCountAdaptiveness; + // General + forceLowestBitrate = initialValues.forceLowestBitrate; + forceHighestSupportedBitrate = initialValues.forceHighestSupportedBitrate; + exceedRendererCapabilitiesIfNecessary = initialValues.exceedRendererCapabilitiesIfNecessary; + tunnelingAudioSessionId = initialValues.tunnelingAudioSessionId; + // Overrides + selectionOverrides = cloneSelectionOverrides(initialValues.selectionOverrides); + rendererDisabledFlags = initialValues.rendererDisabledFlags.clone(); + } + + // Video + + /** + * Equivalent to {@link #setMaxVideoSize setMaxVideoSize(1279, 719)}. + * + * @return This builder. + */ + public ParametersBuilder setMaxVideoSizeSd() { + return setMaxVideoSize(1279, 719); + } + + /** + * Equivalent to {@link #setMaxVideoSize setMaxVideoSize(Integer.MAX_VALUE, Integer.MAX_VALUE)}. + * + * @return This builder. + */ + public ParametersBuilder clearVideoSizeConstraints() { + return setMaxVideoSize(Integer.MAX_VALUE, Integer.MAX_VALUE); + } + + /** + * Sets the maximum allowed video width and height. + * + * @param maxVideoWidth Maximum allowed video width in pixels. + * @param maxVideoHeight Maximum allowed video height in pixels. + * @return This builder. + */ + public ParametersBuilder setMaxVideoSize(int maxVideoWidth, int maxVideoHeight) { + this.maxVideoWidth = maxVideoWidth; + this.maxVideoHeight = maxVideoHeight; + return this; + } + + /** + * Sets the maximum allowed video frame rate. + * + * @param maxVideoFrameRate Maximum allowed video frame rate in hertz. + * @return This builder. + */ + public ParametersBuilder setMaxVideoFrameRate(int maxVideoFrameRate) { + this.maxVideoFrameRate = maxVideoFrameRate; + return this; + } + + /** + * Sets the maximum allowed video bitrate. + * + * @param maxVideoBitrate Maximum allowed video bitrate in bits per second. + * @return This builder. + */ + public ParametersBuilder setMaxVideoBitrate(int maxVideoBitrate) { + this.maxVideoBitrate = maxVideoBitrate; + return this; + } + + /** + * Sets whether to exceed the {@link #setMaxVideoSize(int, int)} and {@link + * #setMaxAudioBitrate(int)} constraints when no selection can be made otherwise. + * + * @param exceedVideoConstraintsIfNecessary Whether to exceed video constraints when no + * selection can be made otherwise. + * @return This builder. + */ + public ParametersBuilder setExceedVideoConstraintsIfNecessary( + boolean exceedVideoConstraintsIfNecessary) { + this.exceedVideoConstraintsIfNecessary = exceedVideoConstraintsIfNecessary; + return this; + } + + /** + * Sets whether to allow adaptive video selections containing mixed MIME types. + * + * <p>Adaptations between different MIME types may not be completely seamless, in which case + * {@link #setAllowVideoNonSeamlessAdaptiveness(boolean)} also needs to be {@code true} for + * mixed MIME type selections to be made. + * + * @param allowVideoMixedMimeTypeAdaptiveness Whether to allow adaptive video selections + * containing mixed MIME types. + * @return This builder. + */ + public ParametersBuilder setAllowVideoMixedMimeTypeAdaptiveness( + boolean allowVideoMixedMimeTypeAdaptiveness) { + this.allowVideoMixedMimeTypeAdaptiveness = allowVideoMixedMimeTypeAdaptiveness; + return this; + } + + /** + * Sets whether to allow adaptive video selections where adaptation may not be completely + * seamless. + * + * @param allowVideoNonSeamlessAdaptiveness Whether to allow adaptive video selections where + * adaptation may not be completely seamless. + * @return This builder. + */ + public ParametersBuilder setAllowVideoNonSeamlessAdaptiveness( + boolean allowVideoNonSeamlessAdaptiveness) { + this.allowVideoNonSeamlessAdaptiveness = allowVideoNonSeamlessAdaptiveness; + return this; + } + + /** + * Equivalent to calling {@link #setViewportSize(int, int, boolean)} with the viewport size + * obtained from {@link Util#getCurrentDisplayModeSize(Context)}. + * + * @param context Any context. + * @param viewportOrientationMayChange Whether the viewport orientation may change during + * playback. + * @return This builder. + */ + public ParametersBuilder setViewportSizeToPhysicalDisplaySize( + Context context, boolean viewportOrientationMayChange) { + // Assume the viewport is fullscreen. + Point viewportSize = Util.getCurrentDisplayModeSize(context); + return setViewportSize(viewportSize.x, viewportSize.y, viewportOrientationMayChange); + } + + /** + * Equivalent to {@link #setViewportSize setViewportSize(Integer.MAX_VALUE, Integer.MAX_VALUE, + * true)}. + * + * @return This builder. + */ + public ParametersBuilder clearViewportSizeConstraints() { + return setViewportSize(Integer.MAX_VALUE, Integer.MAX_VALUE, true); + } + + /** + * Sets the viewport size to constrain adaptive video selections so that only tracks suitable + * for the viewport are selected. + * + * @param viewportWidth Viewport width in pixels. + * @param viewportHeight Viewport height in pixels. + * @param viewportOrientationMayChange Whether the viewport orientation may change during + * playback. + * @return This builder. + */ + public ParametersBuilder setViewportSize( + int viewportWidth, int viewportHeight, boolean viewportOrientationMayChange) { + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.viewportOrientationMayChange = viewportOrientationMayChange; + return this; + } + + // Audio + + @Override + public ParametersBuilder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { + super.setPreferredAudioLanguage(preferredAudioLanguage); + return this; + } + + /** + * Sets the maximum allowed audio channel count. + * + * @param maxAudioChannelCount Maximum allowed audio channel count. + * @return This builder. + */ + public ParametersBuilder setMaxAudioChannelCount(int maxAudioChannelCount) { + this.maxAudioChannelCount = maxAudioChannelCount; + return this; + } + + /** + * Sets the maximum allowed audio bitrate. + * + * @param maxAudioBitrate Maximum allowed audio bitrate in bits per second. + * @return This builder. + */ + public ParametersBuilder setMaxAudioBitrate(int maxAudioBitrate) { + this.maxAudioBitrate = maxAudioBitrate; + return this; + } + + /** + * Sets whether to exceed the {@link #setMaxAudioChannelCount(int)} and {@link + * #setMaxAudioBitrate(int)} constraints when no selection can be made otherwise. + * + * @param exceedAudioConstraintsIfNecessary Whether to exceed audio constraints when no + * selection can be made otherwise. + * @return This builder. + */ + public ParametersBuilder setExceedAudioConstraintsIfNecessary( + boolean exceedAudioConstraintsIfNecessary) { + this.exceedAudioConstraintsIfNecessary = exceedAudioConstraintsIfNecessary; + return this; + } + + /** + * Sets whether to allow adaptive audio selections containing mixed MIME types. + * + * <p>Adaptations between different MIME types may not be completely seamless. + * + * @param allowAudioMixedMimeTypeAdaptiveness Whether to allow adaptive audio selections + * containing mixed MIME types. + * @return This builder. + */ + public ParametersBuilder setAllowAudioMixedMimeTypeAdaptiveness( + boolean allowAudioMixedMimeTypeAdaptiveness) { + this.allowAudioMixedMimeTypeAdaptiveness = allowAudioMixedMimeTypeAdaptiveness; + return this; + } + + /** + * Sets whether to allow adaptive audio selections containing mixed sample rates. + * + * <p>Adaptations between different sample rates may not be completely seamless. + * + * @param allowAudioMixedSampleRateAdaptiveness Whether to allow adaptive audio selections + * containing mixed sample rates. + * @return This builder. + */ + public ParametersBuilder setAllowAudioMixedSampleRateAdaptiveness( + boolean allowAudioMixedSampleRateAdaptiveness) { + this.allowAudioMixedSampleRateAdaptiveness = allowAudioMixedSampleRateAdaptiveness; + return this; + } + + /** + * Sets whether to allow adaptive audio selections containing mixed channel counts. + * + * <p>Adaptations between different channel counts may not be completely seamless. + * + * @param allowAudioMixedChannelCountAdaptiveness Whether to allow adaptive audio selections + * containing mixed channel counts. + * @return This builder. + */ + public ParametersBuilder setAllowAudioMixedChannelCountAdaptiveness( + boolean allowAudioMixedChannelCountAdaptiveness) { + this.allowAudioMixedChannelCountAdaptiveness = allowAudioMixedChannelCountAdaptiveness; + return this; + } + + // Text + + @Override + public ParametersBuilder setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings( + Context context) { + super.setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings(context); + return this; + } + + @Override + public ParametersBuilder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { + super.setPreferredTextLanguage(preferredTextLanguage); + return this; + } + + @Override + public ParametersBuilder setPreferredTextRoleFlags(@C.RoleFlags int preferredTextRoleFlags) { + super.setPreferredTextRoleFlags(preferredTextRoleFlags); + return this; + } + + @Override + public ParametersBuilder setSelectUndeterminedTextLanguage( + boolean selectUndeterminedTextLanguage) { + super.setSelectUndeterminedTextLanguage(selectUndeterminedTextLanguage); + return this; + } + + @Override + public ParametersBuilder setDisabledTextTrackSelectionFlags( + @C.SelectionFlags int disabledTextTrackSelectionFlags) { + super.setDisabledTextTrackSelectionFlags(disabledTextTrackSelectionFlags); + return this; + } + + // General + + /** + * Sets whether to force selection of the single lowest bitrate audio and video tracks that + * comply with all other constraints. + * + * @param forceLowestBitrate Whether to force selection of the single lowest bitrate audio and + * video tracks. + * @return This builder. + */ + public ParametersBuilder setForceLowestBitrate(boolean forceLowestBitrate) { + this.forceLowestBitrate = forceLowestBitrate; + return this; + } + + /** + * Sets whether to force selection of the highest bitrate audio and video tracks that comply + * with all other constraints. + * + * @param forceHighestSupportedBitrate Whether to force selection of the highest bitrate audio + * and video tracks. + * @return This builder. + */ + public ParametersBuilder setForceHighestSupportedBitrate(boolean forceHighestSupportedBitrate) { + this.forceHighestSupportedBitrate = forceHighestSupportedBitrate; + return this; + } + + /** + * @deprecated Use {@link #setAllowVideoMixedMimeTypeAdaptiveness(boolean)} and {@link + * #setAllowAudioMixedMimeTypeAdaptiveness(boolean)}. + */ + @Deprecated + public ParametersBuilder setAllowMixedMimeAdaptiveness(boolean allowMixedMimeAdaptiveness) { + setAllowAudioMixedMimeTypeAdaptiveness(allowMixedMimeAdaptiveness); + setAllowVideoMixedMimeTypeAdaptiveness(allowMixedMimeAdaptiveness); + return this; + } + + /** @deprecated Use {@link #setAllowVideoNonSeamlessAdaptiveness(boolean)} */ + @Deprecated + public ParametersBuilder setAllowNonSeamlessAdaptiveness(boolean allowNonSeamlessAdaptiveness) { + return setAllowVideoNonSeamlessAdaptiveness(allowNonSeamlessAdaptiveness); + } + + /** + * Sets whether to exceed renderer capabilities when no selection can be made otherwise. + * + * <p>This parameter applies when all of the tracks available for a renderer exceed the + * renderer's reported capabilities. If the parameter is {@code true} then the lowest quality + * track will still be selected. Playback may succeed if the renderer has under-reported its + * true capabilities. If {@code false} then no track will be selected. + * + * @param exceedRendererCapabilitiesIfNecessary Whether to exceed renderer capabilities when no + * selection can be made otherwise. + * @return This builder. + */ + public ParametersBuilder setExceedRendererCapabilitiesIfNecessary( + boolean exceedRendererCapabilitiesIfNecessary) { + this.exceedRendererCapabilitiesIfNecessary = exceedRendererCapabilitiesIfNecessary; + return this; + } + + /** + * Sets the audio session id to use when tunneling. + * + * <p>Enables or disables tunneling. To enable tunneling, pass an audio session id to use when + * in tunneling mode. Session ids can be generated using {@link + * C#generateAudioSessionIdV21(Context)}. To disable tunneling pass {@link + * C#AUDIO_SESSION_ID_UNSET}. Tunneling will only be activated if it's both enabled and + * supported by the audio and video renderers for the selected tracks. + * + * @param tunnelingAudioSessionId The audio session id to use when tunneling, or {@link + * C#AUDIO_SESSION_ID_UNSET} to disable tunneling. + * @return This builder. + */ + public ParametersBuilder setTunnelingAudioSessionId(int tunnelingAudioSessionId) { + this.tunnelingAudioSessionId = tunnelingAudioSessionId; + return this; + } + + // Overrides + + /** + * Sets whether the renderer at the specified index is disabled. Disabling a renderer prevents + * the selector from selecting any tracks for it. + * + * @param rendererIndex The renderer index. + * @param disabled Whether the renderer is disabled. + * @return This builder. + */ + public final ParametersBuilder setRendererDisabled(int rendererIndex, boolean disabled) { + if (rendererDisabledFlags.get(rendererIndex) == disabled) { + // The disabled flag is unchanged. + return this; + } + // Only true values are placed in the array to make it easier to check for equality. + if (disabled) { + rendererDisabledFlags.put(rendererIndex, true); + } else { + rendererDisabledFlags.delete(rendererIndex); + } + return this; + } + + /** + * Overrides the track selection for the renderer at the specified index. + * + * <p>When the {@link TrackGroupArray} mapped to the renderer matches the one provided, the + * override is applied. When the {@link TrackGroupArray} does not match, the override has no + * effect. The override replaces any previous override for the specified {@link TrackGroupArray} + * for the specified {@link Renderer}. + * + * <p>Passing a {@code null} override will cause the renderer to be disabled when the {@link + * TrackGroupArray} mapped to it matches the one provided. When the {@link TrackGroupArray} does + * not match a {@code null} override has no effect. Hence a {@code null} override differs from + * disabling the renderer using {@link #setRendererDisabled(int, boolean)} because the renderer + * is disabled conditionally on the {@link TrackGroupArray} mapped to it, where-as {@link + * #setRendererDisabled(int, boolean)} disables the renderer unconditionally. + * + * <p>To remove overrides use {@link #clearSelectionOverride(int, TrackGroupArray)}, {@link + * #clearSelectionOverrides(int)} or {@link #clearSelectionOverrides()}. + * + * @param rendererIndex The renderer index. + * @param groups The {@link TrackGroupArray} for which the override should be applied. + * @param override The override. + * @return This builder. + */ + public final ParametersBuilder setSelectionOverride( + int rendererIndex, TrackGroupArray groups, @Nullable SelectionOverride override) { + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + selectionOverrides.get(rendererIndex); + if (overrides == null) { + overrides = new HashMap<>(); + selectionOverrides.put(rendererIndex, overrides); + } + if (overrides.containsKey(groups) && Util.areEqual(overrides.get(groups), override)) { + // The override is unchanged. + return this; + } + overrides.put(groups, override); + return this; + } + + /** + * Clears a track selection override for the specified renderer and {@link TrackGroupArray}. + * + * @param rendererIndex The renderer index. + * @param groups The {@link TrackGroupArray} for which the override should be cleared. + * @return This builder. + */ + public final ParametersBuilder clearSelectionOverride( + int rendererIndex, TrackGroupArray groups) { + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + selectionOverrides.get(rendererIndex); + if (overrides == null || !overrides.containsKey(groups)) { + // Nothing to clear. + return this; + } + overrides.remove(groups); + if (overrides.isEmpty()) { + selectionOverrides.remove(rendererIndex); + } + return this; + } + + /** + * Clears all track selection overrides for the specified renderer. + * + * @param rendererIndex The renderer index. + * @return This builder. + */ + public final ParametersBuilder clearSelectionOverrides(int rendererIndex) { + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + selectionOverrides.get(rendererIndex); + if (overrides == null || overrides.isEmpty()) { + // Nothing to clear. + return this; + } + selectionOverrides.remove(rendererIndex); + return this; + } + + /** + * Clears all track selection overrides for all renderers. + * + * @return This builder. + */ + public final ParametersBuilder clearSelectionOverrides() { + if (selectionOverrides.size() == 0) { + // Nothing to clear. + return this; + } + selectionOverrides.clear(); + return this; + } + + /** + * Builds a {@link Parameters} instance with the selected values. + */ + public Parameters build() { + return new Parameters( + // Video + maxVideoWidth, + maxVideoHeight, + maxVideoFrameRate, + maxVideoBitrate, + exceedVideoConstraintsIfNecessary, + allowVideoMixedMimeTypeAdaptiveness, + allowVideoNonSeamlessAdaptiveness, + viewportWidth, + viewportHeight, + viewportOrientationMayChange, + // Audio + preferredAudioLanguage, + maxAudioChannelCount, + maxAudioBitrate, + exceedAudioConstraintsIfNecessary, + allowAudioMixedMimeTypeAdaptiveness, + allowAudioMixedSampleRateAdaptiveness, + allowAudioMixedChannelCountAdaptiveness, + // Text + preferredTextLanguage, + preferredTextRoleFlags, + selectUndeterminedTextLanguage, + disabledTextTrackSelectionFlags, + // General + forceLowestBitrate, + forceHighestSupportedBitrate, + exceedRendererCapabilitiesIfNecessary, + tunnelingAudioSessionId, + selectionOverrides, + rendererDisabledFlags); + } + + private void setInitialValuesWithoutContext(@UnderInitialization ParametersBuilder this) { + // Video + maxVideoWidth = Integer.MAX_VALUE; + maxVideoHeight = Integer.MAX_VALUE; + maxVideoFrameRate = Integer.MAX_VALUE; + maxVideoBitrate = Integer.MAX_VALUE; + exceedVideoConstraintsIfNecessary = true; + allowVideoMixedMimeTypeAdaptiveness = false; + allowVideoNonSeamlessAdaptiveness = true; + viewportWidth = Integer.MAX_VALUE; + viewportHeight = Integer.MAX_VALUE; + viewportOrientationMayChange = true; + // Audio + maxAudioChannelCount = Integer.MAX_VALUE; + maxAudioBitrate = Integer.MAX_VALUE; + exceedAudioConstraintsIfNecessary = true; + allowAudioMixedMimeTypeAdaptiveness = false; + allowAudioMixedSampleRateAdaptiveness = false; + allowAudioMixedChannelCountAdaptiveness = false; + // General + forceLowestBitrate = false; + forceHighestSupportedBitrate = false; + exceedRendererCapabilitiesIfNecessary = true; + tunnelingAudioSessionId = C.AUDIO_SESSION_ID_UNSET; + } + + private static SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> + cloneSelectionOverrides( + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> selectionOverrides) { + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> clone = + new SparseArray<>(); + for (int i = 0; i < selectionOverrides.size(); i++) { + clone.put(selectionOverrides.keyAt(i), new HashMap<>(selectionOverrides.valueAt(i))); + } + return clone; + } + } + + /** + * Extends {@link TrackSelectionParameters} by adding fields that are specific to {@link + * DefaultTrackSelector}. + */ + public static final class Parameters extends TrackSelectionParameters { + + /** + * An instance with default values, except those obtained from the {@link Context}. + * + * <p>If possible, use {@link #getDefaults(Context)} instead. + * + * <p>This instance will not have the following settings: + * + * <ul> + * <li>{@link ParametersBuilder#setViewportSizeToPhysicalDisplaySize(Context, boolean) + * Viewport constraints} configured for the primary display. + * <li>{@link + * ParametersBuilder#setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings(Context) + * Preferred text language and role flags} configured to the accessibility settings of + * {@link android.view.accessibility.CaptioningManager}. + * </ul> + */ + @SuppressWarnings("deprecation") + public static final Parameters DEFAULT_WITHOUT_CONTEXT = new ParametersBuilder().build(); + + /** + * @deprecated This instance does not have {@link Context} constraints configured. Use {@link + * #getDefaults(Context)} instead. + */ + @Deprecated public static final Parameters DEFAULT_WITHOUT_VIEWPORT = DEFAULT_WITHOUT_CONTEXT; + + /** + * @deprecated This instance does not have {@link Context} constraints configured. Use {@link + * #getDefaults(Context)} instead. + */ + @Deprecated + public static final Parameters DEFAULT = DEFAULT_WITHOUT_CONTEXT; + + /** Returns an instance configured with default values. */ + public static Parameters getDefaults(Context context) { + return new ParametersBuilder(context).build(); + } + + // Video + /** + * Maximum allowed video width in pixels. The default value is {@link Integer#MAX_VALUE} (i.e. + * no constraint). + * + * <p>To constrain adaptive video track selections to be suitable for a given viewport (the + * region of the display within which video will be played), use ({@link #viewportWidth}, {@link + * #viewportHeight} and {@link #viewportOrientationMayChange}) instead. + */ + public final int maxVideoWidth; + /** + * Maximum allowed video height in pixels. The default value is {@link Integer#MAX_VALUE} (i.e. + * no constraint). + * + * <p>To constrain adaptive video track selections to be suitable for a given viewport (the + * region of the display within which video will be played), use ({@link #viewportWidth}, {@link + * #viewportHeight} and {@link #viewportOrientationMayChange}) instead. + */ + public final int maxVideoHeight; + /** + * Maximum allowed video frame rate in hertz. The default value is {@link Integer#MAX_VALUE} + * (i.e. no constraint). + */ + public final int maxVideoFrameRate; + /** + * Maximum allowed video bitrate in bits per second. The default value is {@link + * Integer#MAX_VALUE} (i.e. no constraint). + */ + public final int maxVideoBitrate; + /** + * Whether to exceed the {@link #maxVideoWidth}, {@link #maxVideoHeight} and {@link + * #maxVideoBitrate} constraints when no selection can be made otherwise. The default value is + * {@code true}. + */ + public final boolean exceedVideoConstraintsIfNecessary; + /** + * Whether to allow adaptive video selections containing mixed MIME types. Adaptations between + * different MIME types may not be completely seamless, in which case {@link + * #allowVideoNonSeamlessAdaptiveness} also needs to be {@code true} for mixed MIME type + * selections to be made. The default value is {@code false}. + */ + public final boolean allowVideoMixedMimeTypeAdaptiveness; + /** + * Whether to allow adaptive video selections where adaptation may not be completely seamless. + * The default value is {@code true}. + */ + public final boolean allowVideoNonSeamlessAdaptiveness; + /** + * Viewport width in pixels. Constrains video track selections for adaptive content so that only + * tracks suitable for the viewport are selected. The default value is the physical width of the + * primary display, in pixels. + */ + public final int viewportWidth; + /** + * Viewport height in pixels. Constrains video track selections for adaptive content so that + * only tracks suitable for the viewport are selected. The default value is the physical height + * of the primary display, in pixels. + */ + public final int viewportHeight; + /** + * Whether the viewport orientation may change during playback. Constrains video track + * selections for adaptive content so that only tracks suitable for the viewport are selected. + * The default value is {@code true}. + */ + public final boolean viewportOrientationMayChange; + // Audio + /** + * Maximum allowed audio channel count. The default value is {@link Integer#MAX_VALUE} (i.e. no + * constraint). + */ + public final int maxAudioChannelCount; + /** + * Maximum allowed audio bitrate in bits per second. The default value is {@link + * Integer#MAX_VALUE} (i.e. no constraint). + */ + public final int maxAudioBitrate; + /** + * Whether to exceed the {@link #maxAudioChannelCount} and {@link #maxAudioBitrate} constraints + * when no selection can be made otherwise. The default value is {@code true}. + */ + public final boolean exceedAudioConstraintsIfNecessary; + /** + * Whether to allow adaptive audio selections containing mixed MIME types. Adaptations between + * different MIME types may not be completely seamless. The default value is {@code false}. + */ + public final boolean allowAudioMixedMimeTypeAdaptiveness; + /** + * Whether to allow adaptive audio selections containing mixed sample rates. Adaptations between + * different sample rates may not be completely seamless. The default value is {@code false}. + */ + public final boolean allowAudioMixedSampleRateAdaptiveness; + /** + * Whether to allow adaptive audio selections containing mixed channel counts. Adaptations + * between different channel counts may not be completely seamless. The default value is {@code + * false}. + */ + public final boolean allowAudioMixedChannelCountAdaptiveness; + + // General + /** + * Whether to force selection of the single lowest bitrate audio and video tracks that comply + * with all other constraints. The default value is {@code false}. + */ + public final boolean forceLowestBitrate; + /** + * Whether to force selection of the highest bitrate audio and video tracks that comply with all + * other constraints. The default value is {@code false}. + */ + public final boolean forceHighestSupportedBitrate; + /** + * @deprecated Use {@link #allowVideoMixedMimeTypeAdaptiveness} and {@link + * #allowAudioMixedMimeTypeAdaptiveness}. + */ + @Deprecated public final boolean allowMixedMimeAdaptiveness; + /** @deprecated Use {@link #allowVideoNonSeamlessAdaptiveness}. */ + @Deprecated public final boolean allowNonSeamlessAdaptiveness; + /** + * Whether to exceed renderer capabilities when no selection can be made otherwise. + * + * <p>This parameter applies when all of the tracks available for a renderer exceed the + * renderer's reported capabilities. If the parameter is {@code true} then the lowest quality + * track will still be selected. Playback may succeed if the renderer has under-reported its + * true capabilities. If {@code false} then no track will be selected. The default value is + * {@code true}. + */ + public final boolean exceedRendererCapabilitiesIfNecessary; + /** + * The audio session id to use when tunneling, or {@link C#AUDIO_SESSION_ID_UNSET} if tunneling + * is disabled. The default value is {@link C#AUDIO_SESSION_ID_UNSET} (i.e. tunneling is + * disabled). + */ + public final int tunnelingAudioSessionId; + + // Overrides + private final SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> + selectionOverrides; + private final SparseBooleanArray rendererDisabledFlags; + + /* package */ Parameters( + // Video + int maxVideoWidth, + int maxVideoHeight, + int maxVideoFrameRate, + int maxVideoBitrate, + boolean exceedVideoConstraintsIfNecessary, + boolean allowVideoMixedMimeTypeAdaptiveness, + boolean allowVideoNonSeamlessAdaptiveness, + int viewportWidth, + int viewportHeight, + boolean viewportOrientationMayChange, + // Audio + @Nullable String preferredAudioLanguage, + int maxAudioChannelCount, + int maxAudioBitrate, + boolean exceedAudioConstraintsIfNecessary, + boolean allowAudioMixedMimeTypeAdaptiveness, + boolean allowAudioMixedSampleRateAdaptiveness, + boolean allowAudioMixedChannelCountAdaptiveness, + // Text + @Nullable String preferredTextLanguage, + @C.RoleFlags int preferredTextRoleFlags, + boolean selectUndeterminedTextLanguage, + @C.SelectionFlags int disabledTextTrackSelectionFlags, + // General + boolean forceLowestBitrate, + boolean forceHighestSupportedBitrate, + boolean exceedRendererCapabilitiesIfNecessary, + int tunnelingAudioSessionId, + // Overrides + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> selectionOverrides, + SparseBooleanArray rendererDisabledFlags) { + super( + preferredAudioLanguage, + preferredTextLanguage, + preferredTextRoleFlags, + selectUndeterminedTextLanguage, + disabledTextTrackSelectionFlags); + // Video + this.maxVideoWidth = maxVideoWidth; + this.maxVideoHeight = maxVideoHeight; + this.maxVideoFrameRate = maxVideoFrameRate; + this.maxVideoBitrate = maxVideoBitrate; + this.exceedVideoConstraintsIfNecessary = exceedVideoConstraintsIfNecessary; + this.allowVideoMixedMimeTypeAdaptiveness = allowVideoMixedMimeTypeAdaptiveness; + this.allowVideoNonSeamlessAdaptiveness = allowVideoNonSeamlessAdaptiveness; + this.viewportWidth = viewportWidth; + this.viewportHeight = viewportHeight; + this.viewportOrientationMayChange = viewportOrientationMayChange; + // Audio + this.maxAudioChannelCount = maxAudioChannelCount; + this.maxAudioBitrate = maxAudioBitrate; + this.exceedAudioConstraintsIfNecessary = exceedAudioConstraintsIfNecessary; + this.allowAudioMixedMimeTypeAdaptiveness = allowAudioMixedMimeTypeAdaptiveness; + this.allowAudioMixedSampleRateAdaptiveness = allowAudioMixedSampleRateAdaptiveness; + this.allowAudioMixedChannelCountAdaptiveness = allowAudioMixedChannelCountAdaptiveness; + // General + this.forceLowestBitrate = forceLowestBitrate; + this.forceHighestSupportedBitrate = forceHighestSupportedBitrate; + this.exceedRendererCapabilitiesIfNecessary = exceedRendererCapabilitiesIfNecessary; + this.tunnelingAudioSessionId = tunnelingAudioSessionId; + // Deprecated fields. + this.allowMixedMimeAdaptiveness = allowVideoMixedMimeTypeAdaptiveness; + this.allowNonSeamlessAdaptiveness = allowVideoNonSeamlessAdaptiveness; + // Overrides + this.selectionOverrides = selectionOverrides; + this.rendererDisabledFlags = rendererDisabledFlags; + } + + /* package */ + Parameters(Parcel in) { + super(in); + // Video + this.maxVideoWidth = in.readInt(); + this.maxVideoHeight = in.readInt(); + this.maxVideoFrameRate = in.readInt(); + this.maxVideoBitrate = in.readInt(); + this.exceedVideoConstraintsIfNecessary = Util.readBoolean(in); + this.allowVideoMixedMimeTypeAdaptiveness = Util.readBoolean(in); + this.allowVideoNonSeamlessAdaptiveness = Util.readBoolean(in); + this.viewportWidth = in.readInt(); + this.viewportHeight = in.readInt(); + this.viewportOrientationMayChange = Util.readBoolean(in); + // Audio + this.maxAudioChannelCount = in.readInt(); + this.maxAudioBitrate = in.readInt(); + this.exceedAudioConstraintsIfNecessary = Util.readBoolean(in); + this.allowAudioMixedMimeTypeAdaptiveness = Util.readBoolean(in); + this.allowAudioMixedSampleRateAdaptiveness = Util.readBoolean(in); + this.allowAudioMixedChannelCountAdaptiveness = Util.readBoolean(in); + // General + this.forceLowestBitrate = Util.readBoolean(in); + this.forceHighestSupportedBitrate = Util.readBoolean(in); + this.exceedRendererCapabilitiesIfNecessary = Util.readBoolean(in); + this.tunnelingAudioSessionId = in.readInt(); + // Overrides + this.selectionOverrides = readSelectionOverrides(in); + this.rendererDisabledFlags = Util.castNonNull(in.readSparseBooleanArray()); + // Deprecated fields. + this.allowMixedMimeAdaptiveness = allowVideoMixedMimeTypeAdaptiveness; + this.allowNonSeamlessAdaptiveness = allowVideoNonSeamlessAdaptiveness; + } + + /** + * Returns whether the renderer is disabled. + * + * @param rendererIndex The renderer index. + * @return Whether the renderer is disabled. + */ + public final boolean getRendererDisabled(int rendererIndex) { + return rendererDisabledFlags.get(rendererIndex); + } + + /** + * Returns whether there is an override for the specified renderer and {@link TrackGroupArray}. + * + * @param rendererIndex The renderer index. + * @param groups The {@link TrackGroupArray}. + * @return Whether there is an override. + */ + public final boolean hasSelectionOverride(int rendererIndex, TrackGroupArray groups) { + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + selectionOverrides.get(rendererIndex); + return overrides != null && overrides.containsKey(groups); + } + + /** + * Returns the override for the specified renderer and {@link TrackGroupArray}. + * + * @param rendererIndex The renderer index. + * @param groups The {@link TrackGroupArray}. + * @return The override, or null if no override exists. + */ + @Nullable + public final SelectionOverride getSelectionOverride(int rendererIndex, TrackGroupArray groups) { + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + selectionOverrides.get(rendererIndex); + return overrides != null ? overrides.get(groups) : null; + } + + /** Creates a new {@link ParametersBuilder}, copying the initial values from this instance. */ + @Override + public ParametersBuilder buildUpon() { + return new ParametersBuilder(this); + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + Parameters other = (Parameters) obj; + return super.equals(obj) + // Video + && maxVideoWidth == other.maxVideoWidth + && maxVideoHeight == other.maxVideoHeight + && maxVideoFrameRate == other.maxVideoFrameRate + && maxVideoBitrate == other.maxVideoBitrate + && exceedVideoConstraintsIfNecessary == other.exceedVideoConstraintsIfNecessary + && allowVideoMixedMimeTypeAdaptiveness == other.allowVideoMixedMimeTypeAdaptiveness + && allowVideoNonSeamlessAdaptiveness == other.allowVideoNonSeamlessAdaptiveness + && viewportOrientationMayChange == other.viewportOrientationMayChange + && viewportWidth == other.viewportWidth + && viewportHeight == other.viewportHeight + // Audio + && maxAudioChannelCount == other.maxAudioChannelCount + && maxAudioBitrate == other.maxAudioBitrate + && exceedAudioConstraintsIfNecessary == other.exceedAudioConstraintsIfNecessary + && allowAudioMixedMimeTypeAdaptiveness == other.allowAudioMixedMimeTypeAdaptiveness + && allowAudioMixedSampleRateAdaptiveness == other.allowAudioMixedSampleRateAdaptiveness + && allowAudioMixedChannelCountAdaptiveness + == other.allowAudioMixedChannelCountAdaptiveness + // General + && forceLowestBitrate == other.forceLowestBitrate + && forceHighestSupportedBitrate == other.forceHighestSupportedBitrate + && exceedRendererCapabilitiesIfNecessary == other.exceedRendererCapabilitiesIfNecessary + && tunnelingAudioSessionId == other.tunnelingAudioSessionId + // Overrides + && areRendererDisabledFlagsEqual(rendererDisabledFlags, other.rendererDisabledFlags) + && areSelectionOverridesEqual(selectionOverrides, other.selectionOverrides); + } + + @Override + public int hashCode() { + int result = super.hashCode(); + // Video + result = 31 * result + maxVideoWidth; + result = 31 * result + maxVideoHeight; + result = 31 * result + maxVideoFrameRate; + result = 31 * result + maxVideoBitrate; + result = 31 * result + (exceedVideoConstraintsIfNecessary ? 1 : 0); + result = 31 * result + (allowVideoMixedMimeTypeAdaptiveness ? 1 : 0); + result = 31 * result + (allowVideoNonSeamlessAdaptiveness ? 1 : 0); + result = 31 * result + (viewportOrientationMayChange ? 1 : 0); + result = 31 * result + viewportWidth; + result = 31 * result + viewportHeight; + // Audio + result = 31 * result + maxAudioChannelCount; + result = 31 * result + maxAudioBitrate; + result = 31 * result + (exceedAudioConstraintsIfNecessary ? 1 : 0); + result = 31 * result + (allowAudioMixedMimeTypeAdaptiveness ? 1 : 0); + result = 31 * result + (allowAudioMixedSampleRateAdaptiveness ? 1 : 0); + result = 31 * result + (allowAudioMixedChannelCountAdaptiveness ? 1 : 0); + // General + result = 31 * result + (forceLowestBitrate ? 1 : 0); + result = 31 * result + (forceHighestSupportedBitrate ? 1 : 0); + result = 31 * result + (exceedRendererCapabilitiesIfNecessary ? 1 : 0); + result = 31 * result + tunnelingAudioSessionId; + // Overrides (omitted from hashCode). + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + super.writeToParcel(dest, flags); + // Video + dest.writeInt(maxVideoWidth); + dest.writeInt(maxVideoHeight); + dest.writeInt(maxVideoFrameRate); + dest.writeInt(maxVideoBitrate); + Util.writeBoolean(dest, exceedVideoConstraintsIfNecessary); + Util.writeBoolean(dest, allowVideoMixedMimeTypeAdaptiveness); + Util.writeBoolean(dest, allowVideoNonSeamlessAdaptiveness); + dest.writeInt(viewportWidth); + dest.writeInt(viewportHeight); + Util.writeBoolean(dest, viewportOrientationMayChange); + // Audio + dest.writeInt(maxAudioChannelCount); + dest.writeInt(maxAudioBitrate); + Util.writeBoolean(dest, exceedAudioConstraintsIfNecessary); + Util.writeBoolean(dest, allowAudioMixedMimeTypeAdaptiveness); + Util.writeBoolean(dest, allowAudioMixedSampleRateAdaptiveness); + Util.writeBoolean(dest, allowAudioMixedChannelCountAdaptiveness); + // General + Util.writeBoolean(dest, forceLowestBitrate); + Util.writeBoolean(dest, forceHighestSupportedBitrate); + Util.writeBoolean(dest, exceedRendererCapabilitiesIfNecessary); + dest.writeInt(tunnelingAudioSessionId); + // Overrides + writeSelectionOverridesToParcel(dest, selectionOverrides); + dest.writeSparseBooleanArray(rendererDisabledFlags); + } + + public static final Parcelable.Creator<Parameters> CREATOR = + new Parcelable.Creator<Parameters>() { + + @Override + public Parameters createFromParcel(Parcel in) { + return new Parameters(in); + } + + @Override + public Parameters[] newArray(int size) { + return new Parameters[size]; + } + }; + + // Static utility methods. + + private static SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> + readSelectionOverrides(Parcel in) { + int renderersWithOverridesCount = in.readInt(); + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> selectionOverrides = + new SparseArray<>(renderersWithOverridesCount); + for (int i = 0; i < renderersWithOverridesCount; i++) { + int rendererIndex = in.readInt(); + int overrideCount = in.readInt(); + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + new HashMap<>(overrideCount); + for (int j = 0; j < overrideCount; j++) { + TrackGroupArray trackGroups = + Assertions.checkNotNull(in.readParcelable(TrackGroupArray.class.getClassLoader())); + @Nullable + SelectionOverride override = in.readParcelable(SelectionOverride.class.getClassLoader()); + overrides.put(trackGroups, override); + } + selectionOverrides.put(rendererIndex, overrides); + } + return selectionOverrides; + } + + private static void writeSelectionOverridesToParcel( + Parcel dest, + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> selectionOverrides) { + int renderersWithOverridesCount = selectionOverrides.size(); + dest.writeInt(renderersWithOverridesCount); + for (int i = 0; i < renderersWithOverridesCount; i++) { + int rendererIndex = selectionOverrides.keyAt(i); + Map<TrackGroupArray, @NullableType SelectionOverride> overrides = + selectionOverrides.valueAt(i); + int overrideCount = overrides.size(); + dest.writeInt(rendererIndex); + dest.writeInt(overrideCount); + for (Map.Entry<TrackGroupArray, @NullableType SelectionOverride> override : + overrides.entrySet()) { + dest.writeParcelable(override.getKey(), /* parcelableFlags= */ 0); + dest.writeParcelable(override.getValue(), /* parcelableFlags= */ 0); + } + } + } + + private static boolean areRendererDisabledFlagsEqual( + SparseBooleanArray first, SparseBooleanArray second) { + int firstSize = first.size(); + if (second.size() != firstSize) { + return false; + } + // Only true values are put into rendererDisabledFlags, so we don't need to compare values. + for (int indexInFirst = 0; indexInFirst < firstSize; indexInFirst++) { + if (second.indexOfKey(first.keyAt(indexInFirst)) < 0) { + return false; + } + } + return true; + } + + private static boolean areSelectionOverridesEqual( + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> first, + SparseArray<Map<TrackGroupArray, @NullableType SelectionOverride>> second) { + int firstSize = first.size(); + if (second.size() != firstSize) { + return false; + } + for (int indexInFirst = 0; indexInFirst < firstSize; indexInFirst++) { + int indexInSecond = second.indexOfKey(first.keyAt(indexInFirst)); + if (indexInSecond < 0 + || !areSelectionOverridesEqual( + first.valueAt(indexInFirst), second.valueAt(indexInSecond))) { + return false; + } + } + return true; + } + + private static boolean areSelectionOverridesEqual( + Map<TrackGroupArray, @NullableType SelectionOverride> first, + Map<TrackGroupArray, @NullableType SelectionOverride> second) { + int firstSize = first.size(); + if (second.size() != firstSize) { + return false; + } + for (Map.Entry<TrackGroupArray, @NullableType SelectionOverride> firstEntry : + first.entrySet()) { + TrackGroupArray key = firstEntry.getKey(); + if (!second.containsKey(key) || !Util.areEqual(firstEntry.getValue(), second.get(key))) { + return false; + } + } + return true; + } + } + + /** A track selection override. */ + public static final class SelectionOverride implements Parcelable { + + public final int groupIndex; + public final int[] tracks; + public final int length; + public final int reason; + public final int data; + + /** + * @param groupIndex The overriding track group index. + * @param tracks The overriding track indices within the track group. + */ + public SelectionOverride(int groupIndex, int... tracks) { + this(groupIndex, tracks, C.SELECTION_REASON_MANUAL, /* data= */ 0); + } + + /** + * @param groupIndex The overriding track group index. + * @param tracks The overriding track indices within the track group. + * @param reason The reason for the override. One of the {@link C} SELECTION_REASON_ constants. + * @param data Optional data associated with this override. + */ + public SelectionOverride(int groupIndex, int[] tracks, int reason, int data) { + this.groupIndex = groupIndex; + this.tracks = Arrays.copyOf(tracks, tracks.length); + this.length = tracks.length; + this.reason = reason; + this.data = data; + Arrays.sort(this.tracks); + } + + /* package */ SelectionOverride(Parcel in) { + groupIndex = in.readInt(); + length = in.readByte(); + tracks = new int[length]; + in.readIntArray(tracks); + reason = in.readInt(); + data = in.readInt(); + } + + /** Returns whether this override contains the specified track index. */ + public boolean containsTrack(int track) { + for (int overrideTrack : tracks) { + if (overrideTrack == track) { + return true; + } + } + return false; + } + + @Override + public int hashCode() { + int hash = 31 * groupIndex + Arrays.hashCode(tracks); + hash = 31 * hash + reason; + return 31 * hash + data; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + SelectionOverride other = (SelectionOverride) obj; + return groupIndex == other.groupIndex + && Arrays.equals(tracks, other.tracks) + && reason == other.reason + && data == other.data; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(groupIndex); + dest.writeInt(tracks.length); + dest.writeIntArray(tracks); + dest.writeInt(reason); + dest.writeInt(data); + } + + public static final Parcelable.Creator<SelectionOverride> CREATOR = + new Parcelable.Creator<SelectionOverride>() { + + @Override + public SelectionOverride createFromParcel(Parcel in) { + return new SelectionOverride(in); + } + + @Override + public SelectionOverride[] newArray(int size) { + return new SelectionOverride[size]; + } + }; + } + + /** + * If a dimension (i.e. width or height) of a video is greater or equal to this fraction of the + * corresponding viewport dimension, then the video is considered as filling the viewport (in that + * dimension). + */ + private static final float FRACTION_TO_CONSIDER_FULLSCREEN = 0.98f; + private static final int[] NO_TRACKS = new int[0]; + private static final int WITHIN_RENDERER_CAPABILITIES_BONUS = 1000; + + private final TrackSelection.Factory trackSelectionFactory; + private final AtomicReference<Parameters> parametersReference; + + private boolean allowMultipleAdaptiveSelections; + + /** @deprecated Use {@link #DefaultTrackSelector(Context)} instead. */ + @Deprecated + @SuppressWarnings("deprecation") + public DefaultTrackSelector() { + this(new AdaptiveTrackSelection.Factory()); + } + + /** + * @deprecated Use {@link #DefaultTrackSelector(Context)} instead. The bandwidth meter should be + * passed directly to the player in {@link + * com.google.android.exoplayer2.SimpleExoPlayer.Builder}. + */ + @Deprecated + @SuppressWarnings("deprecation") + public DefaultTrackSelector(BandwidthMeter bandwidthMeter) { + this(new AdaptiveTrackSelection.Factory(bandwidthMeter)); + } + + /** @deprecated Use {@link #DefaultTrackSelector(Context, TrackSelection.Factory)}. */ + @Deprecated + public DefaultTrackSelector(TrackSelection.Factory trackSelectionFactory) { + this(Parameters.DEFAULT_WITHOUT_CONTEXT, trackSelectionFactory); + } + + /** @param context Any {@link Context}. */ + public DefaultTrackSelector(Context context) { + this(context, new AdaptiveTrackSelection.Factory()); + } + + /** + * @param context Any {@link Context}. + * @param trackSelectionFactory A factory for {@link TrackSelection}s. + */ + public DefaultTrackSelector(Context context, TrackSelection.Factory trackSelectionFactory) { + this(Parameters.getDefaults(context), trackSelectionFactory); + } + + /** + * @param parameters Initial {@link Parameters}. + * @param trackSelectionFactory A factory for {@link TrackSelection}s. + */ + public DefaultTrackSelector(Parameters parameters, TrackSelection.Factory trackSelectionFactory) { + this.trackSelectionFactory = trackSelectionFactory; + parametersReference = new AtomicReference<>(parameters); + } + + /** + * Atomically sets the provided parameters for track selection. + * + * @param parameters The parameters for track selection. + */ + public void setParameters(Parameters parameters) { + Assertions.checkNotNull(parameters); + if (!parametersReference.getAndSet(parameters).equals(parameters)) { + invalidate(); + } + } + + /** + * Atomically sets the provided parameters for track selection. + * + * @param parametersBuilder A builder from which to obtain the parameters for track selection. + */ + public void setParameters(ParametersBuilder parametersBuilder) { + setParameters(parametersBuilder.build()); + } + + /** + * Gets the current selection parameters. + * + * @return The current selection parameters. + */ + public Parameters getParameters() { + return parametersReference.get(); + } + + /** Returns a new {@link ParametersBuilder} initialized with the current selection parameters. */ + public ParametersBuilder buildUponParameters() { + return getParameters().buildUpon(); + } + + /** @deprecated Use {@link ParametersBuilder#setRendererDisabled(int, boolean)}. */ + @Deprecated + public final void setRendererDisabled(int rendererIndex, boolean disabled) { + setParameters(buildUponParameters().setRendererDisabled(rendererIndex, disabled)); + } + + /** @deprecated Use {@link Parameters#getRendererDisabled(int)}. */ + @Deprecated + public final boolean getRendererDisabled(int rendererIndex) { + return getParameters().getRendererDisabled(rendererIndex); + } + + /** + * @deprecated Use {@link ParametersBuilder#setSelectionOverride(int, TrackGroupArray, + * SelectionOverride)}. + */ + @Deprecated + public final void setSelectionOverride( + int rendererIndex, TrackGroupArray groups, @Nullable SelectionOverride override) { + setParameters(buildUponParameters().setSelectionOverride(rendererIndex, groups, override)); + } + + /** @deprecated Use {@link Parameters#hasSelectionOverride(int, TrackGroupArray)}. */ + @Deprecated + public final boolean hasSelectionOverride(int rendererIndex, TrackGroupArray groups) { + return getParameters().hasSelectionOverride(rendererIndex, groups); + } + + /** @deprecated Use {@link Parameters#getSelectionOverride(int, TrackGroupArray)}. */ + @Deprecated + @Nullable + public final SelectionOverride getSelectionOverride(int rendererIndex, TrackGroupArray groups) { + return getParameters().getSelectionOverride(rendererIndex, groups); + } + + /** @deprecated Use {@link ParametersBuilder#clearSelectionOverride(int, TrackGroupArray)}. */ + @Deprecated + public final void clearSelectionOverride(int rendererIndex, TrackGroupArray groups) { + setParameters(buildUponParameters().clearSelectionOverride(rendererIndex, groups)); + } + + /** @deprecated Use {@link ParametersBuilder#clearSelectionOverrides(int)}. */ + @Deprecated + public final void clearSelectionOverrides(int rendererIndex) { + setParameters(buildUponParameters().clearSelectionOverrides(rendererIndex)); + } + + /** @deprecated Use {@link ParametersBuilder#clearSelectionOverrides()}. */ + @Deprecated + public final void clearSelectionOverrides() { + setParameters(buildUponParameters().clearSelectionOverrides()); + } + + /** @deprecated Use {@link ParametersBuilder#setTunnelingAudioSessionId(int)}. */ + @Deprecated + public void setTunnelingAudioSessionId(int tunnelingAudioSessionId) { + setParameters(buildUponParameters().setTunnelingAudioSessionId(tunnelingAudioSessionId)); + } + + /** + * Allows the creation of multiple adaptive track selections. + * + * <p>This method is experimental, and will be renamed or removed in a future release. + */ + public void experimental_allowMultipleAdaptiveSelections() { + this.allowMultipleAdaptiveSelections = true; + } + + // MappingTrackSelector implementation. + + @Override + protected final Pair<@NullableType RendererConfiguration[], @NullableType TrackSelection[]> + selectTracks( + MappedTrackInfo mappedTrackInfo, + @Capabilities int[][][] rendererFormatSupports, + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports) + throws ExoPlaybackException { + Parameters params = parametersReference.get(); + int rendererCount = mappedTrackInfo.getRendererCount(); + TrackSelection.@NullableType Definition[] definitions = + selectAllTracks( + mappedTrackInfo, + rendererFormatSupports, + rendererMixedMimeTypeAdaptationSupports, + params); + + // Apply track disabling and overriding. + for (int i = 0; i < rendererCount; i++) { + if (params.getRendererDisabled(i)) { + definitions[i] = null; + continue; + } + TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(i); + if (params.hasSelectionOverride(i, rendererTrackGroups)) { + SelectionOverride override = params.getSelectionOverride(i, rendererTrackGroups); + definitions[i] = + override == null + ? null + : new TrackSelection.Definition( + rendererTrackGroups.get(override.groupIndex), + override.tracks, + override.reason, + override.data); + } + } + + @NullableType + TrackSelection[] rendererTrackSelections = + trackSelectionFactory.createTrackSelections(definitions, getBandwidthMeter()); + + // Initialize the renderer configurations to the default configuration for all renderers with + // selections, and null otherwise. + @NullableType RendererConfiguration[] rendererConfigurations = + new RendererConfiguration[rendererCount]; + for (int i = 0; i < rendererCount; i++) { + boolean forceRendererDisabled = params.getRendererDisabled(i); + boolean rendererEnabled = + !forceRendererDisabled + && (mappedTrackInfo.getRendererType(i) == C.TRACK_TYPE_NONE + || rendererTrackSelections[i] != null); + rendererConfigurations[i] = rendererEnabled ? RendererConfiguration.DEFAULT : null; + } + + // Configure audio and video renderers to use tunneling if appropriate. + maybeConfigureRenderersForTunneling( + mappedTrackInfo, + rendererFormatSupports, + rendererConfigurations, + rendererTrackSelections, + params.tunnelingAudioSessionId); + + return Pair.create(rendererConfigurations, rendererTrackSelections); + } + + // Track selection prior to overrides and disabled flags being applied. + + /** + * Called from {@link #selectTracks(MappedTrackInfo, int[][][], int[])} to make a track selection + * for each renderer, prior to overrides and disabled flags being applied. + * + * <p>The implementation should not account for overrides and disabled flags. Track selections + * generated by this method will be overridden to account for these properties. + * + * @param mappedTrackInfo Mapped track information. + * @param rendererFormatSupports The {@link Capabilities} for each mapped track, indexed by + * renderer, track group and track (in that order). + * @param rendererMixedMimeTypeAdaptationSupports The {@link AdaptiveSupport} for mixed MIME type + * adaptation for the renderer. + * @return The {@link TrackSelection.Definition}s for the renderers. A null entry indicates no + * selection was made. + * @throws ExoPlaybackException If an error occurs while selecting the tracks. + */ + protected TrackSelection.@NullableType Definition[] selectAllTracks( + MappedTrackInfo mappedTrackInfo, + @Capabilities int[][][] rendererFormatSupports, + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupports, + Parameters params) + throws ExoPlaybackException { + int rendererCount = mappedTrackInfo.getRendererCount(); + TrackSelection.@NullableType Definition[] definitions = + new TrackSelection.Definition[rendererCount]; + + boolean seenVideoRendererWithMappedTracks = false; + boolean selectedVideoTracks = false; + for (int i = 0; i < rendererCount; i++) { + if (C.TRACK_TYPE_VIDEO == mappedTrackInfo.getRendererType(i)) { + if (!selectedVideoTracks) { + definitions[i] = + selectVideoTrack( + mappedTrackInfo.getTrackGroups(i), + rendererFormatSupports[i], + rendererMixedMimeTypeAdaptationSupports[i], + params, + /* enableAdaptiveTrackSelection= */ true); + selectedVideoTracks = definitions[i] != null; + } + seenVideoRendererWithMappedTracks |= mappedTrackInfo.getTrackGroups(i).length > 0; + } + } + + AudioTrackScore selectedAudioTrackScore = null; + String selectedAudioLanguage = null; + int selectedAudioRendererIndex = C.INDEX_UNSET; + for (int i = 0; i < rendererCount; i++) { + if (C.TRACK_TYPE_AUDIO == mappedTrackInfo.getRendererType(i)) { + boolean enableAdaptiveTrackSelection = + allowMultipleAdaptiveSelections || !seenVideoRendererWithMappedTracks; + Pair<TrackSelection.Definition, AudioTrackScore> audioSelection = + selectAudioTrack( + mappedTrackInfo.getTrackGroups(i), + rendererFormatSupports[i], + rendererMixedMimeTypeAdaptationSupports[i], + params, + enableAdaptiveTrackSelection); + if (audioSelection != null + && (selectedAudioTrackScore == null + || audioSelection.second.compareTo(selectedAudioTrackScore) > 0)) { + if (selectedAudioRendererIndex != C.INDEX_UNSET) { + // We've already made a selection for another audio renderer, but it had a lower + // score. Clear the selection for that renderer. + definitions[selectedAudioRendererIndex] = null; + } + TrackSelection.Definition definition = audioSelection.first; + definitions[i] = definition; + // We assume that audio tracks in the same group have matching language. + selectedAudioLanguage = definition.group.getFormat(definition.tracks[0]).language; + selectedAudioTrackScore = audioSelection.second; + selectedAudioRendererIndex = i; + } + } + } + + TextTrackScore selectedTextTrackScore = null; + int selectedTextRendererIndex = C.INDEX_UNSET; + for (int i = 0; i < rendererCount; i++) { + int trackType = mappedTrackInfo.getRendererType(i); + switch (trackType) { + case C.TRACK_TYPE_VIDEO: + case C.TRACK_TYPE_AUDIO: + // Already done. Do nothing. + break; + case C.TRACK_TYPE_TEXT: + Pair<TrackSelection.Definition, TextTrackScore> textSelection = + selectTextTrack( + mappedTrackInfo.getTrackGroups(i), + rendererFormatSupports[i], + params, + selectedAudioLanguage); + if (textSelection != null + && (selectedTextTrackScore == null + || textSelection.second.compareTo(selectedTextTrackScore) > 0)) { + if (selectedTextRendererIndex != C.INDEX_UNSET) { + // We've already made a selection for another text renderer, but it had a lower score. + // Clear the selection for that renderer. + definitions[selectedTextRendererIndex] = null; + } + definitions[i] = textSelection.first; + selectedTextTrackScore = textSelection.second; + selectedTextRendererIndex = i; + } + break; + default: + definitions[i] = + selectOtherTrack( + trackType, mappedTrackInfo.getTrackGroups(i), rendererFormatSupports[i], params); + break; + } + } + + return definitions; + } + + // Video track selection implementation. + + /** + * Called by {@link #selectAllTracks(MappedTrackInfo, int[][][], int[], Parameters)} to create a + * {@link TrackSelection} for a video renderer. + * + * @param groups The {@link TrackGroupArray} mapped to the renderer. + * @param formatSupports The {@link Capabilities} for each mapped track, indexed by renderer, + * track group and track (in that order). + * @param mixedMimeTypeAdaptationSupports The {@link AdaptiveSupport} for mixed MIME type + * adaptation for the renderer. + * @param params The selector's current constraint parameters. + * @param enableAdaptiveTrackSelection Whether adaptive track selection is allowed. + * @return The {@link TrackSelection.Definition} for the renderer, or null if no selection was + * made. + * @throws ExoPlaybackException If an error occurs while selecting the tracks. + */ + @Nullable + protected TrackSelection.Definition selectVideoTrack( + TrackGroupArray groups, + @Capabilities int[][] formatSupports, + @AdaptiveSupport int mixedMimeTypeAdaptationSupports, + Parameters params, + boolean enableAdaptiveTrackSelection) + throws ExoPlaybackException { + TrackSelection.Definition definition = null; + if (!params.forceHighestSupportedBitrate + && !params.forceLowestBitrate + && enableAdaptiveTrackSelection) { + definition = + selectAdaptiveVideoTrack(groups, formatSupports, mixedMimeTypeAdaptationSupports, params); + } + if (definition == null) { + definition = selectFixedVideoTrack(groups, formatSupports, params); + } + return definition; + } + + @Nullable + private static TrackSelection.Definition selectAdaptiveVideoTrack( + TrackGroupArray groups, + @Capabilities int[][] formatSupport, + @AdaptiveSupport int mixedMimeTypeAdaptationSupports, + Parameters params) { + int requiredAdaptiveSupport = + params.allowVideoNonSeamlessAdaptiveness + ? (RendererCapabilities.ADAPTIVE_NOT_SEAMLESS | RendererCapabilities.ADAPTIVE_SEAMLESS) + : RendererCapabilities.ADAPTIVE_SEAMLESS; + boolean allowMixedMimeTypes = + params.allowVideoMixedMimeTypeAdaptiveness + && (mixedMimeTypeAdaptationSupports & requiredAdaptiveSupport) != 0; + for (int i = 0; i < groups.length; i++) { + TrackGroup group = groups.get(i); + int[] adaptiveTracks = + getAdaptiveVideoTracksForGroup( + group, + formatSupport[i], + allowMixedMimeTypes, + requiredAdaptiveSupport, + params.maxVideoWidth, + params.maxVideoHeight, + params.maxVideoFrameRate, + params.maxVideoBitrate, + params.viewportWidth, + params.viewportHeight, + params.viewportOrientationMayChange); + if (adaptiveTracks.length > 0) { + return new TrackSelection.Definition(group, adaptiveTracks); + } + } + return null; + } + + private static int[] getAdaptiveVideoTracksForGroup( + TrackGroup group, + @Capabilities int[] formatSupport, + boolean allowMixedMimeTypes, + int requiredAdaptiveSupport, + int maxVideoWidth, + int maxVideoHeight, + int maxVideoFrameRate, + int maxVideoBitrate, + int viewportWidth, + int viewportHeight, + boolean viewportOrientationMayChange) { + if (group.length < 2) { + return NO_TRACKS; + } + + List<Integer> selectedTrackIndices = getViewportFilteredTrackIndices(group, viewportWidth, + viewportHeight, viewportOrientationMayChange); + if (selectedTrackIndices.size() < 2) { + return NO_TRACKS; + } + + String selectedMimeType = null; + if (!allowMixedMimeTypes) { + // Select the mime type for which we have the most adaptive tracks. + HashSet<@NullableType String> seenMimeTypes = new HashSet<>(); + int selectedMimeTypeTrackCount = 0; + for (int i = 0; i < selectedTrackIndices.size(); i++) { + int trackIndex = selectedTrackIndices.get(i); + String sampleMimeType = group.getFormat(trackIndex).sampleMimeType; + if (seenMimeTypes.add(sampleMimeType)) { + int countForMimeType = + getAdaptiveVideoTrackCountForMimeType( + group, + formatSupport, + requiredAdaptiveSupport, + sampleMimeType, + maxVideoWidth, + maxVideoHeight, + maxVideoFrameRate, + maxVideoBitrate, + selectedTrackIndices); + if (countForMimeType > selectedMimeTypeTrackCount) { + selectedMimeType = sampleMimeType; + selectedMimeTypeTrackCount = countForMimeType; + } + } + } + } + + // Filter by the selected mime type. + filterAdaptiveVideoTrackCountForMimeType( + group, + formatSupport, + requiredAdaptiveSupport, + selectedMimeType, + maxVideoWidth, + maxVideoHeight, + maxVideoFrameRate, + maxVideoBitrate, + selectedTrackIndices); + + return selectedTrackIndices.size() < 2 ? NO_TRACKS : Util.toArray(selectedTrackIndices); + } + + private static int getAdaptiveVideoTrackCountForMimeType( + TrackGroup group, + @Capabilities int[] formatSupport, + int requiredAdaptiveSupport, + @Nullable String mimeType, + int maxVideoWidth, + int maxVideoHeight, + int maxVideoFrameRate, + int maxVideoBitrate, + List<Integer> selectedTrackIndices) { + int adaptiveTrackCount = 0; + for (int i = 0; i < selectedTrackIndices.size(); i++) { + int trackIndex = selectedTrackIndices.get(i); + if (isSupportedAdaptiveVideoTrack( + group.getFormat(trackIndex), + mimeType, + formatSupport[trackIndex], + requiredAdaptiveSupport, + maxVideoWidth, + maxVideoHeight, + maxVideoFrameRate, + maxVideoBitrate)) { + adaptiveTrackCount++; + } + } + return adaptiveTrackCount; + } + + private static void filterAdaptiveVideoTrackCountForMimeType( + TrackGroup group, + @Capabilities int[] formatSupport, + int requiredAdaptiveSupport, + @Nullable String mimeType, + int maxVideoWidth, + int maxVideoHeight, + int maxVideoFrameRate, + int maxVideoBitrate, + List<Integer> selectedTrackIndices) { + for (int i = selectedTrackIndices.size() - 1; i >= 0; i--) { + int trackIndex = selectedTrackIndices.get(i); + if (!isSupportedAdaptiveVideoTrack( + group.getFormat(trackIndex), + mimeType, + formatSupport[trackIndex], + requiredAdaptiveSupport, + maxVideoWidth, + maxVideoHeight, + maxVideoFrameRate, + maxVideoBitrate)) { + selectedTrackIndices.remove(i); + } + } + } + + private static boolean isSupportedAdaptiveVideoTrack( + Format format, + @Nullable String mimeType, + @Capabilities int formatSupport, + int requiredAdaptiveSupport, + int maxVideoWidth, + int maxVideoHeight, + int maxVideoFrameRate, + int maxVideoBitrate) { + return isSupported(formatSupport, false) + && ((formatSupport & requiredAdaptiveSupport) != 0) + && (mimeType == null || Util.areEqual(format.sampleMimeType, mimeType)) + && (format.width == Format.NO_VALUE || format.width <= maxVideoWidth) + && (format.height == Format.NO_VALUE || format.height <= maxVideoHeight) + && (format.frameRate == Format.NO_VALUE || format.frameRate <= maxVideoFrameRate) + && (format.bitrate == Format.NO_VALUE || format.bitrate <= maxVideoBitrate); + } + + @Nullable + private static TrackSelection.Definition selectFixedVideoTrack( + TrackGroupArray groups, @Capabilities int[][] formatSupports, Parameters params) { + TrackGroup selectedGroup = null; + int selectedTrackIndex = 0; + int selectedTrackScore = 0; + int selectedBitrate = Format.NO_VALUE; + int selectedPixelCount = Format.NO_VALUE; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + List<Integer> selectedTrackIndices = getViewportFilteredTrackIndices(trackGroup, + params.viewportWidth, params.viewportHeight, params.viewportOrientationMayChange); + @Capabilities int[] trackFormatSupport = formatSupports[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + boolean isWithinConstraints = + selectedTrackIndices.contains(trackIndex) + && (format.width == Format.NO_VALUE || format.width <= params.maxVideoWidth) + && (format.height == Format.NO_VALUE || format.height <= params.maxVideoHeight) + && (format.frameRate == Format.NO_VALUE + || format.frameRate <= params.maxVideoFrameRate) + && (format.bitrate == Format.NO_VALUE + || format.bitrate <= params.maxVideoBitrate); + if (!isWithinConstraints && !params.exceedVideoConstraintsIfNecessary) { + // Track should not be selected. + continue; + } + int trackScore = isWithinConstraints ? 2 : 1; + boolean isWithinCapabilities = isSupported(trackFormatSupport[trackIndex], false); + if (isWithinCapabilities) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } + boolean selectTrack = trackScore > selectedTrackScore; + if (trackScore == selectedTrackScore) { + int bitrateComparison = compareFormatValues(format.bitrate, selectedBitrate); + if (params.forceLowestBitrate && bitrateComparison != 0) { + // Use bitrate as a tie breaker, preferring the lower bitrate. + selectTrack = bitrateComparison < 0; + } else { + // Use the pixel count as a tie breaker (or bitrate if pixel counts are tied). If + // we're within constraints prefer a higher pixel count (or bitrate), else prefer a + // lower count (or bitrate). If still tied then prefer the first track (i.e. the one + // that's already selected). + int formatPixelCount = format.getPixelCount(); + int comparisonResult = formatPixelCount != selectedPixelCount + ? compareFormatValues(formatPixelCount, selectedPixelCount) + : compareFormatValues(format.bitrate, selectedBitrate); + selectTrack = isWithinCapabilities && isWithinConstraints + ? comparisonResult > 0 : comparisonResult < 0; + } + } + if (selectTrack) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + selectedBitrate = format.bitrate; + selectedPixelCount = format.getPixelCount(); + } + } + } + } + return selectedGroup == null + ? null + : new TrackSelection.Definition(selectedGroup, selectedTrackIndex); + } + + // Audio track selection implementation. + + /** + * Called by {@link #selectAllTracks(MappedTrackInfo, int[][][], int[], Parameters)} to create a + * {@link TrackSelection} for an audio renderer. + * + * @param groups The {@link TrackGroupArray} mapped to the renderer. + * @param formatSupports The {@link Capabilities} for each mapped track, indexed by renderer, + * track group and track (in that order). + * @param mixedMimeTypeAdaptationSupports The {@link AdaptiveSupport} for mixed MIME type + * adaptation for the renderer. + * @param params The selector's current constraint parameters. + * @param enableAdaptiveTrackSelection Whether adaptive track selection is allowed. + * @return The {@link TrackSelection.Definition} and corresponding {@link AudioTrackScore}, or + * null if no selection was made. + * @throws ExoPlaybackException If an error occurs while selecting the tracks. + */ + @SuppressWarnings("unused") + @Nullable + protected Pair<TrackSelection.Definition, AudioTrackScore> selectAudioTrack( + TrackGroupArray groups, + @Capabilities int[][] formatSupports, + @AdaptiveSupport int mixedMimeTypeAdaptationSupports, + Parameters params, + boolean enableAdaptiveTrackSelection) + throws ExoPlaybackException { + int selectedTrackIndex = C.INDEX_UNSET; + int selectedGroupIndex = C.INDEX_UNSET; + AudioTrackScore selectedTrackScore = null; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + @Capabilities int[] trackFormatSupport = formatSupports[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + AudioTrackScore trackScore = + new AudioTrackScore(format, params, trackFormatSupport[trackIndex]); + if (!trackScore.isWithinConstraints && !params.exceedAudioConstraintsIfNecessary) { + // Track should not be selected. + continue; + } + if (selectedTrackScore == null || trackScore.compareTo(selectedTrackScore) > 0) { + selectedGroupIndex = groupIndex; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + + if (selectedGroupIndex == C.INDEX_UNSET) { + return null; + } + + TrackGroup selectedGroup = groups.get(selectedGroupIndex); + + TrackSelection.Definition definition = null; + if (!params.forceHighestSupportedBitrate + && !params.forceLowestBitrate + && enableAdaptiveTrackSelection) { + // If the group of the track with the highest score allows it, try to enable adaptation. + int[] adaptiveTracks = + getAdaptiveAudioTracks( + selectedGroup, + formatSupports[selectedGroupIndex], + params.maxAudioBitrate, + params.allowAudioMixedMimeTypeAdaptiveness, + params.allowAudioMixedSampleRateAdaptiveness, + params.allowAudioMixedChannelCountAdaptiveness); + if (adaptiveTracks.length > 0) { + definition = new TrackSelection.Definition(selectedGroup, adaptiveTracks); + } + } + if (definition == null) { + // We didn't make an adaptive selection, so make a fixed one instead. + definition = new TrackSelection.Definition(selectedGroup, selectedTrackIndex); + } + + return Pair.create(definition, Assertions.checkNotNull(selectedTrackScore)); + } + + private static int[] getAdaptiveAudioTracks( + TrackGroup group, + @Capabilities int[] formatSupport, + int maxAudioBitrate, + boolean allowMixedMimeTypeAdaptiveness, + boolean allowMixedSampleRateAdaptiveness, + boolean allowAudioMixedChannelCountAdaptiveness) { + int selectedConfigurationTrackCount = 0; + AudioConfigurationTuple selectedConfiguration = null; + HashSet<AudioConfigurationTuple> seenConfigurationTuples = new HashSet<>(); + for (int i = 0; i < group.length; i++) { + Format format = group.getFormat(i); + AudioConfigurationTuple configuration = + new AudioConfigurationTuple( + format.channelCount, format.sampleRate, format.sampleMimeType); + if (seenConfigurationTuples.add(configuration)) { + int configurationCount = + getAdaptiveAudioTrackCount( + group, + formatSupport, + configuration, + maxAudioBitrate, + allowMixedMimeTypeAdaptiveness, + allowMixedSampleRateAdaptiveness, + allowAudioMixedChannelCountAdaptiveness); + if (configurationCount > selectedConfigurationTrackCount) { + selectedConfiguration = configuration; + selectedConfigurationTrackCount = configurationCount; + } + } + } + + if (selectedConfigurationTrackCount > 1) { + Assertions.checkNotNull(selectedConfiguration); + int[] adaptiveIndices = new int[selectedConfigurationTrackCount]; + int index = 0; + for (int i = 0; i < group.length; i++) { + Format format = group.getFormat(i); + if (isSupportedAdaptiveAudioTrack( + format, + formatSupport[i], + selectedConfiguration, + maxAudioBitrate, + allowMixedMimeTypeAdaptiveness, + allowMixedSampleRateAdaptiveness, + allowAudioMixedChannelCountAdaptiveness)) { + adaptiveIndices[index++] = i; + } + } + return adaptiveIndices; + } + return NO_TRACKS; + } + + private static int getAdaptiveAudioTrackCount( + TrackGroup group, + @Capabilities int[] formatSupport, + AudioConfigurationTuple configuration, + int maxAudioBitrate, + boolean allowMixedMimeTypeAdaptiveness, + boolean allowMixedSampleRateAdaptiveness, + boolean allowAudioMixedChannelCountAdaptiveness) { + int count = 0; + for (int i = 0; i < group.length; i++) { + if (isSupportedAdaptiveAudioTrack( + group.getFormat(i), + formatSupport[i], + configuration, + maxAudioBitrate, + allowMixedMimeTypeAdaptiveness, + allowMixedSampleRateAdaptiveness, + allowAudioMixedChannelCountAdaptiveness)) { + count++; + } + } + return count; + } + + private static boolean isSupportedAdaptiveAudioTrack( + Format format, + @Capabilities int formatSupport, + AudioConfigurationTuple configuration, + int maxAudioBitrate, + boolean allowMixedMimeTypeAdaptiveness, + boolean allowMixedSampleRateAdaptiveness, + boolean allowAudioMixedChannelCountAdaptiveness) { + return isSupported(formatSupport, false) + && (format.bitrate == Format.NO_VALUE || format.bitrate <= maxAudioBitrate) + && (allowAudioMixedChannelCountAdaptiveness + || (format.channelCount != Format.NO_VALUE + && format.channelCount == configuration.channelCount)) + && (allowMixedMimeTypeAdaptiveness + || (format.sampleMimeType != null + && TextUtils.equals(format.sampleMimeType, configuration.mimeType))) + && (allowMixedSampleRateAdaptiveness + || (format.sampleRate != Format.NO_VALUE + && format.sampleRate == configuration.sampleRate)); + } + + // Text track selection implementation. + + /** + * Called by {@link #selectAllTracks(MappedTrackInfo, int[][][], int[], Parameters)} to create a + * {@link TrackSelection} for a text renderer. + * + * @param groups The {@link TrackGroupArray} mapped to the renderer. + * @param formatSupport The {@link Capabilities} for each mapped track, indexed by renderer, track + * group and track (in that order). + * @param params The selector's current constraint parameters. + * @param selectedAudioLanguage The language of the selected audio track. May be null if the + * selected text track declares no language or no text track was selected. + * @return The {@link TrackSelection.Definition} and corresponding {@link TextTrackScore}, or null + * if no selection was made. + * @throws ExoPlaybackException If an error occurs while selecting the tracks. + */ + @Nullable + protected Pair<TrackSelection.Definition, TextTrackScore> selectTextTrack( + TrackGroupArray groups, + @Capabilities int[][] formatSupport, + Parameters params, + @Nullable String selectedAudioLanguage) + throws ExoPlaybackException { + TrackGroup selectedGroup = null; + int selectedTrackIndex = C.INDEX_UNSET; + TextTrackScore selectedTrackScore = null; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + TextTrackScore trackScore = + new TextTrackScore( + format, params, trackFormatSupport[trackIndex], selectedAudioLanguage); + if (trackScore.isWithinConstraints + && (selectedTrackScore == null || trackScore.compareTo(selectedTrackScore) > 0)) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + return selectedGroup == null + ? null + : Pair.create( + new TrackSelection.Definition(selectedGroup, selectedTrackIndex), + Assertions.checkNotNull(selectedTrackScore)); + } + + // General track selection methods. + + /** + * Called by {@link #selectAllTracks(MappedTrackInfo, int[][][], int[], Parameters)} to create a + * {@link TrackSelection} for a renderer whose type is neither video, audio or text. + * + * @param trackType The type of the renderer. + * @param groups The {@link TrackGroupArray} mapped to the renderer. + * @param formatSupport The {@link Capabilities} for each mapped track, indexed by renderer, track + * group and track (in that order). + * @param params The selector's current constraint parameters. + * @return The {@link TrackSelection} for the renderer, or null if no selection was made. + * @throws ExoPlaybackException If an error occurs while selecting the tracks. + */ + @Nullable + protected TrackSelection.Definition selectOtherTrack( + int trackType, TrackGroupArray groups, @Capabilities int[][] formatSupport, Parameters params) + throws ExoPlaybackException { + TrackGroup selectedGroup = null; + int selectedTrackIndex = 0; + int selectedTrackScore = 0; + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + boolean isDefault = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + int trackScore = isDefault ? 2 : 1; + if (isSupported(trackFormatSupport[trackIndex], false)) { + trackScore += WITHIN_RENDERER_CAPABILITIES_BONUS; + } + if (trackScore > selectedTrackScore) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + return selectedGroup == null + ? null + : new TrackSelection.Definition(selectedGroup, selectedTrackIndex); + } + + // Utility methods. + + /** + * Determines whether tunneling should be enabled, replacing {@link RendererConfiguration}s in + * {@code rendererConfigurations} with configurations that enable tunneling on the appropriate + * renderers if so. + * + * @param mappedTrackInfo Mapped track information. + * @param renderererFormatSupports The {@link Capabilities} for each mapped track, indexed by + * renderer, track group and track (in that order). + * @param rendererConfigurations The renderer configurations. Configurations may be replaced with + * ones that enable tunneling as a result of this call. + * @param trackSelections The renderer track selections. + * @param tunnelingAudioSessionId The audio session id to use when tunneling, or {@link + * C#AUDIO_SESSION_ID_UNSET} if tunneling should not be enabled. + */ + private static void maybeConfigureRenderersForTunneling( + MappedTrackInfo mappedTrackInfo, + @Capabilities int[][][] renderererFormatSupports, + @NullableType RendererConfiguration[] rendererConfigurations, + @NullableType TrackSelection[] trackSelections, + int tunnelingAudioSessionId) { + if (tunnelingAudioSessionId == C.AUDIO_SESSION_ID_UNSET) { + return; + } + // Check whether we can enable tunneling. To enable tunneling we require exactly one audio and + // one video renderer to support tunneling and have a selection. + int tunnelingAudioRendererIndex = -1; + int tunnelingVideoRendererIndex = -1; + boolean enableTunneling = true; + for (int i = 0; i < mappedTrackInfo.getRendererCount(); i++) { + int rendererType = mappedTrackInfo.getRendererType(i); + TrackSelection trackSelection = trackSelections[i]; + if ((rendererType == C.TRACK_TYPE_AUDIO || rendererType == C.TRACK_TYPE_VIDEO) + && trackSelection != null) { + if (rendererSupportsTunneling( + renderererFormatSupports[i], mappedTrackInfo.getTrackGroups(i), trackSelection)) { + if (rendererType == C.TRACK_TYPE_AUDIO) { + if (tunnelingAudioRendererIndex != -1) { + enableTunneling = false; + break; + } else { + tunnelingAudioRendererIndex = i; + } + } else { + if (tunnelingVideoRendererIndex != -1) { + enableTunneling = false; + break; + } else { + tunnelingVideoRendererIndex = i; + } + } + } + } + } + enableTunneling &= tunnelingAudioRendererIndex != -1 && tunnelingVideoRendererIndex != -1; + if (enableTunneling) { + RendererConfiguration tunnelingRendererConfiguration = + new RendererConfiguration(tunnelingAudioSessionId); + rendererConfigurations[tunnelingAudioRendererIndex] = tunnelingRendererConfiguration; + rendererConfigurations[tunnelingVideoRendererIndex] = tunnelingRendererConfiguration; + } + } + + /** + * Returns whether a renderer supports tunneling for a {@link TrackSelection}. + * + * @param formatSupports The {@link Capabilities} for each track, indexed by group index and track + * index (in that order). + * @param trackGroups The {@link TrackGroupArray}s for the renderer. + * @param selection The track selection. + * @return Whether the renderer supports tunneling for the {@link TrackSelection}. + */ + private static boolean rendererSupportsTunneling( + @Capabilities int[][] formatSupports, TrackGroupArray trackGroups, TrackSelection selection) { + if (selection == null) { + return false; + } + int trackGroupIndex = trackGroups.indexOf(selection.getTrackGroup()); + for (int i = 0; i < selection.length(); i++) { + @Capabilities + int trackFormatSupport = formatSupports[trackGroupIndex][selection.getIndexInTrackGroup(i)]; + if (RendererCapabilities.getTunnelingSupport(trackFormatSupport) + != RendererCapabilities.TUNNELING_SUPPORTED) { + return false; + } + } + return true; + } + + /** + * Compares two format values for order. A known value is considered greater than {@link + * Format#NO_VALUE}. + * + * @param first The first value. + * @param second The second value. + * @return A negative integer if the first value is less than the second. Zero if they are equal. + * A positive integer if the first value is greater than the second. + */ + private static int compareFormatValues(int first, int second) { + return first == Format.NO_VALUE + ? (second == Format.NO_VALUE ? 0 : -1) + : (second == Format.NO_VALUE ? 1 : (first - second)); + } + + /** + * Returns true if the {@link FormatSupport} in the given {@link Capabilities} is {@link + * RendererCapabilities#FORMAT_HANDLED} or if {@code allowExceedsCapabilities} is set and the + * format support is {@link RendererCapabilities#FORMAT_EXCEEDS_CAPABILITIES}. + * + * @param formatSupport {@link Capabilities}. + * @param allowExceedsCapabilities Whether to return true if {@link FormatSupport} is {@link + * RendererCapabilities#FORMAT_EXCEEDS_CAPABILITIES}. + * @return True if {@link FormatSupport} is {@link RendererCapabilities#FORMAT_HANDLED}, or if + * {@code allowExceedsCapabilities} is set and the format support is {@link + * RendererCapabilities#FORMAT_EXCEEDS_CAPABILITIES}. + */ + protected static boolean isSupported( + @Capabilities int formatSupport, boolean allowExceedsCapabilities) { + @FormatSupport int maskedSupport = RendererCapabilities.getFormatSupport(formatSupport); + return maskedSupport == RendererCapabilities.FORMAT_HANDLED || (allowExceedsCapabilities + && maskedSupport == RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES); + } + + /** + * Normalizes the input string to null if it does not define a language, or returns it otherwise. + * + * @param language The string. + * @return The string, optionally normalized to null if it does not define a language. + */ + @Nullable + protected static String normalizeUndeterminedLanguageToNull(@Nullable String language) { + return TextUtils.isEmpty(language) || TextUtils.equals(language, C.LANGUAGE_UNDETERMINED) + ? null + : language; + } + + /** + * Returns a score for how well a language specified in a {@link Format} matches a given language. + * + * @param format The {@link Format}. + * @param language The language, or null. + * @param allowUndeterminedFormatLanguage Whether matches with an empty or undetermined format + * language tag are allowed. + * @return A score of 4 if the languages match fully, a score of 3 if the languages match partly, + * a score of 2 if the languages don't match but belong to the same main language, a score of + * 1 if the format language is undetermined and such a match is allowed, and a score of 0 if + * the languages don't match at all. + */ + protected static int getFormatLanguageScore( + Format format, @Nullable String language, boolean allowUndeterminedFormatLanguage) { + if (!TextUtils.isEmpty(language) && language.equals(format.language)) { + // Full literal match of non-empty languages, including matches of an explicit "und" query. + return 4; + } + language = normalizeUndeterminedLanguageToNull(language); + String formatLanguage = normalizeUndeterminedLanguageToNull(format.language); + if (formatLanguage == null || language == null) { + // At least one of the languages is undetermined. + return allowUndeterminedFormatLanguage && formatLanguage == null ? 1 : 0; + } + if (formatLanguage.startsWith(language) || language.startsWith(formatLanguage)) { + // Partial match where one language is a subset of the other (e.g. "zh-hans" and "zh-hans-hk") + return 3; + } + String formatMainLanguage = Util.splitAtFirst(formatLanguage, "-")[0]; + String queryMainLanguage = Util.splitAtFirst(language, "-")[0]; + if (formatMainLanguage.equals(queryMainLanguage)) { + // Partial match where only the main language tag is the same (e.g. "fr-fr" and "fr-ca") + return 2; + } + return 0; + } + + private static List<Integer> getViewportFilteredTrackIndices(TrackGroup group, int viewportWidth, + int viewportHeight, boolean orientationMayChange) { + // Initially include all indices. + ArrayList<Integer> selectedTrackIndices = new ArrayList<>(group.length); + for (int i = 0; i < group.length; i++) { + selectedTrackIndices.add(i); + } + + if (viewportWidth == Integer.MAX_VALUE || viewportHeight == Integer.MAX_VALUE) { + // Viewport dimensions not set. Return the full set of indices. + return selectedTrackIndices; + } + + int maxVideoPixelsToRetain = Integer.MAX_VALUE; + for (int i = 0; i < group.length; i++) { + Format format = group.getFormat(i); + // Keep track of the number of pixels of the selected format whose resolution is the + // smallest to exceed the maximum size at which it can be displayed within the viewport. + // We'll discard formats of higher resolution. + if (format.width > 0 && format.height > 0) { + Point maxVideoSizeInViewport = getMaxVideoSizeInViewport(orientationMayChange, + viewportWidth, viewportHeight, format.width, format.height); + int videoPixels = format.width * format.height; + if (format.width >= (int) (maxVideoSizeInViewport.x * FRACTION_TO_CONSIDER_FULLSCREEN) + && format.height >= (int) (maxVideoSizeInViewport.y * FRACTION_TO_CONSIDER_FULLSCREEN) + && videoPixels < maxVideoPixelsToRetain) { + maxVideoPixelsToRetain = videoPixels; + } + } + } + + // Filter out formats that exceed maxVideoPixelsToRetain. These formats have an unnecessarily + // high resolution given the size at which the video will be displayed within the viewport. Also + // filter out formats with unknown dimensions, since we have some whose dimensions are known. + if (maxVideoPixelsToRetain != Integer.MAX_VALUE) { + for (int i = selectedTrackIndices.size() - 1; i >= 0; i--) { + Format format = group.getFormat(selectedTrackIndices.get(i)); + int pixelCount = format.getPixelCount(); + if (pixelCount == Format.NO_VALUE || pixelCount > maxVideoPixelsToRetain) { + selectedTrackIndices.remove(i); + } + } + } + + return selectedTrackIndices; + } + + /** + * Given viewport dimensions and video dimensions, computes the maximum size of the video as it + * will be rendered to fit inside of the viewport. + */ + private static Point getMaxVideoSizeInViewport(boolean orientationMayChange, int viewportWidth, + int viewportHeight, int videoWidth, int videoHeight) { + if (orientationMayChange && (videoWidth > videoHeight) != (viewportWidth > viewportHeight)) { + // Rotation is allowed, and the video will be larger in the rotated viewport. + int tempViewportWidth = viewportWidth; + viewportWidth = viewportHeight; + viewportHeight = tempViewportWidth; + } + + if (videoWidth * viewportHeight >= videoHeight * viewportWidth) { + // Horizontal letter-boxing along top and bottom. + return new Point(viewportWidth, Util.ceilDivide(viewportWidth * videoHeight, videoWidth)); + } else { + // Vertical letter-boxing along edges. + return new Point(Util.ceilDivide(viewportHeight * videoWidth, videoHeight), viewportHeight); + } + } + + /** + * Compares two integers in a safe way avoiding potential overflow. + * + * @param first The first value. + * @param second The second value. + * @return A negative integer if the first value is less than the second. Zero if they are equal. + * A positive integer if the first value is greater than the second. + */ + private static int compareInts(int first, int second) { + return first > second ? 1 : (second > first ? -1 : 0); + } + + /** Represents how well an audio track matches the selection {@link Parameters}. */ + protected static final class AudioTrackScore implements Comparable<AudioTrackScore> { + + /** + * Whether the provided format is within the parameter constraints. If {@code false}, the format + * should not be selected. + */ + public final boolean isWithinConstraints; + + @Nullable private final String language; + private final Parameters parameters; + private final boolean isWithinRendererCapabilities; + private final int preferredLanguageScore; + private final int localeLanguageMatchIndex; + private final int localeLanguageScore; + private final boolean isDefaultSelectionFlag; + private final int channelCount; + private final int sampleRate; + private final int bitrate; + + public AudioTrackScore(Format format, Parameters parameters, @Capabilities int formatSupport) { + this.parameters = parameters; + this.language = normalizeUndeterminedLanguageToNull(format.language); + isWithinRendererCapabilities = isSupported(formatSupport, false); + preferredLanguageScore = + getFormatLanguageScore( + format, + parameters.preferredAudioLanguage, + /* allowUndeterminedFormatLanguage= */ false); + isDefaultSelectionFlag = (format.selectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + channelCount = format.channelCount; + sampleRate = format.sampleRate; + bitrate = format.bitrate; + isWithinConstraints = + (format.bitrate == Format.NO_VALUE || format.bitrate <= parameters.maxAudioBitrate) + && (format.channelCount == Format.NO_VALUE + || format.channelCount <= parameters.maxAudioChannelCount); + String[] localeLanguages = Util.getSystemLanguageCodes(); + int bestMatchIndex = Integer.MAX_VALUE; + int bestMatchScore = 0; + for (int i = 0; i < localeLanguages.length; i++) { + int score = + getFormatLanguageScore( + format, localeLanguages[i], /* allowUndeterminedFormatLanguage= */ false); + if (score > 0) { + bestMatchIndex = i; + bestMatchScore = score; + break; + } + } + localeLanguageMatchIndex = bestMatchIndex; + localeLanguageScore = bestMatchScore; + } + + /** + * Compares this score with another. + * + * @param other The other score to compare to. + * @return A positive integer if this score is better than the other. Zero if they are equal. A + * negative integer if this score is worse than the other. + */ + @Override + public int compareTo(AudioTrackScore other) { + if (this.isWithinRendererCapabilities != other.isWithinRendererCapabilities) { + return this.isWithinRendererCapabilities ? 1 : -1; + } + if (this.preferredLanguageScore != other.preferredLanguageScore) { + return compareInts(this.preferredLanguageScore, other.preferredLanguageScore); + } + if (this.isWithinConstraints != other.isWithinConstraints) { + return this.isWithinConstraints ? 1 : -1; + } + if (parameters.forceLowestBitrate) { + int bitrateComparison = compareFormatValues(bitrate, other.bitrate); + if (bitrateComparison != 0) { + return bitrateComparison > 0 ? -1 : 1; + } + } + if (this.isDefaultSelectionFlag != other.isDefaultSelectionFlag) { + return this.isDefaultSelectionFlag ? 1 : -1; + } + if (this.localeLanguageMatchIndex != other.localeLanguageMatchIndex) { + return -compareInts(this.localeLanguageMatchIndex, other.localeLanguageMatchIndex); + } + if (this.localeLanguageScore != other.localeLanguageScore) { + return compareInts(this.localeLanguageScore, other.localeLanguageScore); + } + // If the formats are within constraints and renderer capabilities then prefer higher values + // of channel count, sample rate and bit rate in that order. Otherwise, prefer lower values. + int resultSign = isWithinConstraints && isWithinRendererCapabilities ? 1 : -1; + if (this.channelCount != other.channelCount) { + return resultSign * compareInts(this.channelCount, other.channelCount); + } + if (this.sampleRate != other.sampleRate) { + return resultSign * compareInts(this.sampleRate, other.sampleRate); + } + if (Util.areEqual(this.language, other.language)) { + // Only compare bit rates of tracks with the same or unknown language. + return resultSign * compareInts(this.bitrate, other.bitrate); + } + return 0; + } + } + + private static final class AudioConfigurationTuple { + + public final int channelCount; + public final int sampleRate; + @Nullable public final String mimeType; + + public AudioConfigurationTuple(int channelCount, int sampleRate, @Nullable String mimeType) { + this.channelCount = channelCount; + this.sampleRate = sampleRate; + this.mimeType = mimeType; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + AudioConfigurationTuple other = (AudioConfigurationTuple) obj; + return channelCount == other.channelCount && sampleRate == other.sampleRate + && TextUtils.equals(mimeType, other.mimeType); + } + + @Override + public int hashCode() { + int result = channelCount; + result = 31 * result + sampleRate; + result = 31 * result + (mimeType != null ? mimeType.hashCode() : 0); + return result; + } + + } + + /** Represents how well a text track matches the selection {@link Parameters}. */ + protected static final class TextTrackScore implements Comparable<TextTrackScore> { + + /** + * Whether the provided format is within the parameter constraints. If {@code false}, the format + * should not be selected. + */ + public final boolean isWithinConstraints; + + private final boolean isWithinRendererCapabilities; + private final boolean isDefault; + private final boolean hasPreferredIsForcedFlag; + private final int preferredLanguageScore; + private final int preferredRoleFlagsScore; + private final int selectedAudioLanguageScore; + private final boolean hasCaptionRoleFlags; + + public TextTrackScore( + Format format, + Parameters parameters, + @Capabilities int trackFormatSupport, + @Nullable String selectedAudioLanguage) { + isWithinRendererCapabilities = + isSupported(trackFormatSupport, /* allowExceedsCapabilities= */ false); + int maskedSelectionFlags = + format.selectionFlags & ~parameters.disabledTextTrackSelectionFlags; + isDefault = (maskedSelectionFlags & C.SELECTION_FLAG_DEFAULT) != 0; + boolean isForced = (maskedSelectionFlags & C.SELECTION_FLAG_FORCED) != 0; + preferredLanguageScore = + getFormatLanguageScore( + format, parameters.preferredTextLanguage, parameters.selectUndeterminedTextLanguage); + preferredRoleFlagsScore = + Integer.bitCount(format.roleFlags & parameters.preferredTextRoleFlags); + hasCaptionRoleFlags = + (format.roleFlags & (C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND)) != 0; + // Prefer non-forced to forced if a preferred text language has been matched. Where both are + // provided the non-forced track will usually contain the forced subtitles as a subset. + // Otherwise, prefer a forced track. + hasPreferredIsForcedFlag = + (preferredLanguageScore > 0 && !isForced) || (preferredLanguageScore == 0 && isForced); + boolean selectedAudioLanguageUndetermined = + normalizeUndeterminedLanguageToNull(selectedAudioLanguage) == null; + selectedAudioLanguageScore = + getFormatLanguageScore(format, selectedAudioLanguage, selectedAudioLanguageUndetermined); + isWithinConstraints = + preferredLanguageScore > 0 + || (parameters.preferredTextLanguage == null && preferredRoleFlagsScore > 0) + || isDefault + || (isForced && selectedAudioLanguageScore > 0); + } + + /** + * Compares this score with another. + * + * @param other The other score to compare to. + * @return A positive integer if this score is better than the other. Zero if they are equal. A + * negative integer if this score is worse than the other. + */ + @Override + public int compareTo(TextTrackScore other) { + if (this.isWithinRendererCapabilities != other.isWithinRendererCapabilities) { + return this.isWithinRendererCapabilities ? 1 : -1; + } + if (this.preferredLanguageScore != other.preferredLanguageScore) { + return compareInts(this.preferredLanguageScore, other.preferredLanguageScore); + } + if (this.preferredRoleFlagsScore != other.preferredRoleFlagsScore) { + return compareInts(this.preferredRoleFlagsScore, other.preferredRoleFlagsScore); + } + if (this.isDefault != other.isDefault) { + return this.isDefault ? 1 : -1; + } + if (this.hasPreferredIsForcedFlag != other.hasPreferredIsForcedFlag) { + return this.hasPreferredIsForcedFlag ? 1 : -1; + } + if (this.selectedAudioLanguageScore != other.selectedAudioLanguageScore) { + return compareInts(this.selectedAudioLanguageScore, other.selectedAudioLanguageScore); + } + if (preferredRoleFlagsScore == 0 && this.hasCaptionRoleFlags != other.hasCaptionRoleFlags) { + return this.hasCaptionRoleFlags ? -1 : 1; + } + return 0; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java new file mode 100644 index 0000000000..824abaccfa --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/FixedTrackSelection.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A {@link TrackSelection} consisting of a single track. + */ +public final class FixedTrackSelection extends BaseTrackSelection { + + /** + * @deprecated Don't use as adaptive track selection factory as it will throw when multiple tracks + * are selected. If you would like to disable adaptive selection in {@link + * DefaultTrackSelector}, enable the {@link + * DefaultTrackSelector.Parameters#forceHighestSupportedBitrate} flag instead. + */ + @Deprecated + public static final class Factory implements TrackSelection.Factory { + + private final int reason; + @Nullable private final Object data; + + public Factory() { + this.reason = C.SELECTION_REASON_UNKNOWN; + this.data = null; + } + + /** + * @param reason A reason for the track selection. + * @param data Optional data associated with the track selection. + */ + public Factory(int reason, @Nullable Object data) { + this.reason = reason; + this.data = data; + } + + @Override + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> + new FixedTrackSelection(definition.group, definition.tracks[0], reason, data)); + } + } + + private final int reason; + @Nullable private final Object data; + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param track The index of the selected track within the {@link TrackGroup}. + */ + public FixedTrackSelection(TrackGroup group, int track) { + this(group, track, C.SELECTION_REASON_UNKNOWN, null); + } + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param track The index of the selected track within the {@link TrackGroup}. + * @param reason A reason for the track selection. + * @param data Optional data associated with the track selection. + */ + public FixedTrackSelection(TrackGroup group, int track, int reason, @Nullable Object data) { + super(group, track); + this.reason = reason; + this.data = data; + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + // Do nothing. + } + + @Override + public int getSelectedIndex() { + return 0; + } + + @Override + public int getSelectionReason() { + return reason; + } + + @Override + @Nullable + public Object getSelectionData() { + return data; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java new file mode 100644 index 0000000000..8ba581020b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/MappingTrackSelector.java @@ -0,0 +1,541 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import android.util.Pair; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +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.Renderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.AdaptiveSupport; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.Capabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.FormatSupport; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererConfiguration; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +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.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * Base class for {@link TrackSelector}s that first establish a mapping between {@link TrackGroup}s + * and {@link Renderer}s, and then from that mapping create a {@link TrackSelection} for each + * renderer. + */ +public abstract class MappingTrackSelector extends TrackSelector { + + /** + * Provides mapped track information for each renderer. + */ + public static final class MappedTrackInfo { + + /** + * Levels of renderer support. Higher numerical values indicate higher levels of support. One of + * {@link #RENDERER_SUPPORT_NO_TRACKS}, {@link #RENDERER_SUPPORT_UNSUPPORTED_TRACKS}, {@link + * #RENDERER_SUPPORT_EXCEEDS_CAPABILITIES_TRACKS} or {@link #RENDERER_SUPPORT_PLAYABLE_TRACKS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + RENDERER_SUPPORT_NO_TRACKS, + RENDERER_SUPPORT_UNSUPPORTED_TRACKS, + RENDERER_SUPPORT_EXCEEDS_CAPABILITIES_TRACKS, + RENDERER_SUPPORT_PLAYABLE_TRACKS + }) + @interface RendererSupport {} + /** The renderer does not have any associated tracks. */ + public static final int RENDERER_SUPPORT_NO_TRACKS = 0; + /** + * The renderer has tracks mapped to it, but all are unsupported. In other words, {@link + * #getTrackSupport(int, int, int)} returns {@link RendererCapabilities#FORMAT_UNSUPPORTED_DRM}, + * {@link RendererCapabilities#FORMAT_UNSUPPORTED_SUBTYPE} or {@link + * RendererCapabilities#FORMAT_UNSUPPORTED_TYPE} for all tracks mapped to the renderer. + */ + public static final int RENDERER_SUPPORT_UNSUPPORTED_TRACKS = 1; + /** + * The renderer has tracks mapped to it and at least one is of a supported type, but all such + * tracks exceed the renderer's capabilities. In other words, {@link #getTrackSupport(int, int, + * int)} returns {@link RendererCapabilities#FORMAT_EXCEEDS_CAPABILITIES} for at least one + * track mapped to the renderer, but does not return {@link + * RendererCapabilities#FORMAT_HANDLED} for any tracks mapped to the renderer. + */ + public static final int RENDERER_SUPPORT_EXCEEDS_CAPABILITIES_TRACKS = 2; + /** + * The renderer has tracks mapped to it, and at least one such track is playable. In other + * words, {@link #getTrackSupport(int, int, int)} returns {@link + * RendererCapabilities#FORMAT_HANDLED} for at least one track mapped to the renderer. + */ + public static final int RENDERER_SUPPORT_PLAYABLE_TRACKS = 3; + + /** @deprecated Use {@link #getRendererCount()}. */ + @Deprecated public final int length; + + private final int rendererCount; + private final int[] rendererTrackTypes; + private final TrackGroupArray[] rendererTrackGroups; + @AdaptiveSupport private final int[] rendererMixedMimeTypeAdaptiveSupports; + @Capabilities private final int[][][] rendererFormatSupports; + private final TrackGroupArray unmappedTrackGroups; + + /** + * @param rendererTrackTypes The track type handled by each renderer. + * @param rendererTrackGroups The {@link TrackGroup}s mapped to each renderer. + * @param rendererMixedMimeTypeAdaptiveSupports The {@link AdaptiveSupport} for mixed MIME type + * adaptation for the renderer. + * @param rendererFormatSupports The {@link Capabilities} for each mapped track, indexed by + * renderer, track group and track (in that order). + * @param unmappedTrackGroups {@link TrackGroup}s not mapped to any renderer. + */ + @SuppressWarnings("deprecation") + /* package */ MappedTrackInfo( + int[] rendererTrackTypes, + TrackGroupArray[] rendererTrackGroups, + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptiveSupports, + @Capabilities int[][][] rendererFormatSupports, + TrackGroupArray unmappedTrackGroups) { + this.rendererTrackTypes = rendererTrackTypes; + this.rendererTrackGroups = rendererTrackGroups; + this.rendererFormatSupports = rendererFormatSupports; + this.rendererMixedMimeTypeAdaptiveSupports = rendererMixedMimeTypeAdaptiveSupports; + this.unmappedTrackGroups = unmappedTrackGroups; + this.rendererCount = rendererTrackTypes.length; + this.length = rendererCount; + } + + /** Returns the number of renderers. */ + public int getRendererCount() { + return rendererCount; + } + + /** + * Returns the track type that the renderer at a given index handles. + * + * @see Renderer#getTrackType() + * @param rendererIndex The renderer index. + * @return One of the {@code TRACK_TYPE_*} constants defined in {@link C}. + */ + public int getRendererType(int rendererIndex) { + return rendererTrackTypes[rendererIndex]; + } + + /** + * Returns the {@link TrackGroup}s mapped to the renderer at the specified index. + * + * @param rendererIndex The renderer index. + * @return The corresponding {@link TrackGroup}s. + */ + public TrackGroupArray getTrackGroups(int rendererIndex) { + return rendererTrackGroups[rendererIndex]; + } + + /** + * Returns the extent to which a renderer can play the tracks that are mapped to it. + * + * @param rendererIndex The renderer index. + * @return The {@link RendererSupport}. + */ + @RendererSupport + public int getRendererSupport(int rendererIndex) { + @RendererSupport int bestRendererSupport = RENDERER_SUPPORT_NO_TRACKS; + @Capabilities int[][] rendererFormatSupport = rendererFormatSupports[rendererIndex]; + for (@Capabilities int[] trackGroupFormatSupport : rendererFormatSupport) { + for (@Capabilities int trackFormatSupport : trackGroupFormatSupport) { + int trackRendererSupport; + switch (RendererCapabilities.getFormatSupport(trackFormatSupport)) { + case RendererCapabilities.FORMAT_HANDLED: + return RENDERER_SUPPORT_PLAYABLE_TRACKS; + case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES: + trackRendererSupport = RENDERER_SUPPORT_EXCEEDS_CAPABILITIES_TRACKS; + break; + case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE: + case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE: + case RendererCapabilities.FORMAT_UNSUPPORTED_DRM: + trackRendererSupport = RENDERER_SUPPORT_UNSUPPORTED_TRACKS; + break; + default: + throw new IllegalStateException(); + } + bestRendererSupport = Math.max(bestRendererSupport, trackRendererSupport); + } + } + return bestRendererSupport; + } + + /** @deprecated Use {@link #getTypeSupport(int)}. */ + @Deprecated + @RendererSupport + public int getTrackTypeRendererSupport(int trackType) { + return getTypeSupport(trackType); + } + + /** + * Returns the extent to which tracks of a specified type are supported. This is the best level + * of support obtained from {@link #getRendererSupport(int)} for all renderers that handle the + * specified type. If no such renderers exist then {@link #RENDERER_SUPPORT_NO_TRACKS} is + * returned. + * + * @param trackType The track type. One of the {@link C} {@code TRACK_TYPE_*} constants. + * @return The {@link RendererSupport}. + */ + @RendererSupport + public int getTypeSupport(int trackType) { + @RendererSupport int bestRendererSupport = RENDERER_SUPPORT_NO_TRACKS; + for (int i = 0; i < rendererCount; i++) { + if (rendererTrackTypes[i] == trackType) { + bestRendererSupport = Math.max(bestRendererSupport, getRendererSupport(i)); + } + } + return bestRendererSupport; + } + + /** @deprecated Use {@link #getTrackSupport(int, int, int)}. */ + @Deprecated + @FormatSupport + public int getTrackFormatSupport(int rendererIndex, int groupIndex, int trackIndex) { + return getTrackSupport(rendererIndex, groupIndex, trackIndex); + } + + /** + * Returns the extent to which an individual track is supported by the renderer. + * + * @param rendererIndex The renderer index. + * @param groupIndex The index of the track group to which the track belongs. + * @param trackIndex The index of the track within the track group. + * @return The {@link FormatSupport}. + */ + @FormatSupport + public int getTrackSupport(int rendererIndex, int groupIndex, int trackIndex) { + return RendererCapabilities.getFormatSupport( + rendererFormatSupports[rendererIndex][groupIndex][trackIndex]); + } + + /** + * Returns the extent to which a renderer supports adaptation between supported tracks in a + * specified {@link TrackGroup}. + * + * <p>Tracks for which {@link #getTrackSupport(int, int, int)} returns {@link + * RendererCapabilities#FORMAT_HANDLED} are always considered. Tracks for which {@link + * #getTrackSupport(int, int, int)} returns {@link + * RendererCapabilities#FORMAT_EXCEEDS_CAPABILITIES} are also considered if {@code + * includeCapabilitiesExceededTracks} is set to {@code true}. Tracks for which {@link + * #getTrackSupport(int, int, int)} returns {@link RendererCapabilities#FORMAT_UNSUPPORTED_DRM}, + * {@link RendererCapabilities#FORMAT_UNSUPPORTED_TYPE} or {@link + * RendererCapabilities#FORMAT_UNSUPPORTED_SUBTYPE} are never considered. + * + * @param rendererIndex The renderer index. + * @param groupIndex The index of the track group. + * @param includeCapabilitiesExceededTracks Whether tracks that exceed the capabilities of the + * renderer are included when determining support. + * @return The {@link AdaptiveSupport}. + */ + @AdaptiveSupport + public int getAdaptiveSupport( + int rendererIndex, int groupIndex, boolean includeCapabilitiesExceededTracks) { + int trackCount = rendererTrackGroups[rendererIndex].get(groupIndex).length; + // Iterate over the tracks in the group, recording the indices of those to consider. + int[] trackIndices = new int[trackCount]; + int trackIndexCount = 0; + for (int i = 0; i < trackCount; i++) { + @FormatSupport int fixedSupport = getTrackSupport(rendererIndex, groupIndex, i); + if (fixedSupport == RendererCapabilities.FORMAT_HANDLED + || (includeCapabilitiesExceededTracks + && fixedSupport == RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES)) { + trackIndices[trackIndexCount++] = i; + } + } + trackIndices = Arrays.copyOf(trackIndices, trackIndexCount); + return getAdaptiveSupport(rendererIndex, groupIndex, trackIndices); + } + + /** + * Returns the extent to which a renderer supports adaptation between specified tracks within a + * {@link TrackGroup}. + * + * @param rendererIndex The renderer index. + * @param groupIndex The index of the track group. + * @return The {@link AdaptiveSupport}. + */ + @AdaptiveSupport + public int getAdaptiveSupport(int rendererIndex, int groupIndex, int[] trackIndices) { + int handledTrackCount = 0; + @AdaptiveSupport int adaptiveSupport = RendererCapabilities.ADAPTIVE_SEAMLESS; + boolean multipleMimeTypes = false; + String firstSampleMimeType = null; + for (int i = 0; i < trackIndices.length; i++) { + int trackIndex = trackIndices[i]; + String sampleMimeType = + rendererTrackGroups[rendererIndex].get(groupIndex).getFormat(trackIndex).sampleMimeType; + if (handledTrackCount++ == 0) { + firstSampleMimeType = sampleMimeType; + } else { + multipleMimeTypes |= !Util.areEqual(firstSampleMimeType, sampleMimeType); + } + adaptiveSupport = + Math.min( + adaptiveSupport, + RendererCapabilities.getAdaptiveSupport( + rendererFormatSupports[rendererIndex][groupIndex][i])); + } + return multipleMimeTypes + ? Math.min(adaptiveSupport, rendererMixedMimeTypeAdaptiveSupports[rendererIndex]) + : adaptiveSupport; + } + + /** @deprecated Use {@link #getUnmappedTrackGroups()}. */ + @Deprecated + public TrackGroupArray getUnassociatedTrackGroups() { + return getUnmappedTrackGroups(); + } + + /** Returns {@link TrackGroup}s not mapped to any renderer. */ + public TrackGroupArray getUnmappedTrackGroups() { + return unmappedTrackGroups; + } + + } + + @Nullable private MappedTrackInfo currentMappedTrackInfo; + + /** + * Returns the mapping information for the currently active track selection, or null if no + * selection is currently active. + */ + public final @Nullable MappedTrackInfo getCurrentMappedTrackInfo() { + return currentMappedTrackInfo; + } + + // TrackSelector implementation. + + @Override + public final void onSelectionActivated(Object info) { + currentMappedTrackInfo = (MappedTrackInfo) info; + } + + @Override + public final TrackSelectorResult selectTracks( + RendererCapabilities[] rendererCapabilities, + TrackGroupArray trackGroups, + MediaPeriodId periodId, + Timeline timeline) + throws ExoPlaybackException { + // Structures into which data will be written during the selection. The extra item at the end + // of each array is to store data associated with track groups that cannot be associated with + // any renderer. + int[] rendererTrackGroupCounts = new int[rendererCapabilities.length + 1]; + TrackGroup[][] rendererTrackGroups = new TrackGroup[rendererCapabilities.length + 1][]; + @Capabilities int[][][] rendererFormatSupports = new int[rendererCapabilities.length + 1][][]; + for (int i = 0; i < rendererTrackGroups.length; i++) { + rendererTrackGroups[i] = new TrackGroup[trackGroups.length]; + rendererFormatSupports[i] = new int[trackGroups.length][]; + } + + // Determine the extent to which each renderer supports mixed mimeType adaptation. + @AdaptiveSupport + int[] rendererMixedMimeTypeAdaptationSupports = + getMixedMimeTypeAdaptationSupports(rendererCapabilities); + + // Associate each track group to a preferred renderer, and evaluate the support that the + // renderer provides for each track in the group. + for (int groupIndex = 0; groupIndex < trackGroups.length; groupIndex++) { + TrackGroup group = trackGroups.get(groupIndex); + // Associate the group to a preferred renderer. + boolean preferUnassociatedRenderer = + MimeTypes.getTrackType(group.getFormat(0).sampleMimeType) == C.TRACK_TYPE_METADATA; + int rendererIndex = + findRenderer( + rendererCapabilities, group, rendererTrackGroupCounts, preferUnassociatedRenderer); + // Evaluate the support that the renderer provides for each track in the group. + @Capabilities + int[] rendererFormatSupport = + rendererIndex == rendererCapabilities.length + ? new int[group.length] + : getFormatSupport(rendererCapabilities[rendererIndex], group); + // Stash the results. + int rendererTrackGroupCount = rendererTrackGroupCounts[rendererIndex]; + rendererTrackGroups[rendererIndex][rendererTrackGroupCount] = group; + rendererFormatSupports[rendererIndex][rendererTrackGroupCount] = rendererFormatSupport; + rendererTrackGroupCounts[rendererIndex]++; + } + + // Create a track group array for each renderer, and trim each rendererFormatSupports entry. + TrackGroupArray[] rendererTrackGroupArrays = new TrackGroupArray[rendererCapabilities.length]; + int[] rendererTrackTypes = new int[rendererCapabilities.length]; + for (int i = 0; i < rendererCapabilities.length; i++) { + int rendererTrackGroupCount = rendererTrackGroupCounts[i]; + rendererTrackGroupArrays[i] = + new TrackGroupArray( + Util.nullSafeArrayCopy(rendererTrackGroups[i], rendererTrackGroupCount)); + rendererFormatSupports[i] = + Util.nullSafeArrayCopy(rendererFormatSupports[i], rendererTrackGroupCount); + rendererTrackTypes[i] = rendererCapabilities[i].getTrackType(); + } + + // Create a track group array for track groups not mapped to a renderer. + int unmappedTrackGroupCount = rendererTrackGroupCounts[rendererCapabilities.length]; + TrackGroupArray unmappedTrackGroupArray = + new TrackGroupArray( + Util.nullSafeArrayCopy( + rendererTrackGroups[rendererCapabilities.length], unmappedTrackGroupCount)); + + // Package up the track information and selections. + MappedTrackInfo mappedTrackInfo = + new MappedTrackInfo( + rendererTrackTypes, + rendererTrackGroupArrays, + rendererMixedMimeTypeAdaptationSupports, + rendererFormatSupports, + unmappedTrackGroupArray); + + Pair<@NullableType RendererConfiguration[], @NullableType TrackSelection[]> result = + selectTracks( + mappedTrackInfo, rendererFormatSupports, rendererMixedMimeTypeAdaptationSupports); + return new TrackSelectorResult(result.first, result.second, mappedTrackInfo); + } + + /** + * Given mapped track information, returns a track selection and configuration for each renderer. + * + * @param mappedTrackInfo Mapped track information. + * @param rendererFormatSupports The {@link Capabilities} for ach mapped track, indexed by + * renderer, track group and track (in that order). + * @param rendererMixedMimeTypeAdaptationSupport The {@link AdaptiveSupport} for mixed MIME type + * adaptation for the renderer. + * @return A pair consisting of the track selections and configurations for each renderer. A null + * configuration indicates the renderer should be disabled, in which case the track selection + * will also be null. A track selection may also be null for a non-disabled renderer if {@link + * RendererCapabilities#getTrackType()} is {@link C#TRACK_TYPE_NONE}. + * @throws ExoPlaybackException If an error occurs while selecting the tracks. + */ + protected abstract Pair<@NullableType RendererConfiguration[], @NullableType TrackSelection[]> + selectTracks( + MappedTrackInfo mappedTrackInfo, + @Capabilities int[][][] rendererFormatSupports, + @AdaptiveSupport int[] rendererMixedMimeTypeAdaptationSupport) + throws ExoPlaybackException; + + /** + * Finds the renderer to which the provided {@link TrackGroup} should be mapped. + * + * <p>A {@link TrackGroup} is mapped to the renderer that reports the highest of (listed in + * decreasing order of support) {@link RendererCapabilities#FORMAT_HANDLED}, {@link + * RendererCapabilities#FORMAT_EXCEEDS_CAPABILITIES}, {@link + * RendererCapabilities#FORMAT_UNSUPPORTED_DRM} and {@link + * RendererCapabilities#FORMAT_UNSUPPORTED_SUBTYPE}. + * + * <p>In the case that two or more renderers report the same level of support, the assignment + * depends on {@code preferUnassociatedRenderer}. + * + * <ul> + * <li>If {@code preferUnassociatedRenderer} is false, the renderer with the lowest index is + * chosen regardless of how many other track groups are already mapped to this renderer. + * <li>If {@code preferUnassociatedRenderer} is true, the renderer with the lowest index and no + * other mapped track group is chosen, or the renderer with the lowest index if all + * available renderers have already mapped track groups. + * </ul> + * + * <p>If all renderers report {@link RendererCapabilities#FORMAT_UNSUPPORTED_TYPE} for all of the + * tracks in the group, then {@code renderers.length} is returned to indicate that the group was + * not mapped to any renderer. + * + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers. + * @param group The track group to map to a renderer. + * @param rendererTrackGroupCounts The number of already mapped track groups for each renderer. + * @param preferUnassociatedRenderer Whether renderers unassociated to any track group should be + * preferred. + * @return The index of the renderer to which the track group was mapped, or {@code + * renderers.length} if it was not mapped to any renderer. + * @throws ExoPlaybackException If an error occurs finding a renderer. + */ + private static int findRenderer( + RendererCapabilities[] rendererCapabilities, + TrackGroup group, + int[] rendererTrackGroupCounts, + boolean preferUnassociatedRenderer) + throws ExoPlaybackException { + int bestRendererIndex = rendererCapabilities.length; + @FormatSupport int bestFormatSupportLevel = RendererCapabilities.FORMAT_UNSUPPORTED_TYPE; + boolean bestRendererIsUnassociated = true; + for (int rendererIndex = 0; rendererIndex < rendererCapabilities.length; rendererIndex++) { + RendererCapabilities rendererCapability = rendererCapabilities[rendererIndex]; + @FormatSupport int formatSupportLevel = RendererCapabilities.FORMAT_UNSUPPORTED_TYPE; + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + @FormatSupport + int trackFormatSupportLevel = + RendererCapabilities.getFormatSupport( + rendererCapability.supportsFormat(group.getFormat(trackIndex))); + formatSupportLevel = Math.max(formatSupportLevel, trackFormatSupportLevel); + } + boolean rendererIsUnassociated = rendererTrackGroupCounts[rendererIndex] == 0; + if (formatSupportLevel > bestFormatSupportLevel + || (formatSupportLevel == bestFormatSupportLevel + && preferUnassociatedRenderer + && !bestRendererIsUnassociated + && rendererIsUnassociated)) { + bestRendererIndex = rendererIndex; + bestFormatSupportLevel = formatSupportLevel; + bestRendererIsUnassociated = rendererIsUnassociated; + } + } + return bestRendererIndex; + } + + /** + * Calls {@link RendererCapabilities#supportsFormat} for each track in the specified {@link + * TrackGroup}, returning the results in an array. + * + * @param rendererCapabilities The {@link RendererCapabilities} of the renderer. + * @param group The track group to evaluate. + * @return An array containing {@link Capabilities} for each track in the group. + * @throws ExoPlaybackException If an error occurs determining the format support. + */ + @Capabilities + private static int[] getFormatSupport(RendererCapabilities rendererCapabilities, TrackGroup group) + throws ExoPlaybackException { + @Capabilities int[] formatSupport = new int[group.length]; + for (int i = 0; i < group.length; i++) { + formatSupport[i] = rendererCapabilities.supportsFormat(group.getFormat(i)); + } + return formatSupport; + } + + /** + * Calls {@link RendererCapabilities#supportsMixedMimeTypeAdaptation()} for each renderer, + * returning the results in an array. + * + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers. + * @return An array containing the {@link AdaptiveSupport} for mixed MIME type adaptation for the + * renderer. + * @throws ExoPlaybackException If an error occurs determining the adaptation support. + */ + @AdaptiveSupport + private static int[] getMixedMimeTypeAdaptationSupports( + RendererCapabilities[] rendererCapabilities) throws ExoPlaybackException { + @AdaptiveSupport int[] mixedMimeTypeAdaptationSupport = new int[rendererCapabilities.length]; + for (int i = 0; i < mixedMimeTypeAdaptationSupport.length; i++) { + mixedMimeTypeAdaptationSupport[i] = rendererCapabilities[i].supportsMixedMimeTypeAdaptation(); + } + return mixedMimeTypeAdaptationSupport; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java new file mode 100644 index 0000000000..75b7fc21f1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/RandomTrackSelection.java @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import android.os.SystemClock; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import java.util.List; +import java.util.Random; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A {@link TrackSelection} whose selected track is updated randomly. + */ +public final class RandomTrackSelection extends BaseTrackSelection { + + /** + * Factory for {@link RandomTrackSelection} instances. + */ + public static final class Factory implements TrackSelection.Factory { + + private final Random random; + + public Factory() { + random = new Random(); + } + + /** + * @param seed A seed for the {@link Random} instance used by the factory. + */ + public Factory(int seed) { + random = new Random(seed); + } + + @Override + public @NullableType TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter) { + return TrackSelectionUtil.createTrackSelectionsForDefinitions( + definitions, + definition -> new RandomTrackSelection(definition.group, definition.tracks, random)); + } + } + + private final Random random; + + private int selectedIndex; + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * null or empty. May be in any order. + */ + public RandomTrackSelection(TrackGroup group, int... tracks) { + super(group, tracks); + random = new Random(); + selectedIndex = random.nextInt(length); + } + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * null or empty. May be in any order. + * @param seed A seed for the {@link Random} instance used to update the selected track. + */ + public RandomTrackSelection(TrackGroup group, int[] tracks, long seed) { + this(group, tracks, new Random(seed)); + } + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * null or empty. May be in any order. + * @param random A source of random numbers. + */ + public RandomTrackSelection(TrackGroup group, int[] tracks, Random random) { + super(group, tracks); + this.random = random; + selectedIndex = random.nextInt(length); + } + + @Override + public void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators) { + // Count the number of non-blacklisted formats. + long nowMs = SystemClock.elapsedRealtime(); + int nonBlacklistedFormatCount = 0; + for (int i = 0; i < length; i++) { + if (!isBlacklisted(i, nowMs)) { + nonBlacklistedFormatCount++; + } + } + + selectedIndex = random.nextInt(nonBlacklistedFormatCount); + if (nonBlacklistedFormatCount != length) { + // Adjust the format index to account for blacklisted formats. + nonBlacklistedFormatCount = 0; + for (int i = 0; i < length; i++) { + if (!isBlacklisted(i, nowMs) && selectedIndex == nonBlacklistedFormatCount++) { + selectedIndex = i; + return; + } + } + } + } + + @Override + public int getSelectedIndex() { + return selectedIndex; + } + + @Override + public int getSelectionReason() { + return C.SELECTION_REASON_ADAPTIVE; + } + + @Override + @Nullable + public Object getSelectionData() { + return null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelection.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelection.java new file mode 100644 index 0000000000..d2f32222fa --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelection.java @@ -0,0 +1,269 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunk; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.chunk.MediaChunkIterator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import java.util.List; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * A track selection consisting of a static subset of selected tracks belonging to a {@link + * TrackGroup}, and a possibly varying individual selected track from the subset. + * + * <p>Tracks belonging to the subset are exposed in decreasing bandwidth order. The individual + * selected track may change dynamically as a result of calling {@link #updateSelectedTrack(long, + * long, long, List, MediaChunkIterator[])} or {@link #evaluateQueueSize(long, List)}. This only + * happens between calls to {@link #enable()} and {@link #disable()}. + */ +public interface TrackSelection { + + /** Contains of a subset of selected tracks belonging to a {@link TrackGroup}. */ + final class Definition { + /** The {@link TrackGroup} which tracks belong to. */ + public final TrackGroup group; + /** The indices of the selected tracks in {@link #group}. */ + public final int[] tracks; + /** The track selection reason. One of the {@link C} SELECTION_REASON_ constants. */ + public final int reason; + /** Optional data associated with this selection of tracks. */ + @Nullable public final Object data; + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * null or empty. May be in any order. + */ + public Definition(TrackGroup group, int... tracks) { + this(group, tracks, C.SELECTION_REASON_UNKNOWN, /* data= */ null); + } + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * @param reason The track selection reason. One of the {@link C} SELECTION_REASON_ constants. + * @param data Optional data associated with this selection of tracks. + */ + public Definition(TrackGroup group, int[] tracks, int reason, @Nullable Object data) { + this.group = group; + this.tracks = tracks; + this.reason = reason; + this.data = data; + } + } + + /** + * Factory for {@link TrackSelection} instances. + */ + interface Factory { + + /** + * Creates track selections for the provided {@link Definition Definitions}. + * + * <p>Implementations that create at most one adaptive track selection may use {@link + * TrackSelectionUtil#createTrackSelectionsForDefinitions}. + * + * @param definitions A {@link Definition} array. May include null values. + * @param bandwidthMeter A {@link BandwidthMeter} which can be used to select tracks. + * @return The created selections. Must have the same length as {@code definitions} and may + * include null values. + */ + @NullableType + TrackSelection[] createTrackSelections( + @NullableType Definition[] definitions, BandwidthMeter bandwidthMeter); + } + + /** + * Enables the track selection. Dynamic changes via {@link #updateSelectedTrack(long, long, long, + * List, MediaChunkIterator[])} or {@link #evaluateQueueSize(long, List)} will only happen after + * this call. + * + * <p>This method may not be called when the track selection is already enabled. + */ + void enable(); + + /** + * Disables this track selection. No further dynamic changes via {@link #updateSelectedTrack(long, + * long, long, List, MediaChunkIterator[])} or {@link #evaluateQueueSize(long, List)} will happen + * after this call. + * + * <p>This method may only be called when the track selection is already enabled. + */ + void disable(); + + /** + * Returns the {@link TrackGroup} to which the selected tracks belong. + */ + TrackGroup getTrackGroup(); + + // Static subset of selected tracks. + + /** + * Returns the number of tracks in the selection. + */ + int length(); + + /** + * Returns the format of the track at a given index in the selection. + * + * @param index The index in the selection. + * @return The format of the selected track. + */ + Format getFormat(int index); + + /** + * Returns the index in the track group of the track at a given index in the selection. + * + * @param index The index in the selection. + * @return The index of the selected track. + */ + int getIndexInTrackGroup(int index); + + /** + * Returns the index in the selection of the track with the specified format. The format is + * located by identity so, for example, {@code selection.indexOf(selection.getFormat(index)) == + * index} even if multiple selected tracks have formats that contain the same values. + * + * @param format The format. + * @return The index in the selection, or {@link C#INDEX_UNSET} if the track with the specified + * format is not part of the selection. + */ + int indexOf(Format format); + + /** + * Returns the index in the selection of the track with the specified index in the track group. + * + * @param indexInTrackGroup The index in the track group. + * @return The index in the selection, or {@link C#INDEX_UNSET} if the track with the specified + * index is not part of the selection. + */ + int indexOf(int indexInTrackGroup); + + // Individual selected track. + + /** + * Returns the {@link Format} of the individual selected track. + */ + Format getSelectedFormat(); + + /** + * Returns the index in the track group of the individual selected track. + */ + int getSelectedIndexInTrackGroup(); + + /** + * Returns the index of the selected track. + */ + int getSelectedIndex(); + + /** + * Returns the reason for the current track selection. + */ + int getSelectionReason(); + + /** Returns optional data associated with the current track selection. */ + @Nullable Object getSelectionData(); + + // Adaptation. + + /** + * Called to notify the selection of the current playback speed. The playback speed may affect + * adaptive track selection. + * + * @param speed The playback speed. + */ + void onPlaybackSpeed(float speed); + + /** + * Called to notify the selection of a position discontinuity. + * + * <p>This happens when the playback position jumps, e.g., as a result of a seek being performed. + */ + default void onDiscontinuity() {} + + /** + * Updates the selected track for sources that load media in discrete {@link MediaChunk}s. + * + * <p>This method may only be called when the selection is enabled. + * + * @param playbackPositionUs The current playback position in microseconds. If playback of the + * period to which this track selection belongs has not yet started, the value will be the + * starting position in the period minus the duration of any media in previous periods still + * to be played. + * @param bufferedDurationUs The duration of media currently buffered from the current playback + * position, in microseconds. Note that the next load position can be calculated as {@code + * (playbackPositionUs + bufferedDurationUs)}. + * @param availableDurationUs The duration of media available for buffering from the current + * playback position, in microseconds, or {@link C#TIME_UNSET} if media can be buffered to the + * end of the current period. Note that if not set to {@link C#TIME_UNSET}, the position up to + * which media is available for buffering can be calculated as {@code (playbackPositionUs + + * availableDurationUs)}. + * @param queue The queue of already buffered {@link MediaChunk}s. Must not be modified. + * @param mediaChunkIterators An array of {@link MediaChunkIterator}s providing information about + * the sequence of upcoming media chunks for each track in the selection. All iterators start + * from the media chunk which will be loaded next if the respective track is selected. Note + * that this information may not be available for all tracks, and so some iterators may be + * empty. + */ + void updateSelectedTrack( + long playbackPositionUs, + long bufferedDurationUs, + long availableDurationUs, + List<? extends MediaChunk> queue, + MediaChunkIterator[] mediaChunkIterators); + + /** + * May be called periodically by sources that load media in discrete {@link MediaChunk}s and + * support discarding of buffered chunks in order to re-buffer using a different selected track. + * Returns the number of chunks that should be retained in the queue. + * <p> + * To avoid excessive re-buffering, implementations should normally return the size of the queue. + * An example of a case where a smaller value may be returned is if network conditions have + * improved dramatically, allowing chunks to be discarded and re-buffered in a track of + * significantly higher quality. Discarding chunks may allow faster switching to a higher quality + * track in this case. This method may only be called when the selection is enabled. + * + * @param playbackPositionUs The current playback position in microseconds. If playback of the + * period to which this track selection belongs has not yet started, the value will be the + * starting position in the period minus the duration of any media in previous periods still + * to be played. + * @param queue The queue of buffered {@link MediaChunk}s. Must not be modified. + * @return The number of chunks to retain in the queue. + */ + int evaluateQueueSize(long playbackPositionUs, List<? extends MediaChunk> queue); + + /** + * Attempts to blacklist the track at the specified index in the selection, making it ineligible + * for selection by calls to {@link #updateSelectedTrack(long, long, long, List, + * MediaChunkIterator[])} for the specified period of time. Blacklisting will fail if all other + * tracks are currently blacklisted. If blacklisting the currently selected track, note that it + * will remain selected until the next call to {@link #updateSelectedTrack(long, long, long, List, + * MediaChunkIterator[])}. + * + * <p>This method may only be called when the selection is enabled. + * + * @param index The index of the track in the selection. + * @param blacklistDurationMs The duration of time for which the track should be blacklisted, in + * milliseconds. + * @return Whether blacklisting was successful. + */ + boolean blacklist(int index, long blacklistDurationMs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionArray.java new file mode 100644 index 0000000000..7953ef354c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionArray.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** An array of {@link TrackSelection}s. */ +public final class TrackSelectionArray { + + /** The length of this array. */ + public final int length; + + private final @NullableType TrackSelection[] trackSelections; + + // Lazily initialized hashcode. + private int hashCode; + + /** @param trackSelections The selections. Must not be null, but may contain null elements. */ + public TrackSelectionArray(@NullableType TrackSelection... trackSelections) { + this.trackSelections = trackSelections; + this.length = trackSelections.length; + } + + /** + * Returns the selection at a given index. + * + * @param index The index of the selection. + * @return The selection. + */ + @Nullable + public TrackSelection get(int index) { + return trackSelections[index]; + } + + /** Returns the selections in a newly allocated array. */ + public @NullableType TrackSelection[] getAll() { + return trackSelections.clone(); + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 17; + result = 31 * result + Arrays.hashCode(trackSelections); + hashCode = result; + } + return hashCode; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackSelectionArray other = (TrackSelectionArray) obj; + return Arrays.equals(trackSelections, other.trackSelections); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java new file mode 100644 index 0000000000..b6086fa594 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionParameters.java @@ -0,0 +1,336 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import android.annotation.TargetApi; +import android.content.Context; +import android.os.Looper; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.view.accessibility.CaptioningManager; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Locale; + +/** Constraint parameters for track selection. */ +public class TrackSelectionParameters implements Parcelable { + + /** + * A builder for {@link TrackSelectionParameters}. See the {@link TrackSelectionParameters} + * documentation for explanations of the parameters that can be configured using this builder. + */ + public static class Builder { + + @Nullable /* package */ String preferredAudioLanguage; + @Nullable /* package */ String preferredTextLanguage; + @C.RoleFlags /* package */ int preferredTextRoleFlags; + /* package */ boolean selectUndeterminedTextLanguage; + @C.SelectionFlags /* package */ int disabledTextTrackSelectionFlags; + + /** + * Creates a builder with default initial values. + * + * @param context Any context. + */ + @SuppressWarnings({"deprecation", "initialization:method.invocation.invalid"}) + public Builder(Context context) { + this(); + setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings(context); + } + + /** + * @deprecated {@link Context} constraints will not be set when using this constructor. Use + * {@link #Builder(Context)} instead. + */ + @Deprecated + public Builder() { + preferredAudioLanguage = null; + preferredTextLanguage = null; + preferredTextRoleFlags = 0; + selectUndeterminedTextLanguage = false; + disabledTextTrackSelectionFlags = 0; + } + + /** + * @param initialValues The {@link TrackSelectionParameters} from which the initial values of + * the builder are obtained. + */ + /* package */ Builder(TrackSelectionParameters initialValues) { + preferredAudioLanguage = initialValues.preferredAudioLanguage; + preferredTextLanguage = initialValues.preferredTextLanguage; + preferredTextRoleFlags = initialValues.preferredTextRoleFlags; + selectUndeterminedTextLanguage = initialValues.selectUndeterminedTextLanguage; + disabledTextTrackSelectionFlags = initialValues.disabledTextTrackSelectionFlags; + } + + /** + * Sets the preferred language for audio and forced text tracks. + * + * @param preferredAudioLanguage Preferred audio language as an IETF BCP 47 conformant tag, or + * {@code null} to select the default track, or the first track if there's no default. + * @return This builder. + */ + public Builder setPreferredAudioLanguage(@Nullable String preferredAudioLanguage) { + this.preferredAudioLanguage = preferredAudioLanguage; + return this; + } + + /** + * Sets the preferred language and role flags for text tracks based on the accessibility + * settings of {@link CaptioningManager}. + * + * <p>Does nothing for API levels < 19 or when the {@link CaptioningManager} is disabled. + * + * @param context A {@link Context}. + * @return This builder. + */ + public Builder setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings( + Context context) { + if (Util.SDK_INT >= 19) { + setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettingsV19(context); + } + return this; + } + + /** + * Sets the preferred language for text tracks. + * + * @param preferredTextLanguage Preferred text language as an IETF BCP 47 conformant tag, or + * {@code null} to select the default track if there is one, or no track otherwise. + * @return This builder. + */ + public Builder setPreferredTextLanguage(@Nullable String preferredTextLanguage) { + this.preferredTextLanguage = preferredTextLanguage; + return this; + } + + /** + * Sets the preferred {@link C.RoleFlags} for text tracks. + * + * @param preferredTextRoleFlags Preferred text role flags. + * @return This builder. + */ + public Builder setPreferredTextRoleFlags(@C.RoleFlags int preferredTextRoleFlags) { + this.preferredTextRoleFlags = preferredTextRoleFlags; + return this; + } + + /** + * Sets whether a text track with undetermined language should be selected if no track with + * {@link #setPreferredTextLanguage(String)} is available, or if the preferred language is + * unset. + * + * @param selectUndeterminedTextLanguage Whether a text track with undetermined language should + * be selected if no preferred language track is available. + * @return This builder. + */ + public Builder setSelectUndeterminedTextLanguage(boolean selectUndeterminedTextLanguage) { + this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; + return this; + } + + /** + * Sets a bitmask of selection flags that are disabled for text track selections. + * + * @param disabledTextTrackSelectionFlags A bitmask of {@link C.SelectionFlags} that are + * disabled for text track selections. + * @return This builder. + */ + public Builder setDisabledTextTrackSelectionFlags( + @C.SelectionFlags int disabledTextTrackSelectionFlags) { + this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; + return this; + } + + /** Builds a {@link TrackSelectionParameters} instance with the selected values. */ + public TrackSelectionParameters build() { + return new TrackSelectionParameters( + // Audio + preferredAudioLanguage, + // Text + preferredTextLanguage, + preferredTextRoleFlags, + selectUndeterminedTextLanguage, + disabledTextTrackSelectionFlags); + } + + @TargetApi(19) + private void setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettingsV19( + Context context) { + if (Util.SDK_INT < 23 && Looper.myLooper() == null) { + // Android platform bug (pre-Marshmallow) that causes RuntimeExceptions when + // CaptioningService is instantiated from a non-Looper thread. See [internal: b/143779904]. + return; + } + CaptioningManager captioningManager = + (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); + if (captioningManager == null || !captioningManager.isEnabled()) { + return; + } + preferredTextRoleFlags = C.ROLE_FLAG_CAPTION | C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; + Locale preferredLocale = captioningManager.getLocale(); + if (preferredLocale != null) { + preferredTextLanguage = Util.getLocaleLanguageTag(preferredLocale); + } + } + } + + /** + * An instance with default values, except those obtained from the {@link Context}. + * + * <p>If possible, use {@link #getDefaults(Context)} instead. + * + * <p>This instance will not have the following settings: + * + * <ul> + * <li>{@link Builder#setPreferredTextLanguageAndRoleFlagsToCaptioningManagerSettings(Context) + * Preferred text language and role flags} configured to the accessibility settings of + * {@link CaptioningManager}. + * </ul> + */ + @SuppressWarnings("deprecation") + public static final TrackSelectionParameters DEFAULT_WITHOUT_CONTEXT = new Builder().build(); + + /** + * @deprecated This instance is not configured using {@link Context} constraints. Use {@link + * #getDefaults(Context)} instead. + */ + @Deprecated public static final TrackSelectionParameters DEFAULT = DEFAULT_WITHOUT_CONTEXT; + + /** Returns an instance configured with default values. */ + public static TrackSelectionParameters getDefaults(Context context) { + return new Builder(context).build(); + } + + /** + * The preferred language for audio and forced text tracks as an IETF BCP 47 conformant tag. + * {@code null} selects the default track, or the first track if there's no default. The default + * value is {@code null}. + */ + @Nullable public final String preferredAudioLanguage; + /** + * The preferred language for text tracks as an IETF BCP 47 conformant tag. {@code null} selects + * the default track if there is one, or no track otherwise. The default value is {@code null}, or + * the language of the accessibility {@link CaptioningManager} if enabled. + */ + @Nullable public final String preferredTextLanguage; + /** + * The preferred {@link C.RoleFlags} for text tracks. {@code 0} selects the default track if there + * is one, or no track otherwise. The default value is {@code 0}, or {@link C#ROLE_FLAG_SUBTITLE} + * | {@link C#ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND} if the accessibility {@link CaptioningManager} + * is enabled. + */ + @C.RoleFlags public final int preferredTextRoleFlags; + /** + * Whether a text track with undetermined language should be selected if no track with {@link + * #preferredTextLanguage} is available, or if {@link #preferredTextLanguage} is unset. The + * default value is {@code false}. + */ + public final boolean selectUndeterminedTextLanguage; + /** + * Bitmask of selection flags that are disabled for text track selections. See {@link + * C.SelectionFlags}. The default value is {@code 0} (i.e. no flags). + */ + @C.SelectionFlags public final int disabledTextTrackSelectionFlags; + + /* package */ TrackSelectionParameters( + @Nullable String preferredAudioLanguage, + @Nullable String preferredTextLanguage, + @C.RoleFlags int preferredTextRoleFlags, + boolean selectUndeterminedTextLanguage, + @C.SelectionFlags int disabledTextTrackSelectionFlags) { + // Audio + this.preferredAudioLanguage = Util.normalizeLanguageCode(preferredAudioLanguage); + // Text + this.preferredTextLanguage = Util.normalizeLanguageCode(preferredTextLanguage); + this.preferredTextRoleFlags = preferredTextRoleFlags; + this.selectUndeterminedTextLanguage = selectUndeterminedTextLanguage; + this.disabledTextTrackSelectionFlags = disabledTextTrackSelectionFlags; + } + + /* package */ TrackSelectionParameters(Parcel in) { + this.preferredAudioLanguage = in.readString(); + this.preferredTextLanguage = in.readString(); + this.preferredTextRoleFlags = in.readInt(); + this.selectUndeterminedTextLanguage = Util.readBoolean(in); + this.disabledTextTrackSelectionFlags = in.readInt(); + } + + /** Creates a new {@link Builder}, copying the initial values from this instance. */ + public Builder buildUpon() { + return new Builder(this); + } + + @Override + @SuppressWarnings("EqualsGetClass") + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + TrackSelectionParameters other = (TrackSelectionParameters) obj; + return TextUtils.equals(preferredAudioLanguage, other.preferredAudioLanguage) + && TextUtils.equals(preferredTextLanguage, other.preferredTextLanguage) + && preferredTextRoleFlags == other.preferredTextRoleFlags + && selectUndeterminedTextLanguage == other.selectUndeterminedTextLanguage + && disabledTextTrackSelectionFlags == other.disabledTextTrackSelectionFlags; + } + + @Override + public int hashCode() { + int result = 1; + result = 31 * result + (preferredAudioLanguage == null ? 0 : preferredAudioLanguage.hashCode()); + result = 31 * result + (preferredTextLanguage == null ? 0 : preferredTextLanguage.hashCode()); + result = 31 * result + preferredTextRoleFlags; + result = 31 * result + (selectUndeterminedTextLanguage ? 1 : 0); + result = 31 * result + disabledTextTrackSelectionFlags; + return result; + } + + // Parcelable implementation. + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(preferredAudioLanguage); + dest.writeString(preferredTextLanguage); + dest.writeInt(preferredTextRoleFlags); + Util.writeBoolean(dest, selectUndeterminedTextLanguage); + dest.writeInt(disabledTextTrackSelectionFlags); + } + + public static final Creator<TrackSelectionParameters> CREATOR = + new Creator<TrackSelectionParameters>() { + + @Override + public TrackSelectionParameters createFromParcel(Parcel in) { + return new TrackSelectionParameters(in); + } + + @Override + public TrackSelectionParameters[] newArray(int size) { + return new TrackSelectionParameters[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java new file mode 100644 index 0000000000..b2fcf5c13c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectionUtil.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector.SelectionOverride; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection.Definition; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Track selection related utility methods. */ +public final class TrackSelectionUtil { + + private TrackSelectionUtil() {} + + /** Functional interface to create a single adaptive track selection. */ + public interface AdaptiveTrackSelectionFactory { + + /** + * Creates an adaptive track selection for the provided track selection definition. + * + * @param trackSelectionDefinition A {@link Definition} for the track selection. + * @return The created track selection. + */ + TrackSelection createAdaptiveTrackSelection(Definition trackSelectionDefinition); + } + + /** + * Creates track selections for an array of track selection definitions, with at most one + * multi-track adaptive selection. + * + * @param definitions The list of track selection {@link Definition definitions}. May include null + * values. + * @param adaptiveTrackSelectionFactory A factory for the multi-track adaptive track selection. + * @return The array of created track selection. For null entries in {@code definitions} returns + * null values. + */ + public static @NullableType TrackSelection[] createTrackSelectionsForDefinitions( + @NullableType Definition[] definitions, + AdaptiveTrackSelectionFactory adaptiveTrackSelectionFactory) { + TrackSelection[] selections = new TrackSelection[definitions.length]; + boolean createdAdaptiveTrackSelection = false; + for (int i = 0; i < definitions.length; i++) { + Definition definition = definitions[i]; + if (definition == null) { + continue; + } + if (definition.tracks.length > 1 && !createdAdaptiveTrackSelection) { + createdAdaptiveTrackSelection = true; + selections[i] = adaptiveTrackSelectionFactory.createAdaptiveTrackSelection(definition); + } else { + selections[i] = + new FixedTrackSelection( + definition.group, definition.tracks[0], definition.reason, definition.data); + } + } + return selections; + } + + /** + * Updates {@link DefaultTrackSelector.Parameters} with an override. + * + * @param parameters The current {@link DefaultTrackSelector.Parameters} to build upon. + * @param rendererIndex The renderer index to update. + * @param trackGroupArray The {@link TrackGroupArray} of the renderer. + * @param isDisabled Whether the renderer should be set disabled. + * @param override An optional override for the renderer. If null, no override will be set and an + * existing override for this renderer will be cleared. + * @return The updated {@link DefaultTrackSelector.Parameters}. + */ + public static DefaultTrackSelector.Parameters updateParametersWithOverride( + DefaultTrackSelector.Parameters parameters, + int rendererIndex, + TrackGroupArray trackGroupArray, + boolean isDisabled, + @Nullable SelectionOverride override) { + DefaultTrackSelector.ParametersBuilder builder = + parameters + .buildUpon() + .clearSelectionOverrides(rendererIndex) + .setRendererDisabled(rendererIndex, isDisabled); + if (override != null) { + builder.setSelectionOverride(rendererIndex, trackGroupArray, override); + } + return builder.build(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelector.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelector.java new file mode 100644 index 0000000000..878031824d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelector.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +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.Renderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererConfiguration; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.BandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * The component of an {@link ExoPlayer} responsible for selecting tracks to be consumed by each of + * the player's {@link Renderer}s. The {@link DefaultTrackSelector} implementation should be + * suitable for most use cases. + * + * <h3>Interactions with the player</h3> + * + * The following interactions occur between the player and its track selector during playback. + * + * <ul> + * <li>When the player is created it will initialize the track selector by calling {@link + * #init(InvalidationListener, BandwidthMeter)}. + * <li>When the player needs to make a track selection it will call {@link + * #selectTracks(RendererCapabilities[], TrackGroupArray, MediaPeriodId, Timeline)}. This + * typically occurs at the start of playback, when the player starts to buffer a new period of + * the media being played, and when the track selector invalidates its previous selections. + * <li>The player may perform a track selection well in advance of the selected tracks becoming + * active, where active is defined to mean that the renderers are actually consuming media + * corresponding to the selection that was made. For example when playing media containing + * multiple periods, the track selection for a period is made when the player starts to buffer + * that period. Hence if the player's buffering policy is to maintain a 30 second buffer, the + * selection will occur approximately 30 seconds in advance of it becoming active. In fact the + * selection may never become active, for example if the user seeks to some other period of + * the media during the 30 second gap. The player indicates to the track selector when a + * selection it has previously made becomes active by calling {@link + * #onSelectionActivated(Object)}. + * <li>If the track selector wishes to indicate to the player that selections it has previously + * made are invalid, it can do so by calling {@link + * InvalidationListener#onTrackSelectionsInvalidated()} on the {@link InvalidationListener} + * that was passed to {@link #init(InvalidationListener, BandwidthMeter)}. A track selector + * may wish to do this if its configuration has changed, for example if it now wishes to + * prefer audio tracks in a particular language. This will trigger the player to make new + * track selections. Note that the player will have to re-buffer in the case that the new + * track selection for the currently playing period differs from the one that was invalidated. + * </ul> + * + * <h3>Renderer configuration</h3> + * + * The {@link TrackSelectorResult} returned by {@link #selectTracks(RendererCapabilities[], + * TrackGroupArray, MediaPeriodId, Timeline)} contains not only {@link TrackSelection}s for each + * renderer, but also {@link RendererConfiguration}s defining configuration parameters that the + * renderers should apply when consuming the corresponding media. Whilst it may seem counter- + * intuitive for a track selector to also specify renderer configuration information, in practice + * the two are tightly bound together. It may only be possible to play a certain combination tracks + * if the renderers are configured in a particular way. Equally, it may only be possible to + * configure renderers in a particular way if certain tracks are selected. Hence it makes sense to + * determine the track selection and corresponding renderer configurations in a single step. + * + * <h3>Threading model</h3> + * + * All calls made by the player into the track selector are on the player's internal playback + * thread. The track selector may call {@link InvalidationListener#onTrackSelectionsInvalidated()} + * from any thread. + */ +public abstract class TrackSelector { + + /** + * Notified when selections previously made by a {@link TrackSelector} are no longer valid. + */ + public interface InvalidationListener { + + /** + * Called by a {@link TrackSelector} to indicate that selections it has previously made are no + * longer valid. May be called from any thread. + */ + void onTrackSelectionsInvalidated(); + + } + + @Nullable private InvalidationListener listener; + @Nullable private BandwidthMeter bandwidthMeter; + + /** + * Called by the player to initialize the selector. + * + * @param listener An invalidation listener that the selector can call to indicate that selections + * it has previously made are no longer valid. + * @param bandwidthMeter A bandwidth meter which can be used by track selections to select tracks. + */ + public final void init(InvalidationListener listener, BandwidthMeter bandwidthMeter) { + this.listener = listener; + this.bandwidthMeter = bandwidthMeter; + } + + /** + * Called by the player to perform a track selection. + * + * @param rendererCapabilities The {@link RendererCapabilities} of the renderers for which tracks + * are to be selected. + * @param trackGroups The available track groups. + * @param periodId The {@link MediaPeriodId} of the period for which tracks are to be selected. + * @param timeline The {@link Timeline} holding the period for which tracks are to be selected. + * @return A {@link TrackSelectorResult} describing the track selections. + * @throws ExoPlaybackException If an error occurs selecting tracks. + */ + public abstract TrackSelectorResult selectTracks( + RendererCapabilities[] rendererCapabilities, + TrackGroupArray trackGroups, + MediaPeriodId periodId, + Timeline timeline) + throws ExoPlaybackException; + + /** + * Called by the player when a {@link TrackSelectorResult} previously generated by {@link + * #selectTracks(RendererCapabilities[], TrackGroupArray, MediaPeriodId, Timeline)} is activated. + * + * @param info The value of {@link TrackSelectorResult#info} in the activated selection. + */ + public abstract void onSelectionActivated(Object info); + + /** + * Calls {@link InvalidationListener#onTrackSelectionsInvalidated()} to invalidate all previously + * generated track selections. + */ + protected final void invalidate() { + if (listener != null) { + listener.onTrackSelectionsInvalidated(); + } + } + + /** + * Returns a bandwidth meter which can be used by track selections to select tracks. Must only be + * called after {@link #init(InvalidationListener, BandwidthMeter)} has been called. + */ + protected final BandwidthMeter getBandwidthMeter() { + return Assertions.checkNotNull(bandwidthMeter); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java new file mode 100644 index 0000000000..9c005497cc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/TrackSelectorResult.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererConfiguration; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** + * The result of a {@link TrackSelector} operation. + */ +public final class TrackSelectorResult { + + /** The number of selections in the result. Greater than or equal to zero. */ + public final int length; + /** + * A {@link RendererConfiguration} for each renderer. A null entry indicates the corresponding + * renderer should be disabled. + */ + public final @NullableType RendererConfiguration[] rendererConfigurations; + /** + * A {@link TrackSelectionArray} containing the track selection for each renderer. + */ + public final TrackSelectionArray selections; + /** + * An opaque object that will be returned to {@link TrackSelector#onSelectionActivated(Object)} + * should the selections be activated. + */ + public final Object info; + + /** + * @param rendererConfigurations A {@link RendererConfiguration} for each renderer. A null entry + * indicates the corresponding renderer should be disabled. + * @param selections A {@link TrackSelectionArray} containing the selection for each renderer. + * @param info An opaque object that will be returned to {@link + * TrackSelector#onSelectionActivated(Object)} should the selection be activated. + */ + public TrackSelectorResult( + @NullableType RendererConfiguration[] rendererConfigurations, + @NullableType TrackSelection[] selections, + Object info) { + this.rendererConfigurations = rendererConfigurations; + this.selections = new TrackSelectionArray(selections); + this.info = info; + length = rendererConfigurations.length; + } + + /** Returns whether the renderer at the specified index is enabled. */ + public boolean isRendererEnabled(int index) { + return rendererConfigurations[index] != null; + } + + /** + * Returns whether this result is equivalent to {@code other} for all renderers. + * + * @param other The other {@link TrackSelectorResult}. May be null, in which case {@code false} + * will be returned. + * @return Whether this result is equivalent to {@code other} for all renderers. + */ + public boolean isEquivalent(@Nullable TrackSelectorResult other) { + if (other == null || other.selections.length != selections.length) { + return false; + } + for (int i = 0; i < selections.length; i++) { + if (!isEquivalent(other, i)) { + return false; + } + } + return true; + } + + /** + * Returns whether this result is equivalent to {@code other} for the renderer at the given index. + * The results are equivalent if they have equal track selections and configurations for the + * renderer. + * + * @param other The other {@link TrackSelectorResult}. May be null, in which case {@code false} + * will be returned. + * @param index The renderer index to check for equivalence. + * @return Whether this result is equivalent to {@code other} for the renderer at the specified + * index. + */ + public boolean isEquivalent(@Nullable TrackSelectorResult other, int index) { + if (other == null) { + return false; + } + return Util.areEqual(rendererConfigurations[index], other.rendererConfigurations[index]) + && Util.areEqual(selections.get(index), other.selections.get(index)); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/package-info.java new file mode 100644 index 0000000000..4a04290d0f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/trackselection/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Allocation.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Allocation.java new file mode 100644 index 0000000000..87dd142e6a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Allocation.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +/** + * An allocation within a byte array. + * <p> + * The allocation's length is obtained by calling {@link Allocator#getIndividualAllocationLength()} + * on the {@link Allocator} from which it was obtained. + */ +public final class Allocation { + + /** + * The array containing the allocated space. The allocated space might not be at the start of the + * array, and so {@link #offset} must be used when indexing into it. + */ + public final byte[] data; + + /** + * The offset of the allocated space in {@link #data}. + */ + public final int offset; + + /** + * @param data The array containing the allocated space. + * @param offset The offset of the allocated space in {@code data}. + */ + public Allocation(byte[] data, int offset) { + this.data = data; + this.offset = offset; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Allocator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Allocator.java new file mode 100644 index 0000000000..d554d0fe7f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Allocator.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +/** + * A source of allocations. + */ +public interface Allocator { + + /** + * Obtain an {@link Allocation}. + * <p> + * When the caller has finished with the {@link Allocation}, it should be returned by calling + * {@link #release(Allocation)}. + * + * @return The {@link Allocation}. + */ + Allocation allocate(); + + /** + * Releases an {@link Allocation} back to the allocator. + * + * @param allocation The {@link Allocation} being released. + */ + void release(Allocation allocation); + + /** + * Releases an array of {@link Allocation}s back to the allocator. + * + * @param allocations The array of {@link Allocation}s being released. + */ + void release(Allocation[] allocations); + + /** + * Hints to the allocator that it should make a best effort to release any excess + * {@link Allocation}s. + */ + void trim(); + + /** + * Returns the total number of bytes currently allocated. + */ + int getTotalBytesAllocated(); + + /** + * Returns the length of each individual {@link Allocation}. + */ + int getIndividualAllocationLength(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/AssetDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/AssetDataSource.java new file mode 100644 index 0000000000..70cd1de8fe --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/AssetDataSource.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.content.Context; +import android.content.res.AssetManager; +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** A {@link DataSource} for reading from a local asset. */ +public final class AssetDataSource extends BaseDataSource { + + /** + * Thrown when an {@link IOException} is encountered reading a local asset. + */ + public static final class AssetDataSourceException extends IOException { + + public AssetDataSourceException(IOException cause) { + super(cause); + } + + } + + private final AssetManager assetManager; + + @Nullable private Uri uri; + @Nullable private InputStream inputStream; + private long bytesRemaining; + private boolean opened; + + /** @param context A context. */ + public AssetDataSource(Context context) { + super(/* isNetwork= */ false); + this.assetManager = context.getAssets(); + } + + @Override + public long open(DataSpec dataSpec) throws AssetDataSourceException { + try { + uri = dataSpec.uri; + String path = Assertions.checkNotNull(uri.getPath()); + if (path.startsWith("/android_asset/")) { + path = path.substring(15); + } else if (path.startsWith("/")) { + path = path.substring(1); + } + transferInitializing(dataSpec); + inputStream = assetManager.open(path, AssetManager.ACCESS_RANDOM); + long skipped = inputStream.skip(dataSpec.position); + if (skipped < dataSpec.position) { + // assetManager.open() returns an AssetInputStream, whose skip() implementation only skips + // fewer bytes than requested if the skip is beyond the end of the asset's data. + throw new EOFException(); + } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + bytesRemaining = inputStream.available(); + if (bytesRemaining == Integer.MAX_VALUE) { + // assetManager.open() returns an AssetInputStream, whose available() implementation + // returns Integer.MAX_VALUE if the remaining length is greater than (or equal to) + // Integer.MAX_VALUE. We don't know the true length in this case, so treat as unbounded. + bytesRemaining = C.LENGTH_UNSET; + } + } + } catch (IOException e) { + throw new AssetDataSourceException(e); + } + + opened = true; + transferStarted(dataSpec); + return bytesRemaining; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws AssetDataSourceException { + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + int bytesRead; + try { + int bytesToRead = bytesRemaining == C.LENGTH_UNSET ? readLength + : (int) Math.min(bytesRemaining, readLength); + bytesRead = castNonNull(inputStream).read(buffer, offset, bytesToRead); + } catch (IOException e) { + throw new AssetDataSourceException(e); + } + + if (bytesRead == -1) { + if (bytesRemaining != C.LENGTH_UNSET) { + // End of stream reached having not read sufficient data. + throw new AssetDataSourceException(new EOFException()); + } + return C.RESULT_END_OF_INPUT; + } + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @Override + public void close() throws AssetDataSourceException { + uri = null; + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + throw new AssetDataSourceException(e); + } finally { + inputStream = null; + if (opened) { + opened = false; + transferEnded(); + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/BandwidthMeter.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/BandwidthMeter.java new file mode 100644 index 0000000000..5606b45702 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/BandwidthMeter.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.os.Handler; +import androidx.annotation.Nullable; + +/** + * Provides estimates of the currently available bandwidth. + */ +public interface BandwidthMeter { + + /** + * A listener of {@link BandwidthMeter} events. + */ + interface EventListener { + + /** + * Called periodically to indicate that bytes have been transferred or the estimated bitrate has + * changed. + * + * <p>Note: The estimated bitrate is typically derived from more information than just {@code + * bytes} and {@code elapsedMs}. + * + * @param elapsedMs The time taken to transfer {@code bytesTransferred}, in milliseconds. This + * is at most the elapsed time since the last callback, but may be less if there were + * periods during which data was not being transferred. + * @param bytesTransferred The number of bytes transferred since the last callback. + * @param bitrateEstimate The estimated bitrate in bits/sec. + */ + void onBandwidthSample(int elapsedMs, long bytesTransferred, long bitrateEstimate); + } + + /** Returns the estimated bitrate. */ + long getBitrateEstimate(); + + /** + * Returns the {@link TransferListener} that this instance uses to gather bandwidth information + * from data transfers. May be null if the implementation does not listen to data transfers. + */ + @Nullable + TransferListener getTransferListener(); + + /** + * Adds an {@link EventListener}. + * + * @param eventHandler A handler for events. + * @param eventListener A listener of events. + */ + void addEventListener(Handler eventHandler, EventListener eventListener); + + /** + * Removes an {@link EventListener}. + * + * @param eventListener The listener to be removed. + */ + void removeEventListener(EventListener eventListener); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/BaseDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/BaseDataSource.java new file mode 100644 index 0000000000..3838094927 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/BaseDataSource.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import java.util.ArrayList; + +/** + * Base {@link DataSource} implementation to keep a list of {@link TransferListener}s. + * + * <p>Subclasses must call {@link #transferInitializing(DataSpec)}, {@link + * #transferStarted(DataSpec)}, {@link #bytesTransferred(int)}, and {@link #transferEnded()} to + * inform listeners of data transfers. + */ +public abstract class BaseDataSource implements DataSource { + + private final boolean isNetwork; + private final ArrayList<TransferListener> listeners; + + private int listenerCount; + @Nullable private DataSpec dataSpec; + + /** + * Creates base data source. + * + * @param isNetwork Whether the data source loads data through a network. + */ + protected BaseDataSource(boolean isNetwork) { + this.isNetwork = isNetwork; + this.listeners = new ArrayList<>(/* initialCapacity= */ 1); + } + + @Override + public final void addTransferListener(TransferListener transferListener) { + if (!listeners.contains(transferListener)) { + listeners.add(transferListener); + listenerCount++; + } + } + + /** + * Notifies listeners that data transfer for the specified {@link DataSpec} is being initialized. + * + * @param dataSpec {@link DataSpec} describing the data for initializing transfer. + */ + protected final void transferInitializing(DataSpec dataSpec) { + for (int i = 0; i < listenerCount; i++) { + listeners.get(i).onTransferInitializing(/* source= */ this, dataSpec, isNetwork); + } + } + + /** + * Notifies listeners that data transfer for the specified {@link DataSpec} started. + * + * @param dataSpec {@link DataSpec} describing the data being transferred. + */ + protected final void transferStarted(DataSpec dataSpec) { + this.dataSpec = dataSpec; + for (int i = 0; i < listenerCount; i++) { + listeners.get(i).onTransferStart(/* source= */ this, dataSpec, isNetwork); + } + } + + /** + * Notifies listeners that bytes were transferred. + * + * @param bytesTransferred The number of bytes transferred since the previous call to this method + * (or if the first call, since the transfer was started). + */ + protected final void bytesTransferred(int bytesTransferred) { + DataSpec dataSpec = castNonNull(this.dataSpec); + for (int i = 0; i < listenerCount; i++) { + listeners + .get(i) + .onBytesTransferred(/* source= */ this, dataSpec, isNetwork, bytesTransferred); + } + } + + /** Notifies listeners that a transfer ended. */ + protected final void transferEnded() { + DataSpec dataSpec = castNonNull(this.dataSpec); + for (int i = 0; i < listenerCount; i++) { + listeners.get(i).onTransferEnd(/* source= */ this, dataSpec, isNetwork); + } + this.dataSpec = null; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ByteArrayDataSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ByteArrayDataSink.java new file mode 100644 index 0000000000..4aa66538ff --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ByteArrayDataSink.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link DataSink} for writing to a byte array. + */ +public final class ByteArrayDataSink implements DataSink { + + private @MonotonicNonNull ByteArrayOutputStream stream; + + @Override + public void open(DataSpec dataSpec) { + if (dataSpec.length == C.LENGTH_UNSET) { + stream = new ByteArrayOutputStream(); + } else { + Assertions.checkArgument(dataSpec.length <= Integer.MAX_VALUE); + stream = new ByteArrayOutputStream((int) dataSpec.length); + } + } + + @Override + public void close() throws IOException { + castNonNull(stream).close(); + } + + @Override + public void write(byte[] buffer, int offset, int length) { + castNonNull(stream).write(buffer, offset, length); + } + + /** + * Returns the data written to the sink since the last call to {@link #open(DataSpec)}, or null if + * {@link #open(DataSpec)} has never been called. + */ + @Nullable + public byte[] getData() { + return stream == null ? null : stream.toByteArray(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java new file mode 100644 index 0000000000..0be103701d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ByteArrayDataSource.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; + +/** A {@link DataSource} for reading from a byte array. */ +public final class ByteArrayDataSource extends BaseDataSource { + + private final byte[] data; + + @Nullable private Uri uri; + private int readPosition; + private int bytesRemaining; + private boolean opened; + + /** + * @param data The data to be read. + */ + public ByteArrayDataSource(byte[] data) { + super(/* isNetwork= */ false); + Assertions.checkNotNull(data); + Assertions.checkArgument(data.length > 0); + this.data = data; + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + uri = dataSpec.uri; + transferInitializing(dataSpec); + readPosition = (int) dataSpec.position; + bytesRemaining = (int) ((dataSpec.length == C.LENGTH_UNSET) + ? (data.length - dataSpec.position) : dataSpec.length); + if (bytesRemaining <= 0 || readPosition + bytesRemaining > data.length) { + throw new IOException("Unsatisfiable range: [" + readPosition + ", " + dataSpec.length + + "], length: " + data.length); + } + opened = true; + transferStarted(dataSpec); + return bytesRemaining; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) { + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + readLength = Math.min(readLength, bytesRemaining); + System.arraycopy(data, readPosition, buffer, offset, readLength); + readPosition += readLength; + bytesRemaining -= readLength; + bytesTransferred(readLength); + return readLength; + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @Override + public void close() { + if (opened) { + opened = false; + transferEnded(); + } + uri = null; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ContentDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ContentDataSource.java new file mode 100644 index 0000000000..b73d9d6375 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ContentDataSource.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.EOFException; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.channels.FileChannel; + +/** A {@link DataSource} for reading from a content URI. */ +public final class ContentDataSource extends BaseDataSource { + + /** + * Thrown when an {@link IOException} is encountered reading from a content URI. + */ + public static class ContentDataSourceException extends IOException { + + public ContentDataSourceException(IOException cause) { + super(cause); + } + + } + + private final ContentResolver resolver; + + @Nullable private Uri uri; + @Nullable private AssetFileDescriptor assetFileDescriptor; + @Nullable private FileInputStream inputStream; + private long bytesRemaining; + private boolean opened; + + /** + * @param context A context. + */ + public ContentDataSource(Context context) { + super(/* isNetwork= */ false); + this.resolver = context.getContentResolver(); + } + + @Override + public long open(DataSpec dataSpec) throws ContentDataSourceException { + try { + Uri uri = dataSpec.uri; + this.uri = uri; + + transferInitializing(dataSpec); + AssetFileDescriptor assetFileDescriptor = resolver.openAssetFileDescriptor(uri, "r"); + this.assetFileDescriptor = assetFileDescriptor; + if (assetFileDescriptor == null) { + throw new FileNotFoundException("Could not open file descriptor for: " + uri); + } + FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); + this.inputStream = inputStream; + + long assetStartOffset = assetFileDescriptor.getStartOffset(); + long skipped = inputStream.skip(assetStartOffset + dataSpec.position) - assetStartOffset; + if (skipped != dataSpec.position) { + // We expect the skip to be satisfied in full. If it isn't then we're probably trying to + // skip beyond the end of the data. + throw new EOFException(); + } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + long assetFileDescriptorLength = assetFileDescriptor.getLength(); + if (assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH) { + // The asset must extend to the end of the file. If FileInputStream.getChannel().size() + // returns 0 then the remaining length cannot be determined. + FileChannel channel = inputStream.getChannel(); + long channelSize = channel.size(); + bytesRemaining = channelSize == 0 ? C.LENGTH_UNSET : channelSize - channel.position(); + } else { + bytesRemaining = assetFileDescriptorLength - skipped; + } + } + } catch (IOException e) { + throw new ContentDataSourceException(e); + } + + opened = true; + transferStarted(dataSpec); + + return bytesRemaining; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws ContentDataSourceException { + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + int bytesRead; + try { + int bytesToRead = bytesRemaining == C.LENGTH_UNSET ? readLength + : (int) Math.min(bytesRemaining, readLength); + bytesRead = castNonNull(inputStream).read(buffer, offset, bytesToRead); + } catch (IOException e) { + throw new ContentDataSourceException(e); + } + + if (bytesRead == -1) { + if (bytesRemaining != C.LENGTH_UNSET) { + // End of stream reached having not read sufficient data. + throw new ContentDataSourceException(new EOFException()); + } + return C.RESULT_END_OF_INPUT; + } + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @SuppressWarnings("Finally") + @Override + public void close() throws ContentDataSourceException { + uri = null; + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + throw new ContentDataSourceException(e); + } finally { + inputStream = null; + try { + if (assetFileDescriptor != null) { + assetFileDescriptor.close(); + } + } catch (IOException e) { + throw new ContentDataSourceException(e); + } finally { + assetFileDescriptor = null; + if (opened) { + opened = false; + transferEnded(); + } + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java new file mode 100644 index 0000000000..57420250ac --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSchemeDataSource.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.net.Uri; +import android.util.Base64; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.net.URLDecoder; + +/** A {@link DataSource} for reading data URLs, as defined by RFC 2397. */ +public final class DataSchemeDataSource extends BaseDataSource { + + public static final String SCHEME_DATA = "data"; + + @Nullable private DataSpec dataSpec; + @Nullable private byte[] data; + private int endPosition; + private int readPosition; + + // the constructor does not initialize fields: data + @SuppressWarnings("nullness:initialization.fields.uninitialized") + public DataSchemeDataSource() { + super(/* isNetwork= */ false); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + transferInitializing(dataSpec); + this.dataSpec = dataSpec; + readPosition = (int) dataSpec.position; + Uri uri = dataSpec.uri; + String scheme = uri.getScheme(); + if (!SCHEME_DATA.equals(scheme)) { + throw new ParserException("Unsupported scheme: " + scheme); + } + String[] uriParts = Util.split(uri.getSchemeSpecificPart(), ","); + if (uriParts.length != 2) { + throw new ParserException("Unexpected URI format: " + uri); + } + String dataString = uriParts[1]; + if (uriParts[0].contains(";base64")) { + try { + data = Base64.decode(dataString, 0); + } catch (IllegalArgumentException e) { + throw new ParserException("Error while parsing Base64 encoded string: " + dataString, e); + } + } else { + // TODO: Add support for other charsets. + data = Util.getUtf8Bytes(URLDecoder.decode(dataString, C.ASCII_NAME)); + } + endPosition = + dataSpec.length != C.LENGTH_UNSET ? (int) dataSpec.length + readPosition : data.length; + if (endPosition > data.length || readPosition > endPosition) { + data = null; + throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE); + } + transferStarted(dataSpec); + return (long) endPosition - readPosition; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) { + if (readLength == 0) { + return 0; + } + int remainingBytes = endPosition - readPosition; + if (remainingBytes == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = Math.min(readLength, remainingBytes); + System.arraycopy(castNonNull(data), readPosition, buffer, offset, readLength); + readPosition += readLength; + bytesTransferred(readLength); + return readLength; + } + + @Override + @Nullable + public Uri getUri() { + return dataSpec != null ? dataSpec.uri : null; + } + + @Override + public void close() { + if (data != null) { + data = null; + transferEnded(); + } + dataSpec = null; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSink.java new file mode 100644 index 0000000000..c85ec8cfca --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSink.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import java.io.IOException; + +/** + * A component to which streams of data can be written. + */ +public interface DataSink { + + /** + * A factory for {@link DataSink} instances. + */ + interface Factory { + + /** + * Creates a {@link DataSink} instance. + */ + DataSink createDataSink(); + + } + + /** + * Opens the sink to consume the specified data. + * + * <p>Note: If an {@link IOException} is thrown, callers must still call {@link #close()} to + * ensure that any partial effects of the invocation are cleaned up. + * + * @param dataSpec Defines the data to be consumed. + * @throws IOException If an error occurs opening the sink. + */ + void open(DataSpec dataSpec) throws IOException; + + /** + * Consumes the provided data. + * + * @param buffer The buffer from which data should be consumed. + * @param offset The offset of the data to consume in {@code buffer}. + * @param length The length of the data to consume, in bytes. + * @throws IOException If an error occurs writing to the sink. + */ + void write(byte[] buffer, int offset, int length) throws IOException; + + /** + * Closes the sink. + * + * <p>Note: This method must be called even if the corresponding call to {@link #open(DataSpec)} + * threw an {@link IOException}. See {@link #open(DataSpec)} for more details. + * + * @throws IOException If an error occurs closing the sink. + */ + void close() throws IOException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSource.java new file mode 100644 index 0000000000..26529253f8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSource.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A component from which streams of data can be read. + */ +public interface DataSource { + + /** + * A factory for {@link DataSource} instances. + */ + interface Factory { + + /** + * Creates a {@link DataSource} instance. + */ + DataSource createDataSource(); + } + + /** + * Adds a {@link TransferListener} to listen to data transfers. This method is not thread-safe. + * + * @param transferListener A {@link TransferListener}. + */ + void addTransferListener(TransferListener transferListener); + + /** + * Opens the source to read the specified data. + * <p> + * Note: If an {@link IOException} is thrown, callers must still call {@link #close()} to ensure + * that any partial effects of the invocation are cleaned up. + * + * @param dataSpec Defines the data to be read. + * @throws IOException If an error occurs opening the source. {@link DataSourceException} can be + * thrown or used as a cause of the thrown exception to specify the reason of the error. + * @return The number of bytes that can be read from the opened source. For unbounded requests + * (i.e. requests where {@link DataSpec#length} equals {@link C#LENGTH_UNSET}) this value + * is the resolved length of the request, or {@link C#LENGTH_UNSET} if the length is still + * unresolved. For all other requests, the value returned will be equal to the request's + * {@link DataSpec#length}. + */ + long open(DataSpec dataSpec) throws IOException; + + /** + * Reads up to {@code readLength} bytes of data and stores them into {@code buffer}, starting at + * index {@code offset}. + * + * <p>If {@code readLength} is zero then 0 is returned. Otherwise, if no data is available because + * the end of the opened range has been reached, then {@link C#RESULT_END_OF_INPUT} is returned. + * Otherwise, the call will block until at least one byte of data has been read and the number of + * bytes read is returned. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if no data is available + * because the end of the opened range has been reached. + * @throws IOException If an error occurs reading from the source. + */ + int read(byte[] buffer, int offset, int readLength) throws IOException; + + /** + * When the source is open, returns the {@link Uri} from which data is being read. The returned + * {@link Uri} will be identical to the one passed {@link #open(DataSpec)} in the {@link DataSpec} + * unless redirection has occurred. If redirection has occurred, the {@link Uri} after redirection + * is returned. + * + * @return The {@link Uri} from which data is being read, or null if the source is not open. + */ + @Nullable Uri getUri(); + + /** + * When the source is open, returns the response headers associated with the last {@link #open} + * call. Otherwise, returns an empty map. + */ + default Map<String, List<String>> getResponseHeaders() { + return Collections.emptyMap(); + } + + /** + * Closes the source. + * <p> + * Note: This method must be called even if the corresponding call to {@link #open(DataSpec)} + * threw an {@link IOException}. See {@link #open(DataSpec)} for more details. + * + * @throws IOException If an error occurs closing the source. + */ + void close() throws IOException; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSourceException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSourceException.java new file mode 100644 index 0000000000..13c34d1dfb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSourceException.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import java.io.IOException; + +/** + * Used to specify reason of a DataSource error. + */ +public final class DataSourceException extends IOException { + + public static final int POSITION_OUT_OF_RANGE = 0; + + /** + * The reason of this {@link DataSourceException}. It can only be {@link #POSITION_OUT_OF_RANGE}. + */ + public final int reason; + + /** + * Constructs a DataSourceException. + * + * @param reason Reason of the error. It can only be {@link #POSITION_OUT_OF_RANGE}. + */ + public DataSourceException(int reason) { + this.reason = reason; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSourceInputStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSourceInputStream.java new file mode 100644 index 0000000000..c25ba4c10a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSourceInputStream.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import androidx.annotation.NonNull; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.io.InputStream; + +/** + * Allows data corresponding to a given {@link DataSpec} to be read from a {@link DataSource} and + * consumed through an {@link InputStream}. + */ +public final class DataSourceInputStream extends InputStream { + + private final DataSource dataSource; + private final DataSpec dataSpec; + private final byte[] singleByteArray; + + private boolean opened = false; + private boolean closed = false; + private long totalBytesRead; + + /** + * @param dataSource The {@link DataSource} from which the data should be read. + * @param dataSpec The {@link DataSpec} defining the data to be read from {@code dataSource}. + */ + public DataSourceInputStream(DataSource dataSource, DataSpec dataSpec) { + this.dataSource = dataSource; + this.dataSpec = dataSpec; + singleByteArray = new byte[1]; + } + + /** + * Returns the total number of bytes that have been read or skipped. + */ + public long bytesRead() { + return totalBytesRead; + } + + /** + * Optional call to open the underlying {@link DataSource}. + * <p> + * Calling this method does nothing if the {@link DataSource} is already open. Calling this + * method is optional, since the read and skip methods will automatically open the underlying + * {@link DataSource} if it's not open already. + * + * @throws IOException If an error occurs opening the {@link DataSource}. + */ + public void open() throws IOException { + checkOpened(); + } + + @Override + public int read() throws IOException { + int length = read(singleByteArray); + return length == -1 ? -1 : (singleByteArray[0] & 0xFF); + } + + @Override + public int read(@NonNull byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int length) throws IOException { + Assertions.checkState(!closed); + checkOpened(); + int bytesRead = dataSource.read(buffer, offset, length); + if (bytesRead == C.RESULT_END_OF_INPUT) { + return -1; + } else { + totalBytesRead += bytesRead; + return bytesRead; + } + } + + @Override + public void close() throws IOException { + if (!closed) { + dataSource.close(); + closed = true; + } + } + + private void checkOpened() throws IOException { + if (!opened) { + dataSource.open(dataSpec); + opened = true; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSpec.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSpec.java new file mode 100644 index 0000000000..6a419c6632 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DataSpec.java @@ -0,0 +1,478 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Defines a region of data. + */ +public final class DataSpec { + + /** + * The flags that apply to any request for data. Possible flag values are {@link + * #FLAG_ALLOW_GZIP}, {@link #FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN} and {@link + * #FLAG_ALLOW_CACHE_FRAGMENTATION}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {FLAG_ALLOW_GZIP, FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN, FLAG_ALLOW_CACHE_FRAGMENTATION}) + public @interface Flags {} + /** + * Allows an underlying network stack to request that the server use gzip compression. + * + * <p>Should not typically be set if the data being requested is already compressed (e.g. most + * audio and video requests). May be set when requesting other data. + * + * <p>When a {@link DataSource} is used to request data with this flag set, and if the {@link + * DataSource} does make a network request, then the value returned from {@link + * DataSource#open(DataSpec)} will typically be {@link C#LENGTH_UNSET}. The data read from {@link + * DataSource#read(byte[], int, int)} will be the decompressed data. + */ + public static final int FLAG_ALLOW_GZIP = 1; + /** Prevents caching if the length cannot be resolved when the {@link DataSource} is opened. */ + public static final int FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN = 1 << 1; // 2 + /** + * Allows fragmentation of this request into multiple cache files, meaning a cache eviction policy + * will be able to evict individual fragments of the data. Depending on the cache implementation, + * setting this flag may also enable more concurrent access to the data (e.g. reading one fragment + * whilst writing another). + */ + public static final int FLAG_ALLOW_CACHE_FRAGMENTATION = 1 << 2; // 4 + + /** + * The set of HTTP methods that are supported by ExoPlayer {@link HttpDataSource}s. One of {@link + * #HTTP_METHOD_GET}, {@link #HTTP_METHOD_POST} or {@link #HTTP_METHOD_HEAD}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({HTTP_METHOD_GET, HTTP_METHOD_POST, HTTP_METHOD_HEAD}) + public @interface HttpMethod {} + + public static final int HTTP_METHOD_GET = 1; + public static final int HTTP_METHOD_POST = 2; + public static final int HTTP_METHOD_HEAD = 3; + + /** + * The source from which data should be read. + */ + public final Uri uri; + + /** + * The HTTP method, which will be used by {@link HttpDataSource} when requesting this DataSpec. + * This value will be ignored by non-http {@link DataSource}s. + */ + public final @HttpMethod int httpMethod; + + /** + * The HTTP request body, null otherwise. If the body is non-null, then httpBody.length will be + * non-zero. + */ + @Nullable public final byte[] httpBody; + + /** Immutable map containing the headers to use in HTTP requests. */ + public final Map<String, String> httpRequestHeaders; + + /** The absolute position of the data in the full stream. */ + public final long absoluteStreamPosition; + /** + * The position of the data when read from {@link #uri}. + * <p> + * Always equal to {@link #absoluteStreamPosition} unless the {@link #uri} defines the location + * of a subset of the underlying data. + */ + public final long position; + /** + * The length of the data, or {@link C#LENGTH_UNSET}. + */ + public final long length; + /** + * A key that uniquely identifies the original stream. Used for cache indexing. May be null if the + * data spec is not intended to be used in conjunction with a cache. + */ + @Nullable public final String key; + /** Request {@link Flags flags}. */ + public final @Flags int flags; + + /** + * Construct a data spec for the given uri and with {@link #key} set to null. + * + * @param uri {@link #uri}. + */ + public DataSpec(Uri uri) { + this(uri, 0); + } + + /** + * Construct a data spec for the given uri and with {@link #key} set to null. + * + * @param uri {@link #uri}. + * @param flags {@link #flags}. + */ + public DataSpec(Uri uri, @Flags int flags) { + this(uri, 0, C.LENGTH_UNSET, null, flags); + } + + /** + * Construct a data spec where {@link #position} equals {@link #absoluteStreamPosition}. + * + * @param uri {@link #uri}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}, equal to {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + */ + public DataSpec(Uri uri, long absoluteStreamPosition, long length, @Nullable String key) { + this(uri, absoluteStreamPosition, absoluteStreamPosition, length, key, 0); + } + + /** + * Construct a data spec where {@link #position} equals {@link #absoluteStreamPosition}. + * + * @param uri {@link #uri}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}, equal to {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + * @param flags {@link #flags}. + */ + public DataSpec( + Uri uri, long absoluteStreamPosition, long length, @Nullable String key, @Flags int flags) { + this(uri, absoluteStreamPosition, absoluteStreamPosition, length, key, flags); + } + + /** + * Construct a data spec where {@link #position} equals {@link #absoluteStreamPosition} and has + * request headers. + * + * @param uri {@link #uri}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}, equal to {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + * @param flags {@link #flags}. + * @param httpRequestHeaders {@link #httpRequestHeaders} + */ + public DataSpec( + Uri uri, + long absoluteStreamPosition, + long length, + @Nullable String key, + @Flags int flags, + Map<String, String> httpRequestHeaders) { + this( + uri, + inferHttpMethod(null), + null, + absoluteStreamPosition, + absoluteStreamPosition, + length, + key, + flags, + httpRequestHeaders); + } + + /** + * Construct a data spec where {@link #position} may differ from {@link #absoluteStreamPosition}. + * + * @param uri {@link #uri}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}. + * @param position {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + * @param flags {@link #flags}. + */ + public DataSpec( + Uri uri, + long absoluteStreamPosition, + long position, + long length, + @Nullable String key, + @Flags int flags) { + this(uri, null, absoluteStreamPosition, position, length, key, flags); + } + + /** + * Construct a data spec by inferring the {@link #httpMethod} based on the {@code postBody} + * parameter. If postBody is non-null, then httpMethod is set to {@link #HTTP_METHOD_POST}. If + * postBody is null, then httpMethod is set to {@link #HTTP_METHOD_GET}. + * + * @param uri {@link #uri}. + * @param postBody {@link #httpBody} The body of the HTTP request, which is also used to infer the + * {@link #httpMethod}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}. + * @param position {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + * @param flags {@link #flags}. + */ + public DataSpec( + Uri uri, + @Nullable byte[] postBody, + long absoluteStreamPosition, + long position, + long length, + @Nullable String key, + @Flags int flags) { + this( + uri, + /* httpMethod= */ inferHttpMethod(postBody), + /* httpBody= */ postBody, + absoluteStreamPosition, + position, + length, + key, + flags); + } + + /** + * Construct a data spec where {@link #position} may differ from {@link #absoluteStreamPosition}. + * + * @param uri {@link #uri}. + * @param httpMethod {@link #httpMethod}. + * @param httpBody {@link #httpBody}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}. + * @param position {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + * @param flags {@link #flags}. + */ + public DataSpec( + Uri uri, + @HttpMethod int httpMethod, + @Nullable byte[] httpBody, + long absoluteStreamPosition, + long position, + long length, + @Nullable String key, + @Flags int flags) { + this( + uri, + httpMethod, + httpBody, + absoluteStreamPosition, + position, + length, + key, + flags, + /* httpRequestHeaders= */ Collections.emptyMap()); + } + + /** + * Construct a data spec with request parameters to be used as HTTP headers inside HTTP requests. + * + * @param uri {@link #uri}. + * @param httpMethod {@link #httpMethod}. + * @param httpBody {@link #httpBody}. + * @param absoluteStreamPosition {@link #absoluteStreamPosition}. + * @param position {@link #position}. + * @param length {@link #length}. + * @param key {@link #key}. + * @param flags {@link #flags}. + * @param httpRequestHeaders {@link #httpRequestHeaders}. + */ + public DataSpec( + Uri uri, + @HttpMethod int httpMethod, + @Nullable byte[] httpBody, + long absoluteStreamPosition, + long position, + long length, + @Nullable String key, + @Flags int flags, + Map<String, String> httpRequestHeaders) { + Assertions.checkArgument(absoluteStreamPosition >= 0); + Assertions.checkArgument(position >= 0); + Assertions.checkArgument(length > 0 || length == C.LENGTH_UNSET); + this.uri = uri; + this.httpMethod = httpMethod; + this.httpBody = (httpBody != null && httpBody.length != 0) ? httpBody : null; + this.absoluteStreamPosition = absoluteStreamPosition; + this.position = position; + this.length = length; + this.key = key; + this.flags = flags; + this.httpRequestHeaders = Collections.unmodifiableMap(new HashMap<>(httpRequestHeaders)); + } + + /** + * Returns whether the given flag is set. + * + * @param flag Flag to be checked if it is set. + */ + public boolean isFlagSet(@Flags int flag) { + return (this.flags & flag) == flag; + } + + @Override + public String toString() { + return "DataSpec[" + + getHttpMethodString() + + " " + + uri + + ", " + + Arrays.toString(httpBody) + + ", " + + absoluteStreamPosition + + ", " + + position + + ", " + + length + + ", " + + key + + ", " + + flags + + "]"; + } + + /** + * Returns an uppercase HTTP method name (e.g., "GET", "POST", "HEAD") corresponding to the {@link + * #httpMethod}. + */ + public final String getHttpMethodString() { + return getStringForHttpMethod(httpMethod); + } + + /** + * Returns an uppercase HTTP method name (e.g., "GET", "POST", "HEAD") corresponding to the {@code + * httpMethod}. + */ + public static String getStringForHttpMethod(@HttpMethod int httpMethod) { + switch (httpMethod) { + case HTTP_METHOD_GET: + return "GET"; + case HTTP_METHOD_POST: + return "POST"; + case HTTP_METHOD_HEAD: + return "HEAD"; + default: + throw new AssertionError(httpMethod); + } + } + + /** + * Returns a data spec that represents a subrange of the data defined by this DataSpec. The + * subrange includes data from the offset up to the end of this DataSpec. + * + * @param offset The offset of the subrange. + * @return A data spec that represents a subrange of the data defined by this DataSpec. + */ + public DataSpec subrange(long offset) { + return subrange(offset, length == C.LENGTH_UNSET ? C.LENGTH_UNSET : length - offset); + } + + /** + * Returns a data spec that represents a subrange of the data defined by this DataSpec. + * + * @param offset The offset of the subrange. + * @param length The length of the subrange. + * @return A data spec that represents a subrange of the data defined by this DataSpec. + */ + public DataSpec subrange(long offset, long length) { + if (offset == 0 && this.length == length) { + return this; + } else { + return new DataSpec( + uri, + httpMethod, + httpBody, + absoluteStreamPosition + offset, + position + offset, + length, + key, + flags, + httpRequestHeaders); + } + } + + /** + * Returns a copy of this data spec with the specified Uri. + * + * @param uri The new source {@link Uri}. + * @return The copied data spec with the specified Uri. + */ + public DataSpec withUri(Uri uri) { + return new DataSpec( + uri, + httpMethod, + httpBody, + absoluteStreamPosition, + position, + length, + key, + flags, + httpRequestHeaders); + } + + /** + * Returns a copy of this data spec with the specified request headers. + * + * @param requestHeaders The HTTP request headers. + * @return The copied data spec with the specified request headers. + */ + public DataSpec withRequestHeaders(Map<String, String> requestHeaders) { + return new DataSpec( + uri, + httpMethod, + httpBody, + absoluteStreamPosition, + position, + length, + key, + flags, + requestHeaders); + } + + /** + * Returns a copy this data spec with additional request headers. + * + * <p>Note: Values in {@code requestHeaders} will overwrite values with the same header key that + * were previously set in this instance's {@code #httpRequestHeaders}. + * + * @param requestHeaders The additional HTTP request headers. + * @return The copied data with the additional HTTP request headers. + */ + public DataSpec withAdditionalHeaders(Map<String, String> requestHeaders) { + Map<String, String> totalHeaders = new HashMap<>(this.httpRequestHeaders); + totalHeaders.putAll(requestHeaders); + + return new DataSpec( + uri, + httpMethod, + httpBody, + absoluteStreamPosition, + position, + length, + key, + flags, + totalHeaders); + } + + @HttpMethod + private static int inferHttpMethod(@Nullable byte[] postBody) { + return postBody != null ? HTTP_METHOD_POST : HTTP_METHOD_GET; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultAllocator.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultAllocator.java new file mode 100644 index 0000000000..b12efcbe4e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultAllocator.java @@ -0,0 +1,179 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Default implementation of {@link Allocator}. + */ +public final class DefaultAllocator implements Allocator { + + private static final int AVAILABLE_EXTRA_CAPACITY = 100; + + private final boolean trimOnReset; + private final int individualAllocationSize; + private final byte[] initialAllocationBlock; + private final Allocation[] singleAllocationReleaseHolder; + + private int targetBufferSize; + private int allocatedCount; + private int availableCount; + private Allocation[] availableAllocations; + + /** + * Constructs an instance without creating any {@link Allocation}s up front. + * + * @param trimOnReset Whether memory is freed when the allocator is reset. Should be true unless + * the allocator will be re-used by multiple player instances. + * @param individualAllocationSize The length of each individual {@link Allocation}. + */ + public DefaultAllocator(boolean trimOnReset, int individualAllocationSize) { + this(trimOnReset, individualAllocationSize, 0); + } + + /** + * Constructs an instance with some {@link Allocation}s created up front. + * <p> + * Note: {@link Allocation}s created up front will never be discarded by {@link #trim()}. + * + * @param trimOnReset Whether memory is freed when the allocator is reset. Should be true unless + * the allocator will be re-used by multiple player instances. + * @param individualAllocationSize The length of each individual {@link Allocation}. + * @param initialAllocationCount The number of allocations to create up front. + */ + public DefaultAllocator(boolean trimOnReset, int individualAllocationSize, + int initialAllocationCount) { + Assertions.checkArgument(individualAllocationSize > 0); + Assertions.checkArgument(initialAllocationCount >= 0); + this.trimOnReset = trimOnReset; + this.individualAllocationSize = individualAllocationSize; + this.availableCount = initialAllocationCount; + this.availableAllocations = new Allocation[initialAllocationCount + AVAILABLE_EXTRA_CAPACITY]; + if (initialAllocationCount > 0) { + initialAllocationBlock = new byte[initialAllocationCount * individualAllocationSize]; + for (int i = 0; i < initialAllocationCount; i++) { + int allocationOffset = i * individualAllocationSize; + availableAllocations[i] = new Allocation(initialAllocationBlock, allocationOffset); + } + } else { + initialAllocationBlock = null; + } + singleAllocationReleaseHolder = new Allocation[1]; + } + + public synchronized void reset() { + if (trimOnReset) { + setTargetBufferSize(0); + } + } + + public synchronized void setTargetBufferSize(int targetBufferSize) { + boolean targetBufferSizeReduced = targetBufferSize < this.targetBufferSize; + this.targetBufferSize = targetBufferSize; + if (targetBufferSizeReduced) { + trim(); + } + } + + @Override + public synchronized Allocation allocate() { + allocatedCount++; + Allocation allocation; + if (availableCount > 0) { + allocation = availableAllocations[--availableCount]; + availableAllocations[availableCount] = null; + } else { + allocation = new Allocation(new byte[individualAllocationSize], 0); + } + return allocation; + } + + @Override + public synchronized void release(Allocation allocation) { + singleAllocationReleaseHolder[0] = allocation; + release(singleAllocationReleaseHolder); + } + + @Override + public synchronized void release(Allocation[] allocations) { + if (availableCount + allocations.length >= availableAllocations.length) { + availableAllocations = Arrays.copyOf(availableAllocations, + Math.max(availableAllocations.length * 2, availableCount + allocations.length)); + } + for (Allocation allocation : allocations) { + availableAllocations[availableCount++] = allocation; + } + allocatedCount -= allocations.length; + // Wake up threads waiting for the allocated size to drop. + notifyAll(); + } + + @Override + public synchronized void trim() { + int targetAllocationCount = Util.ceilDivide(targetBufferSize, individualAllocationSize); + int targetAvailableCount = Math.max(0, targetAllocationCount - allocatedCount); + if (targetAvailableCount >= availableCount) { + // We're already at or below the target. + return; + } + + if (initialAllocationBlock != null) { + // Some allocations are backed by an initial block. We need to make sure that we hold onto all + // such allocations. Re-order the available allocations so that the ones backed by the initial + // block come first. + int lowIndex = 0; + int highIndex = availableCount - 1; + while (lowIndex <= highIndex) { + Allocation lowAllocation = availableAllocations[lowIndex]; + if (lowAllocation.data == initialAllocationBlock) { + lowIndex++; + } else { + Allocation highAllocation = availableAllocations[highIndex]; + if (highAllocation.data != initialAllocationBlock) { + highIndex--; + } else { + availableAllocations[lowIndex++] = highAllocation; + availableAllocations[highIndex--] = lowAllocation; + } + } + } + // lowIndex is the index of the first allocation not backed by an initial block. + targetAvailableCount = Math.max(targetAvailableCount, lowIndex); + if (targetAvailableCount >= availableCount) { + // We're already at or below the target. + return; + } + } + + // Discard allocations beyond the target. + Arrays.fill(availableAllocations, targetAvailableCount, availableCount, null); + availableCount = targetAvailableCount; + } + + @Override + public synchronized int getTotalBytesAllocated() { + return allocatedCount * individualAllocationSize; + } + + @Override + public int getIndividualAllocationLength() { + return individualAllocationSize; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java new file mode 100644 index 0000000000..63ca7c7eac --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultBandwidthMeter.java @@ -0,0 +1,731 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.os.Handler; +import android.os.Looper; +import android.util.SparseArray; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Clock; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.EventDispatcher; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.SlidingPercentile; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * Estimates bandwidth by listening to data transfers. + * + * <p>The bandwidth estimate is calculated using a {@link SlidingPercentile} and is updated each + * time a transfer ends. The initial estimate is based on the current operator's network country + * code or the locale of the user, as well as the network connection type. This can be configured in + * the {@link Builder}. + */ +public final class DefaultBandwidthMeter implements BandwidthMeter, TransferListener { + + /** + * Country groups used to determine the default initial bitrate estimate. The group assignment for + * each country is an array of group indices for [Wifi, 2G, 3G, 4G]. + */ + public static final Map<String, int[]> DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS = + createInitialBitrateCountryGroupAssignment(); + + /** Default initial Wifi bitrate estimate in bits per second. */ + public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI = + new long[] {5_700_000, 3_500_000, 2_000_000, 1_100_000, 470_000}; + + /** Default initial 2G bitrate estimates in bits per second. */ + public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_2G = + new long[] {200_000, 148_000, 132_000, 115_000, 95_000}; + + /** Default initial 3G bitrate estimates in bits per second. */ + public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_3G = + new long[] {2_200_000, 1_300_000, 970_000, 810_000, 490_000}; + + /** Default initial 4G bitrate estimates in bits per second. */ + public static final long[] DEFAULT_INITIAL_BITRATE_ESTIMATES_4G = + new long[] {5_300_000, 3_200_000, 2_000_000, 1_400_000, 690_000}; + + /** + * Default initial bitrate estimate used when the device is offline or the network type cannot be + * determined, in bits per second. + */ + public static final long DEFAULT_INITIAL_BITRATE_ESTIMATE = 1_000_000; + + /** Default maximum weight for the sliding window. */ + public static final int DEFAULT_SLIDING_WINDOW_MAX_WEIGHT = 2000; + + @Nullable private static DefaultBandwidthMeter singletonInstance; + + /** Builder for a bandwidth meter. */ + public static final class Builder { + + @Nullable private final Context context; + + private SparseArray<Long> initialBitrateEstimates; + private int slidingWindowMaxWeight; + private Clock clock; + private boolean resetOnNetworkTypeChange; + + /** + * Creates a builder with default parameters and without listener. + * + * @param context A context. + */ + public Builder(Context context) { + // Handling of null is for backward compatibility only. + this.context = context == null ? null : context.getApplicationContext(); + initialBitrateEstimates = getInitialBitrateEstimatesForCountry(Util.getCountryCode(context)); + slidingWindowMaxWeight = DEFAULT_SLIDING_WINDOW_MAX_WEIGHT; + clock = Clock.DEFAULT; + resetOnNetworkTypeChange = true; + } + + /** + * Sets the maximum weight for the sliding window. + * + * @param slidingWindowMaxWeight The maximum weight for the sliding window. + * @return This builder. + */ + public Builder setSlidingWindowMaxWeight(int slidingWindowMaxWeight) { + this.slidingWindowMaxWeight = slidingWindowMaxWeight; + return this; + } + + /** + * Sets the initial bitrate estimate in bits per second that should be assumed when a bandwidth + * estimate is unavailable. + * + * @param initialBitrateEstimate The initial bitrate estimate in bits per second. + * @return This builder. + */ + public Builder setInitialBitrateEstimate(long initialBitrateEstimate) { + for (int i = 0; i < initialBitrateEstimates.size(); i++) { + initialBitrateEstimates.setValueAt(i, initialBitrateEstimate); + } + return this; + } + + /** + * Sets the initial bitrate estimate in bits per second that should be assumed when a bandwidth + * estimate is unavailable and the current network connection is of the specified type. + * + * @param networkType The {@link C.NetworkType} this initial estimate is for. + * @param initialBitrateEstimate The initial bitrate estimate in bits per second. + * @return This builder. + */ + public Builder setInitialBitrateEstimate( + @C.NetworkType int networkType, long initialBitrateEstimate) { + initialBitrateEstimates.put(networkType, initialBitrateEstimate); + return this; + } + + /** + * Sets the initial bitrate estimates to the default values of the specified country. The + * initial estimates are used when a bandwidth estimate is unavailable. + * + * @param countryCode The ISO 3166-1 alpha-2 country code of the country whose default bitrate + * estimates should be used. + * @return This builder. + */ + public Builder setInitialBitrateEstimate(String countryCode) { + initialBitrateEstimates = + getInitialBitrateEstimatesForCountry(Util.toUpperInvariant(countryCode)); + return this; + } + + /** + * Sets the clock used to estimate bandwidth from data transfers. Should only be set for testing + * purposes. + * + * @param clock The clock used to estimate bandwidth from data transfers. + * @return This builder. + */ + public Builder setClock(Clock clock) { + this.clock = clock; + return this; + } + + /** + * Sets whether to reset if the network type changes. The default value is {@code true}. + * + * @param resetOnNetworkTypeChange Whether to reset if the network type changes. + * @return This builder. + */ + public Builder setResetOnNetworkTypeChange(boolean resetOnNetworkTypeChange) { + this.resetOnNetworkTypeChange = resetOnNetworkTypeChange; + return this; + } + + /** + * Builds the bandwidth meter. + * + * @return A bandwidth meter with the configured properties. + */ + public DefaultBandwidthMeter build() { + return new DefaultBandwidthMeter( + context, + initialBitrateEstimates, + slidingWindowMaxWeight, + clock, + resetOnNetworkTypeChange); + } + + private static SparseArray<Long> getInitialBitrateEstimatesForCountry(String countryCode) { + int[] groupIndices = getCountryGroupIndices(countryCode); + SparseArray<Long> result = new SparseArray<>(/* initialCapacity= */ 6); + result.append(C.NETWORK_TYPE_UNKNOWN, DEFAULT_INITIAL_BITRATE_ESTIMATE); + result.append(C.NETWORK_TYPE_WIFI, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); + result.append(C.NETWORK_TYPE_2G, DEFAULT_INITIAL_BITRATE_ESTIMATES_2G[groupIndices[1]]); + result.append(C.NETWORK_TYPE_3G, DEFAULT_INITIAL_BITRATE_ESTIMATES_3G[groupIndices[2]]); + result.append(C.NETWORK_TYPE_4G, DEFAULT_INITIAL_BITRATE_ESTIMATES_4G[groupIndices[3]]); + // Assume default Wifi bitrate for Ethernet and 5G to prevent using the slower fallback. + result.append( + C.NETWORK_TYPE_ETHERNET, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); + result.append(C.NETWORK_TYPE_5G, DEFAULT_INITIAL_BITRATE_ESTIMATES_WIFI[groupIndices[0]]); + return result; + } + + private static int[] getCountryGroupIndices(String countryCode) { + int[] groupIndices = DEFAULT_INITIAL_BITRATE_COUNTRY_GROUPS.get(countryCode); + // Assume median group if not found. + return groupIndices == null ? new int[] {2, 2, 2, 2} : groupIndices; + } + } + + /** + * Returns a singleton instance of a {@link DefaultBandwidthMeter} with default configuration. + * + * @param context A {@link Context}. + * @return The singleton instance. + */ + public static synchronized DefaultBandwidthMeter getSingletonInstance(Context context) { + if (singletonInstance == null) { + singletonInstance = new DefaultBandwidthMeter.Builder(context).build(); + } + return singletonInstance; + } + + private static final int ELAPSED_MILLIS_FOR_ESTIMATE = 2000; + private static final int BYTES_TRANSFERRED_FOR_ESTIMATE = 512 * 1024; + + @Nullable private final Context context; + private final SparseArray<Long> initialBitrateEstimates; + private final EventDispatcher<EventListener> eventDispatcher; + private final SlidingPercentile slidingPercentile; + private final Clock clock; + + private int streamCount; + private long sampleStartTimeMs; + private long sampleBytesTransferred; + + @C.NetworkType private int networkType; + private long totalElapsedTimeMs; + private long totalBytesTransferred; + private long bitrateEstimate; + private long lastReportedBitrateEstimate; + + private boolean networkTypeOverrideSet; + @C.NetworkType private int networkTypeOverride; + + /** @deprecated Use {@link Builder} instead. */ + @Deprecated + public DefaultBandwidthMeter() { + this( + /* context= */ null, + /* initialBitrateEstimates= */ new SparseArray<>(), + DEFAULT_SLIDING_WINDOW_MAX_WEIGHT, + Clock.DEFAULT, + /* resetOnNetworkTypeChange= */ false); + } + + private DefaultBandwidthMeter( + @Nullable Context context, + SparseArray<Long> initialBitrateEstimates, + int maxWeight, + Clock clock, + boolean resetOnNetworkTypeChange) { + this.context = context == null ? null : context.getApplicationContext(); + this.initialBitrateEstimates = initialBitrateEstimates; + this.eventDispatcher = new EventDispatcher<>(); + this.slidingPercentile = new SlidingPercentile(maxWeight); + this.clock = clock; + // Set the initial network type and bitrate estimate + networkType = context == null ? C.NETWORK_TYPE_UNKNOWN : Util.getNetworkType(context); + bitrateEstimate = getInitialBitrateEstimateForNetworkType(networkType); + // Register to receive connectivity actions if possible. + if (context != null && resetOnNetworkTypeChange) { + ConnectivityActionReceiver connectivityActionReceiver = + ConnectivityActionReceiver.getInstance(context); + connectivityActionReceiver.register(/* bandwidthMeter= */ this); + } + } + + /** + * Overrides the network type. Handled in the same way as if the meter had detected a change from + * the current network type to the specified network type internally. + * + * <p>Applications should not normally call this method. It is intended for testing purposes. + * + * @param networkType The overriding network type. + */ + public synchronized void setNetworkTypeOverride(@C.NetworkType int networkType) { + networkTypeOverride = networkType; + networkTypeOverrideSet = true; + onConnectivityAction(); + } + + @Override + public synchronized long getBitrateEstimate() { + return bitrateEstimate; + } + + @Override + @Nullable + public TransferListener getTransferListener() { + return this; + } + + @Override + public void addEventListener(Handler eventHandler, EventListener eventListener) { + eventDispatcher.addListener(eventHandler, eventListener); + } + + @Override + public void removeEventListener(EventListener eventListener) { + eventDispatcher.removeListener(eventListener); + } + + @Override + public void onTransferInitializing(DataSource source, DataSpec dataSpec, boolean isNetwork) { + // Do nothing. + } + + @Override + public synchronized void onTransferStart( + DataSource source, DataSpec dataSpec, boolean isNetwork) { + if (!isNetwork) { + return; + } + if (streamCount == 0) { + sampleStartTimeMs = clock.elapsedRealtime(); + } + streamCount++; + } + + @Override + public synchronized void onBytesTransferred( + DataSource source, DataSpec dataSpec, boolean isNetwork, int bytes) { + if (!isNetwork) { + return; + } + sampleBytesTransferred += bytes; + } + + @Override + public synchronized void onTransferEnd(DataSource source, DataSpec dataSpec, boolean isNetwork) { + if (!isNetwork) { + return; + } + Assertions.checkState(streamCount > 0); + long nowMs = clock.elapsedRealtime(); + int sampleElapsedTimeMs = (int) (nowMs - sampleStartTimeMs); + totalElapsedTimeMs += sampleElapsedTimeMs; + totalBytesTransferred += sampleBytesTransferred; + if (sampleElapsedTimeMs > 0) { + float bitsPerSecond = (sampleBytesTransferred * 8000f) / sampleElapsedTimeMs; + slidingPercentile.addSample((int) Math.sqrt(sampleBytesTransferred), bitsPerSecond); + if (totalElapsedTimeMs >= ELAPSED_MILLIS_FOR_ESTIMATE + || totalBytesTransferred >= BYTES_TRANSFERRED_FOR_ESTIMATE) { + bitrateEstimate = (long) slidingPercentile.getPercentile(0.5f); + } + maybeNotifyBandwidthSample(sampleElapsedTimeMs, sampleBytesTransferred, bitrateEstimate); + sampleStartTimeMs = nowMs; + sampleBytesTransferred = 0; + } // Else any sample bytes transferred will be carried forward into the next sample. + streamCount--; + } + + private synchronized void onConnectivityAction() { + int networkType = + networkTypeOverrideSet + ? networkTypeOverride + : (context == null ? C.NETWORK_TYPE_UNKNOWN : Util.getNetworkType(context)); + if (this.networkType == networkType) { + return; + } + + this.networkType = networkType; + if (networkType == C.NETWORK_TYPE_OFFLINE + || networkType == C.NETWORK_TYPE_UNKNOWN + || networkType == C.NETWORK_TYPE_OTHER) { + // It's better not to reset the bandwidth meter for these network types. + return; + } + + // Reset the bitrate estimate and report it, along with any bytes transferred. + this.bitrateEstimate = getInitialBitrateEstimateForNetworkType(networkType); + long nowMs = clock.elapsedRealtime(); + int sampleElapsedTimeMs = streamCount > 0 ? (int) (nowMs - sampleStartTimeMs) : 0; + maybeNotifyBandwidthSample(sampleElapsedTimeMs, sampleBytesTransferred, bitrateEstimate); + + // Reset the remainder of the state. + sampleStartTimeMs = nowMs; + sampleBytesTransferred = 0; + totalBytesTransferred = 0; + totalElapsedTimeMs = 0; + slidingPercentile.reset(); + } + + private void maybeNotifyBandwidthSample( + int elapsedMs, long bytesTransferred, long bitrateEstimate) { + if (elapsedMs == 0 && bytesTransferred == 0 && bitrateEstimate == lastReportedBitrateEstimate) { + return; + } + lastReportedBitrateEstimate = bitrateEstimate; + eventDispatcher.dispatch( + listener -> listener.onBandwidthSample(elapsedMs, bytesTransferred, bitrateEstimate)); + } + + private long getInitialBitrateEstimateForNetworkType(@C.NetworkType int networkType) { + Long initialBitrateEstimate = initialBitrateEstimates.get(networkType); + if (initialBitrateEstimate == null) { + initialBitrateEstimate = initialBitrateEstimates.get(C.NETWORK_TYPE_UNKNOWN); + } + if (initialBitrateEstimate == null) { + initialBitrateEstimate = DEFAULT_INITIAL_BITRATE_ESTIMATE; + } + return initialBitrateEstimate; + } + + /* + * Note: This class only holds a weak reference to DefaultBandwidthMeter instances. It should not + * be made non-static, since doing so adds a strong reference (i.e. DefaultBandwidthMeter.this). + */ + private static class ConnectivityActionReceiver extends BroadcastReceiver { + + private static @MonotonicNonNull ConnectivityActionReceiver staticInstance; + + private final Handler mainHandler; + private final ArrayList<WeakReference<DefaultBandwidthMeter>> bandwidthMeters; + + public static synchronized ConnectivityActionReceiver getInstance(Context context) { + if (staticInstance == null) { + staticInstance = new ConnectivityActionReceiver(); + IntentFilter filter = new IntentFilter(); + filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION); + context.registerReceiver(staticInstance, filter); + } + return staticInstance; + } + + private ConnectivityActionReceiver() { + mainHandler = new Handler(Looper.getMainLooper()); + bandwidthMeters = new ArrayList<>(); + } + + public synchronized void register(DefaultBandwidthMeter bandwidthMeter) { + removeClearedReferences(); + bandwidthMeters.add(new WeakReference<>(bandwidthMeter)); + // Simulate an initial update on the main thread (like the sticky broadcast we'd receive if + // we were to register a separate broadcast receiver for each bandwidth meter). + mainHandler.post(() -> updateBandwidthMeter(bandwidthMeter)); + } + + @Override + public synchronized void onReceive(Context context, Intent intent) { + if (isInitialStickyBroadcast()) { + return; + } + removeClearedReferences(); + for (int i = 0; i < bandwidthMeters.size(); i++) { + WeakReference<DefaultBandwidthMeter> bandwidthMeterReference = bandwidthMeters.get(i); + DefaultBandwidthMeter bandwidthMeter = bandwidthMeterReference.get(); + if (bandwidthMeter != null) { + updateBandwidthMeter(bandwidthMeter); + } + } + } + + private void updateBandwidthMeter(DefaultBandwidthMeter bandwidthMeter) { + bandwidthMeter.onConnectivityAction(); + } + + private void removeClearedReferences() { + for (int i = bandwidthMeters.size() - 1; i >= 0; i--) { + WeakReference<DefaultBandwidthMeter> bandwidthMeterReference = bandwidthMeters.get(i); + DefaultBandwidthMeter bandwidthMeter = bandwidthMeterReference.get(); + if (bandwidthMeter == null) { + bandwidthMeters.remove(i); + } + } + } + } + + private static Map<String, int[]> createInitialBitrateCountryGroupAssignment() { + HashMap<String, int[]> countryGroupAssignment = new HashMap<>(); + countryGroupAssignment.put("AD", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("AE", new int[] {1, 4, 4, 4}); + countryGroupAssignment.put("AF", new int[] {4, 4, 3, 3}); + countryGroupAssignment.put("AG", new int[] {3, 1, 0, 1}); + countryGroupAssignment.put("AI", new int[] {1, 0, 0, 3}); + countryGroupAssignment.put("AL", new int[] {1, 2, 0, 1}); + countryGroupAssignment.put("AM", new int[] {2, 2, 2, 2}); + countryGroupAssignment.put("AO", new int[] {3, 4, 2, 0}); + countryGroupAssignment.put("AR", new int[] {2, 3, 2, 2}); + countryGroupAssignment.put("AS", new int[] {3, 0, 4, 2}); + countryGroupAssignment.put("AT", new int[] {0, 3, 0, 0}); + countryGroupAssignment.put("AU", new int[] {0, 3, 0, 1}); + countryGroupAssignment.put("AW", new int[] {1, 1, 0, 3}); + countryGroupAssignment.put("AX", new int[] {0, 3, 0, 2}); + countryGroupAssignment.put("AZ", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("BA", new int[] {1, 1, 0, 1}); + countryGroupAssignment.put("BB", new int[] {0, 2, 0, 0}); + countryGroupAssignment.put("BD", new int[] {2, 1, 3, 3}); + countryGroupAssignment.put("BE", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("BF", new int[] {4, 4, 4, 1}); + countryGroupAssignment.put("BG", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("BH", new int[] {2, 1, 3, 4}); + countryGroupAssignment.put("BI", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("BJ", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("BL", new int[] {1, 0, 2, 2}); + countryGroupAssignment.put("BM", new int[] {1, 2, 0, 0}); + countryGroupAssignment.put("BN", new int[] {4, 1, 3, 2}); + countryGroupAssignment.put("BO", new int[] {1, 2, 3, 2}); + countryGroupAssignment.put("BQ", new int[] {1, 1, 2, 4}); + countryGroupAssignment.put("BR", new int[] {2, 3, 3, 2}); + countryGroupAssignment.put("BS", new int[] {2, 1, 1, 4}); + countryGroupAssignment.put("BT", new int[] {3, 0, 3, 1}); + countryGroupAssignment.put("BW", new int[] {4, 4, 1, 2}); + countryGroupAssignment.put("BY", new int[] {0, 1, 1, 2}); + countryGroupAssignment.put("BZ", new int[] {2, 2, 2, 1}); + countryGroupAssignment.put("CA", new int[] {0, 3, 1, 3}); + countryGroupAssignment.put("CD", new int[] {4, 4, 2, 2}); + countryGroupAssignment.put("CF", new int[] {4, 4, 3, 0}); + countryGroupAssignment.put("CG", new int[] {3, 4, 2, 4}); + countryGroupAssignment.put("CH", new int[] {0, 0, 1, 0}); + countryGroupAssignment.put("CI", new int[] {3, 4, 3, 3}); + countryGroupAssignment.put("CK", new int[] {2, 4, 1, 0}); + countryGroupAssignment.put("CL", new int[] {1, 2, 2, 3}); + countryGroupAssignment.put("CM", new int[] {3, 4, 3, 1}); + countryGroupAssignment.put("CN", new int[] {2, 0, 2, 3}); + countryGroupAssignment.put("CO", new int[] {2, 3, 2, 2}); + countryGroupAssignment.put("CR", new int[] {2, 3, 4, 4}); + countryGroupAssignment.put("CU", new int[] {4, 4, 3, 1}); + countryGroupAssignment.put("CV", new int[] {2, 3, 1, 2}); + countryGroupAssignment.put("CW", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("CY", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("CZ", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("DE", new int[] {0, 1, 1, 3}); + countryGroupAssignment.put("DJ", new int[] {4, 3, 4, 1}); + countryGroupAssignment.put("DK", new int[] {0, 0, 1, 1}); + countryGroupAssignment.put("DM", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("DO", new int[] {3, 3, 4, 4}); + countryGroupAssignment.put("DZ", new int[] {3, 3, 4, 4}); + countryGroupAssignment.put("EC", new int[] {2, 3, 4, 3}); + countryGroupAssignment.put("EE", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("EG", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("EH", new int[] {2, 0, 3, 3}); + countryGroupAssignment.put("ER", new int[] {4, 2, 2, 0}); + countryGroupAssignment.put("ES", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("ET", new int[] {4, 4, 4, 0}); + countryGroupAssignment.put("FI", new int[] {0, 0, 1, 0}); + countryGroupAssignment.put("FJ", new int[] {3, 0, 3, 3}); + countryGroupAssignment.put("FK", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("FM", new int[] {4, 0, 4, 0}); + countryGroupAssignment.put("FO", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("FR", new int[] {1, 0, 3, 1}); + countryGroupAssignment.put("GA", new int[] {3, 3, 2, 2}); + countryGroupAssignment.put("GB", new int[] {0, 1, 3, 3}); + countryGroupAssignment.put("GD", new int[] {2, 0, 4, 4}); + countryGroupAssignment.put("GE", new int[] {1, 1, 1, 4}); + countryGroupAssignment.put("GF", new int[] {2, 3, 4, 4}); + countryGroupAssignment.put("GG", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("GH", new int[] {3, 3, 2, 2}); + countryGroupAssignment.put("GI", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("GL", new int[] {2, 2, 0, 2}); + countryGroupAssignment.put("GM", new int[] {4, 4, 3, 4}); + countryGroupAssignment.put("GN", new int[] {3, 4, 4, 2}); + countryGroupAssignment.put("GP", new int[] {2, 1, 1, 4}); + countryGroupAssignment.put("GQ", new int[] {4, 4, 3, 0}); + countryGroupAssignment.put("GR", new int[] {1, 1, 0, 2}); + countryGroupAssignment.put("GT", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("GU", new int[] {1, 2, 4, 4}); + countryGroupAssignment.put("GW", new int[] {4, 4, 4, 1}); + countryGroupAssignment.put("GY", new int[] {3, 2, 1, 1}); + countryGroupAssignment.put("HK", new int[] {0, 2, 3, 4}); + countryGroupAssignment.put("HN", new int[] {3, 2, 3, 2}); + countryGroupAssignment.put("HR", new int[] {1, 1, 0, 1}); + countryGroupAssignment.put("HT", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("HU", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("ID", new int[] {3, 2, 3, 4}); + countryGroupAssignment.put("IE", new int[] {1, 0, 1, 1}); + countryGroupAssignment.put("IL", new int[] {0, 0, 2, 3}); + countryGroupAssignment.put("IM", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("IN", new int[] {2, 2, 4, 4}); + countryGroupAssignment.put("IO", new int[] {4, 2, 2, 2}); + countryGroupAssignment.put("IQ", new int[] {3, 3, 4, 2}); + countryGroupAssignment.put("IR", new int[] {3, 0, 2, 2}); + countryGroupAssignment.put("IS", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("IT", new int[] {1, 0, 1, 2}); + countryGroupAssignment.put("JE", new int[] {1, 0, 0, 1}); + countryGroupAssignment.put("JM", new int[] {2, 3, 3, 1}); + countryGroupAssignment.put("JO", new int[] {1, 2, 1, 2}); + countryGroupAssignment.put("JP", new int[] {0, 2, 1, 1}); + countryGroupAssignment.put("KE", new int[] {3, 4, 4, 3}); + countryGroupAssignment.put("KG", new int[] {1, 1, 2, 2}); + countryGroupAssignment.put("KH", new int[] {1, 0, 4, 4}); + countryGroupAssignment.put("KI", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("KM", new int[] {4, 3, 2, 3}); + countryGroupAssignment.put("KN", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("KP", new int[] {4, 2, 4, 2}); + countryGroupAssignment.put("KR", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("KW", new int[] {2, 3, 1, 1}); + countryGroupAssignment.put("KY", new int[] {1, 1, 0, 1}); + countryGroupAssignment.put("KZ", new int[] {1, 2, 2, 3}); + countryGroupAssignment.put("LA", new int[] {2, 2, 1, 1}); + countryGroupAssignment.put("LB", new int[] {3, 2, 0, 0}); + countryGroupAssignment.put("LC", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("LI", new int[] {0, 0, 2, 4}); + countryGroupAssignment.put("LK", new int[] {2, 1, 2, 3}); + countryGroupAssignment.put("LR", new int[] {3, 4, 3, 1}); + countryGroupAssignment.put("LS", new int[] {3, 3, 2, 0}); + countryGroupAssignment.put("LT", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("LU", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("LV", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("LY", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("MA", new int[] {2, 1, 2, 1}); + countryGroupAssignment.put("MC", new int[] {0, 0, 0, 1}); + countryGroupAssignment.put("MD", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("ME", new int[] {1, 2, 1, 2}); + countryGroupAssignment.put("MF", new int[] {1, 1, 1, 1}); + countryGroupAssignment.put("MG", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("MH", new int[] {4, 0, 2, 4}); + countryGroupAssignment.put("MK", new int[] {1, 0, 0, 0}); + countryGroupAssignment.put("ML", new int[] {4, 4, 2, 0}); + countryGroupAssignment.put("MM", new int[] {3, 3, 1, 2}); + countryGroupAssignment.put("MN", new int[] {2, 3, 2, 3}); + countryGroupAssignment.put("MO", new int[] {0, 0, 4, 4}); + countryGroupAssignment.put("MP", new int[] {0, 2, 4, 4}); + countryGroupAssignment.put("MQ", new int[] {2, 1, 1, 4}); + countryGroupAssignment.put("MR", new int[] {4, 2, 4, 2}); + countryGroupAssignment.put("MS", new int[] {1, 2, 3, 3}); + countryGroupAssignment.put("MT", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("MU", new int[] {2, 2, 3, 4}); + countryGroupAssignment.put("MV", new int[] {4, 3, 0, 2}); + countryGroupAssignment.put("MW", new int[] {3, 2, 1, 0}); + countryGroupAssignment.put("MX", new int[] {2, 4, 4, 3}); + countryGroupAssignment.put("MY", new int[] {2, 2, 3, 3}); + countryGroupAssignment.put("MZ", new int[] {3, 3, 2, 1}); + countryGroupAssignment.put("NA", new int[] {3, 3, 2, 1}); + countryGroupAssignment.put("NC", new int[] {2, 0, 3, 3}); + countryGroupAssignment.put("NE", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("NF", new int[] {1, 2, 2, 2}); + countryGroupAssignment.put("NG", new int[] {3, 4, 3, 1}); + countryGroupAssignment.put("NI", new int[] {3, 3, 4, 4}); + countryGroupAssignment.put("NL", new int[] {0, 2, 3, 3}); + countryGroupAssignment.put("NO", new int[] {0, 1, 1, 0}); + countryGroupAssignment.put("NP", new int[] {2, 2, 2, 2}); + countryGroupAssignment.put("NR", new int[] {4, 0, 3, 1}); + countryGroupAssignment.put("NZ", new int[] {0, 0, 1, 2}); + countryGroupAssignment.put("OM", new int[] {3, 2, 1, 3}); + countryGroupAssignment.put("PA", new int[] {1, 3, 3, 4}); + countryGroupAssignment.put("PE", new int[] {2, 3, 4, 4}); + countryGroupAssignment.put("PF", new int[] {2, 2, 0, 1}); + countryGroupAssignment.put("PG", new int[] {4, 3, 3, 1}); + countryGroupAssignment.put("PH", new int[] {3, 0, 3, 4}); + countryGroupAssignment.put("PK", new int[] {3, 3, 3, 3}); + countryGroupAssignment.put("PL", new int[] {1, 0, 1, 3}); + countryGroupAssignment.put("PM", new int[] {0, 2, 2, 0}); + countryGroupAssignment.put("PR", new int[] {1, 2, 3, 3}); + countryGroupAssignment.put("PS", new int[] {3, 3, 2, 4}); + countryGroupAssignment.put("PT", new int[] {1, 1, 0, 0}); + countryGroupAssignment.put("PW", new int[] {2, 1, 2, 0}); + countryGroupAssignment.put("PY", new int[] {2, 0, 2, 3}); + countryGroupAssignment.put("QA", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("RE", new int[] {1, 0, 2, 2}); + countryGroupAssignment.put("RO", new int[] {0, 1, 1, 2}); + countryGroupAssignment.put("RS", new int[] {1, 2, 0, 0}); + countryGroupAssignment.put("RU", new int[] {0, 1, 1, 1}); + countryGroupAssignment.put("RW", new int[] {4, 4, 2, 4}); + countryGroupAssignment.put("SA", new int[] {2, 2, 2, 1}); + countryGroupAssignment.put("SB", new int[] {4, 4, 3, 0}); + countryGroupAssignment.put("SC", new int[] {4, 2, 0, 1}); + countryGroupAssignment.put("SD", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("SE", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("SG", new int[] {0, 2, 3, 3}); + countryGroupAssignment.put("SH", new int[] {4, 4, 2, 3}); + countryGroupAssignment.put("SI", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("SJ", new int[] {2, 0, 2, 4}); + countryGroupAssignment.put("SK", new int[] {0, 1, 0, 0}); + countryGroupAssignment.put("SL", new int[] {4, 3, 3, 3}); + countryGroupAssignment.put("SM", new int[] {0, 0, 2, 4}); + countryGroupAssignment.put("SN", new int[] {3, 4, 4, 2}); + countryGroupAssignment.put("SO", new int[] {3, 4, 4, 3}); + countryGroupAssignment.put("SR", new int[] {2, 2, 1, 0}); + countryGroupAssignment.put("SS", new int[] {4, 3, 4, 3}); + countryGroupAssignment.put("ST", new int[] {3, 4, 2, 2}); + countryGroupAssignment.put("SV", new int[] {2, 3, 3, 4}); + countryGroupAssignment.put("SX", new int[] {2, 4, 1, 0}); + countryGroupAssignment.put("SY", new int[] {4, 3, 2, 1}); + countryGroupAssignment.put("SZ", new int[] {4, 4, 3, 4}); + countryGroupAssignment.put("TC", new int[] {1, 2, 1, 1}); + countryGroupAssignment.put("TD", new int[] {4, 4, 4, 2}); + countryGroupAssignment.put("TG", new int[] {3, 3, 1, 0}); + countryGroupAssignment.put("TH", new int[] {1, 3, 4, 4}); + countryGroupAssignment.put("TJ", new int[] {4, 4, 4, 4}); + countryGroupAssignment.put("TL", new int[] {4, 2, 4, 4}); + countryGroupAssignment.put("TM", new int[] {4, 1, 2, 2}); + countryGroupAssignment.put("TN", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("TO", new int[] {3, 3, 3, 1}); + countryGroupAssignment.put("TR", new int[] {2, 2, 1, 2}); + countryGroupAssignment.put("TT", new int[] {1, 3, 1, 2}); + countryGroupAssignment.put("TV", new int[] {4, 2, 2, 4}); + countryGroupAssignment.put("TW", new int[] {0, 0, 0, 0}); + countryGroupAssignment.put("TZ", new int[] {3, 3, 4, 3}); + countryGroupAssignment.put("UA", new int[] {0, 2, 1, 2}); + countryGroupAssignment.put("UG", new int[] {4, 3, 3, 2}); + countryGroupAssignment.put("US", new int[] {1, 1, 3, 3}); + countryGroupAssignment.put("UY", new int[] {2, 2, 1, 1}); + countryGroupAssignment.put("UZ", new int[] {2, 2, 2, 2}); + countryGroupAssignment.put("VA", new int[] {1, 2, 4, 2}); + countryGroupAssignment.put("VC", new int[] {2, 0, 2, 4}); + countryGroupAssignment.put("VE", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("VG", new int[] {3, 0, 1, 3}); + countryGroupAssignment.put("VI", new int[] {1, 1, 4, 4}); + countryGroupAssignment.put("VN", new int[] {0, 2, 4, 4}); + countryGroupAssignment.put("VU", new int[] {4, 1, 3, 1}); + countryGroupAssignment.put("WS", new int[] {3, 3, 3, 2}); + countryGroupAssignment.put("XK", new int[] {1, 2, 1, 0}); + countryGroupAssignment.put("YE", new int[] {4, 4, 4, 3}); + countryGroupAssignment.put("YT", new int[] {2, 2, 2, 3}); + countryGroupAssignment.put("ZA", new int[] {2, 4, 2, 2}); + countryGroupAssignment.put("ZM", new int[] {3, 2, 2, 1}); + countryGroupAssignment.put("ZW", new int[] {3, 3, 2, 1}); + return Collections.unmodifiableMap(countryGroupAssignment); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultDataSource.java new file mode 100644 index 0000000000..87e1c728a0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultDataSource.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.content.Context; +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A {@link DataSource} that supports multiple URI schemes. The supported schemes are: + * + * <ul> + * <li>file: For fetching data from a local file (e.g. file:///path/to/media/media.mp4, or just + * /path/to/media/media.mp4 because the implementation assumes that a URI without a scheme is + * a local file URI). + * <li>asset: For fetching data from an asset in the application's apk (e.g. asset:///media.mp4). + * <li>rawresource: For fetching data from a raw resource in the application's apk (e.g. + * rawresource:///resourceId, where rawResourceId is the integer identifier of the raw + * resource). + * <li>content: For fetching data from a content URI (e.g. content://authority/path/123). + * <li>rtmp: For fetching data over RTMP. Only supported if the project using ExoPlayer has an + * explicit dependency on ExoPlayer's RTMP extension. + * <li>data: For parsing data inlined in the URI as defined in RFC 2397. + * <li>udp: For fetching data over UDP (e.g. udp://something.com/media). + * <li>http(s): For fetching data over HTTP and HTTPS (e.g. https://www.something.com/media.mp4), + * if constructed using {@link #DefaultDataSource(Context, String, boolean)}, or any other + * schemes supported by a base data source if constructed using {@link + * #DefaultDataSource(Context, DataSource)}. + * </ul> + */ +public final class DefaultDataSource implements DataSource { + + private static final String TAG = "DefaultDataSource"; + + private static final String SCHEME_ASSET = "asset"; + private static final String SCHEME_CONTENT = "content"; + private static final String SCHEME_RTMP = "rtmp"; + private static final String SCHEME_UDP = "udp"; + private static final String SCHEME_RAW = RawResourceDataSource.RAW_RESOURCE_SCHEME; + + private final Context context; + private final List<TransferListener> transferListeners; + private final DataSource baseDataSource; + + // Lazily initialized. + @Nullable private DataSource fileDataSource; + @Nullable private DataSource assetDataSource; + @Nullable private DataSource contentDataSource; + @Nullable private DataSource rtmpDataSource; + @Nullable private DataSource udpDataSource; + @Nullable private DataSource dataSchemeDataSource; + @Nullable private DataSource rawResourceDataSource; + + @Nullable private DataSource dataSource; + + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + * @param userAgent The User-Agent to use when requesting remote data. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled when fetching remote data. + */ + public DefaultDataSource(Context context, String userAgent, boolean allowCrossProtocolRedirects) { + this( + context, + userAgent, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + allowCrossProtocolRedirects); + } + + /** + * Constructs a new instance, optionally configured to follow cross-protocol redirects. + * + * @param context A context. + * @param userAgent The User-Agent to use when requesting remote data. + * @param connectTimeoutMillis The connection timeout that should be used when requesting remote + * data, in milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param readTimeoutMillis The read timeout that should be used when requesting remote data, in + * milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled when fetching remote data. + */ + public DefaultDataSource( + Context context, + String userAgent, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects) { + this( + context, + new DefaultHttpDataSource( + userAgent, + connectTimeoutMillis, + readTimeoutMillis, + allowCrossProtocolRedirects, + /* defaultRequestProperties= */ null)); + } + + /** + * Constructs a new instance that delegates to a provided {@link DataSource} for URI schemes other + * than file, asset and content. + * + * @param context A context. + * @param baseDataSource A {@link DataSource} to use for URI schemes other than file, asset and + * content. This {@link DataSource} should normally support at least http(s). + */ + public DefaultDataSource(Context context, DataSource baseDataSource) { + this.context = context.getApplicationContext(); + this.baseDataSource = Assertions.checkNotNull(baseDataSource); + transferListeners = new ArrayList<>(); + } + + @Override + public void addTransferListener(TransferListener transferListener) { + baseDataSource.addTransferListener(transferListener); + transferListeners.add(transferListener); + maybeAddListenerToDataSource(fileDataSource, transferListener); + maybeAddListenerToDataSource(assetDataSource, transferListener); + maybeAddListenerToDataSource(contentDataSource, transferListener); + maybeAddListenerToDataSource(rtmpDataSource, transferListener); + maybeAddListenerToDataSource(udpDataSource, transferListener); + maybeAddListenerToDataSource(dataSchemeDataSource, transferListener); + maybeAddListenerToDataSource(rawResourceDataSource, transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + Assertions.checkState(dataSource == null); + // Choose the correct source for the scheme. + String scheme = dataSpec.uri.getScheme(); + if (Util.isLocalFileUri(dataSpec.uri)) { + String uriPath = dataSpec.uri.getPath(); + if (uriPath != null && uriPath.startsWith("/android_asset/")) { + dataSource = getAssetDataSource(); + } else { + dataSource = getFileDataSource(); + } + } else if (SCHEME_ASSET.equals(scheme)) { + dataSource = getAssetDataSource(); + } else if (SCHEME_CONTENT.equals(scheme)) { + dataSource = getContentDataSource(); + } else if (SCHEME_RTMP.equals(scheme)) { + dataSource = getRtmpDataSource(); + } else if (SCHEME_UDP.equals(scheme)) { + dataSource = getUdpDataSource(); + } else if (DataSchemeDataSource.SCHEME_DATA.equals(scheme)) { + dataSource = getDataSchemeDataSource(); + } else if (SCHEME_RAW.equals(scheme)) { + dataSource = getRawResourceDataSource(); + } else { + dataSource = baseDataSource; + } + // Open the source and return. + return dataSource.open(dataSpec); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + return Assertions.checkNotNull(dataSource).read(buffer, offset, readLength); + } + + @Override + @Nullable + public Uri getUri() { + return dataSource == null ? null : dataSource.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return dataSource == null ? Collections.emptyMap() : dataSource.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + if (dataSource != null) { + try { + dataSource.close(); + } finally { + dataSource = null; + } + } + } + + private DataSource getUdpDataSource() { + if (udpDataSource == null) { + udpDataSource = new UdpDataSource(); + addListenersToDataSource(udpDataSource); + } + return udpDataSource; + } + + private DataSource getFileDataSource() { + if (fileDataSource == null) { + fileDataSource = new FileDataSource(); + addListenersToDataSource(fileDataSource); + } + return fileDataSource; + } + + private DataSource getAssetDataSource() { + if (assetDataSource == null) { + assetDataSource = new AssetDataSource(context); + addListenersToDataSource(assetDataSource); + } + return assetDataSource; + } + + private DataSource getContentDataSource() { + if (contentDataSource == null) { + contentDataSource = new ContentDataSource(context); + addListenersToDataSource(contentDataSource); + } + return contentDataSource; + } + + private DataSource getRtmpDataSource() { + if (rtmpDataSource == null) { + try { + // LINT.IfChange + Class<?> clazz = Class.forName("com.google.android.exoplayer2.ext.rtmp.RtmpDataSource"); + rtmpDataSource = (DataSource) clazz.getConstructor().newInstance(); + // LINT.ThenChange(../../../../../../../../proguard-rules.txt) + addListenersToDataSource(rtmpDataSource); + } catch (ClassNotFoundException e) { + // Expected if the app was built without the RTMP extension. + Log.w(TAG, "Attempting to play RTMP stream without depending on the RTMP extension"); + } catch (Exception e) { + // The RTMP extension is present, but instantiation failed. + throw new RuntimeException("Error instantiating RTMP extension", e); + } + if (rtmpDataSource == null) { + rtmpDataSource = baseDataSource; + } + } + return rtmpDataSource; + } + + private DataSource getDataSchemeDataSource() { + if (dataSchemeDataSource == null) { + dataSchemeDataSource = new DataSchemeDataSource(); + addListenersToDataSource(dataSchemeDataSource); + } + return dataSchemeDataSource; + } + + private DataSource getRawResourceDataSource() { + if (rawResourceDataSource == null) { + rawResourceDataSource = new RawResourceDataSource(context); + addListenersToDataSource(rawResourceDataSource); + } + return rawResourceDataSource; + } + + private void addListenersToDataSource(DataSource dataSource) { + for (int i = 0; i < transferListeners.size(); i++) { + dataSource.addTransferListener(transferListeners.get(i)); + } + } + + private void maybeAddListenerToDataSource( + @Nullable DataSource dataSource, TransferListener listener) { + if (dataSource != null) { + dataSource.addTransferListener(listener); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java new file mode 100644 index 0000000000..81add13c10 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultDataSourceFactory.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.content.Context; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource.Factory; + +/** + * A {@link Factory} that produces {@link DefaultDataSource} instances that delegate to + * {@link DefaultHttpDataSource}s for non-file/asset/content URIs. + */ +public final class DefaultDataSourceFactory implements Factory { + + private final Context context; + @Nullable private final TransferListener listener; + private final DataSource.Factory baseDataSourceFactory; + + /** + * @param context A context. + * @param userAgent The User-Agent string that should be used. + */ + public DefaultDataSourceFactory(Context context, String userAgent) { + this(context, userAgent, /* listener= */ null); + } + + /** + * @param context A context. + * @param userAgent The User-Agent string that should be used. + * @param listener An optional listener. + */ + public DefaultDataSourceFactory( + Context context, String userAgent, @Nullable TransferListener listener) { + this(context, listener, new DefaultHttpDataSourceFactory(userAgent, listener)); + } + + /** + * @param context A context. + * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} + * for {@link DefaultDataSource}. + * @see DefaultDataSource#DefaultDataSource(Context, DataSource) + */ + public DefaultDataSourceFactory(Context context, DataSource.Factory baseDataSourceFactory) { + this(context, /* listener= */ null, baseDataSourceFactory); + } + + /** + * @param context A context. + * @param listener An optional listener. + * @param baseDataSourceFactory A {@link Factory} to be used to create a base {@link DataSource} + * for {@link DefaultDataSource}. + * @see DefaultDataSource#DefaultDataSource(Context, DataSource) + */ + public DefaultDataSourceFactory( + Context context, + @Nullable TransferListener listener, + DataSource.Factory baseDataSourceFactory) { + this.context = context.getApplicationContext(); + this.listener = listener; + this.baseDataSourceFactory = baseDataSourceFactory; + } + + @Override + public DefaultDataSource createDataSource() { + DefaultDataSource dataSource = + new DefaultDataSource(context, baseDataSourceFactory.createDataSource()); + if (listener != null) { + dataSource.addTransferListener(listener); + } + return dataSource; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java new file mode 100644 index 0000000000..c0e8e23bfe --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultHttpDataSource.java @@ -0,0 +1,798 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Predicate; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.NoRouteToHostException; +import java.net.ProtocolException; +import java.net.Proxy; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLConnection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.GZIPInputStream; + +/** + * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}. + * + * <p>By default this implementation will not follow cross-protocol redirects (i.e. redirects from + * HTTP to HTTPS or vice versa). Cross-protocol redirects can be enabled by using the {@link + * #DefaultHttpDataSource(String, int, int, boolean, RequestProperties)} constructor and passing + * {@code true} for the {@code allowCrossProtocolRedirects} argument. + * + * <p>Note: HTTP request headers will be set using all parameters passed via (in order of decreasing + * priority) the {@code dataSpec}, {@link #setRequestProperty} and the default parameters used to + * construct the instance. + */ +public class DefaultHttpDataSource extends BaseDataSource implements HttpDataSource { + + /** The default connection timeout, in milliseconds. */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 8 * 1000; + /** + * The default read timeout, in milliseconds. + */ + public static final int DEFAULT_READ_TIMEOUT_MILLIS = 8 * 1000; + + private static final String TAG = "DefaultHttpDataSource"; + private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. + private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; + private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; + private static final long MAX_BYTES_TO_DRAIN = 2048; + private static final Pattern CONTENT_RANGE_HEADER = + Pattern.compile("^bytes (\\d+)-(\\d+)/(\\d+)$"); + private static final AtomicReference<byte[]> skipBufferReference = new AtomicReference<>(); + + private final boolean allowCrossProtocolRedirects; + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + private final String userAgent; + @Nullable private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + + @Nullable private Predicate<String> contentTypePredicate; + @Nullable private DataSpec dataSpec; + @Nullable private HttpURLConnection connection; + @Nullable private InputStream inputStream; + private boolean opened; + private int responseCode; + + private long bytesToSkip; + private long bytesToRead; + + private long bytesSkipped; + private long bytesRead; + + /** @param userAgent The User-Agent string that should be used. */ + public DefaultHttpDataSource(String userAgent) { + this(userAgent, DEFAULT_CONNECT_TIMEOUT_MILLIS, DEFAULT_READ_TIMEOUT_MILLIS); + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is + * interpreted as an infinite timeout. + * @param readTimeoutMillis The read timeout, in milliseconds. A timeout of zero is interpreted as + * an infinite timeout. + */ + public DefaultHttpDataSource(String userAgent, int connectTimeoutMillis, int readTimeoutMillis) { + this( + userAgent, + connectTimeoutMillis, + readTimeoutMillis, + /* allowCrossProtocolRedirects= */ false, + /* defaultRequestProperties= */ null); + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is + * interpreted as an infinite timeout. Pass {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS} to use the + * default value. + * @param readTimeoutMillis The read timeout, in milliseconds. A timeout of zero is interpreted as + * an infinite timeout. Pass {@link #DEFAULT_READ_TIMEOUT_MILLIS} to use the default value. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled. + * @param defaultRequestProperties The default request properties to be sent to the server as HTTP + * headers or {@code null} if not required. + */ + public DefaultHttpDataSource( + String userAgent, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects, + @Nullable RequestProperties defaultRequestProperties) { + super(/* isNetwork= */ true); + this.userAgent = Assertions.checkNotEmpty(userAgent); + this.requestProperties = new RequestProperties(); + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + this.defaultRequestProperties = defaultRequestProperties; + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @deprecated Use {@link #DefaultHttpDataSource(String)} and {@link + * #setContentTypePredicate(Predicate)}. + */ + @Deprecated + public DefaultHttpDataSource(String userAgent, @Nullable Predicate<String> contentTypePredicate) { + this( + userAgent, + contentTypePredicate, + DEFAULT_CONNECT_TIMEOUT_MILLIS, + DEFAULT_READ_TIMEOUT_MILLIS); + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is + * interpreted as an infinite timeout. + * @param readTimeoutMillis The read timeout, in milliseconds. A timeout of zero is interpreted as + * an infinite timeout. + * @deprecated Use {@link #DefaultHttpDataSource(String, int, int)} and {@link + * #setContentTypePredicate(Predicate)}. + */ + @SuppressWarnings("deprecation") + @Deprecated + public DefaultHttpDataSource( + String userAgent, + @Nullable Predicate<String> contentTypePredicate, + int connectTimeoutMillis, + int readTimeoutMillis) { + this( + userAgent, + contentTypePredicate, + connectTimeoutMillis, + readTimeoutMillis, + /* allowCrossProtocolRedirects= */ false, + /* defaultRequestProperties= */ null); + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param contentTypePredicate An optional {@link Predicate}. If a content type is rejected by the + * predicate then a {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link + * #open(DataSpec)}. + * @param connectTimeoutMillis The connection timeout, in milliseconds. A timeout of zero is + * interpreted as an infinite timeout. Pass {@link #DEFAULT_CONNECT_TIMEOUT_MILLIS} to use the + * default value. + * @param readTimeoutMillis The read timeout, in milliseconds. A timeout of zero is interpreted as + * an infinite timeout. Pass {@link #DEFAULT_READ_TIMEOUT_MILLIS} to use the default value. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled. + * @param defaultRequestProperties The default request properties to be sent to the server as HTTP + * headers or {@code null} if not required. + * @deprecated Use {@link #DefaultHttpDataSource(String, int, int, boolean, RequestProperties)} + * and {@link #setContentTypePredicate(Predicate)}. + */ + @Deprecated + public DefaultHttpDataSource( + String userAgent, + @Nullable Predicate<String> contentTypePredicate, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects, + @Nullable RequestProperties defaultRequestProperties) { + super(/* isNetwork= */ true); + this.userAgent = Assertions.checkNotEmpty(userAgent); + this.contentTypePredicate = contentTypePredicate; + this.requestProperties = new RequestProperties(); + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + this.defaultRequestProperties = defaultRequestProperties; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate then a + * {@link HttpDataSource.InvalidContentTypeException} is thrown from {@link #open(DataSpec)}. + * + * @param contentTypePredicate The content type {@link Predicate}, or {@code null} to clear a + * predicate that was previously set. + */ + public void setContentTypePredicate(@Nullable Predicate<String> contentTypePredicate) { + this.contentTypePredicate = contentTypePredicate; + } + + @Override + @Nullable + public Uri getUri() { + return connection == null ? null : Uri.parse(connection.getURL().toString()); + } + + @Override + public int getResponseCode() { + return connection == null || responseCode <= 0 ? -1 : responseCode; + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return connection == null ? Collections.emptyMap() : connection.getHeaderFields(); + } + + @Override + public void setRequestProperty(String name, String value) { + Assertions.checkNotNull(name); + Assertions.checkNotNull(value); + requestProperties.set(name, value); + } + + @Override + public void clearRequestProperty(String name) { + Assertions.checkNotNull(name); + requestProperties.remove(name); + } + + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + /** + * Opens the source to read the specified data. + */ + @Override + public long open(DataSpec dataSpec) throws HttpDataSourceException { + this.dataSpec = dataSpec; + this.bytesRead = 0; + this.bytesSkipped = 0; + transferInitializing(dataSpec); + try { + connection = makeConnection(dataSpec); + } catch (IOException e) { + throw new HttpDataSourceException( + "Unable to connect", e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } catch (URISyntaxException e) { + throw new HttpDataSourceException("URI invalid: " + dataSpec.uri.toString(), dataSpec, HttpDataSourceException.TYPE_OPEN); + } + + String responseMessage; + try { + responseCode = connection.getResponseCode(); + responseMessage = connection.getResponseMessage(); + } catch (IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException( + "Unable to connect", e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } + + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + Map<String, List<String>> headers = connection.getHeaderFields(); + closeConnectionQuietly(); + InvalidResponseCodeException exception = + new InvalidResponseCodeException(responseCode, responseMessage, headers, dataSpec); + if (responseCode == 416) { + exception.initCause(new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE)); + } + throw exception; + } + + // Check for a valid content type. + String contentType = connection.getContentType(); + if (contentTypePredicate != null && !contentTypePredicate.evaluate(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpec); + } + + // If we requested a range starting from a non-zero position and received a 200 rather than a + // 206, then the server does not support partial requests. We'll need to manually skip to the + // requested position. + bytesToSkip = responseCode == 200 && dataSpec.position != 0 ? dataSpec.position : 0; + + // Determine the length of the data to be read, after skipping. + boolean isCompressed = isCompressed(connection); + if (!isCompressed) { + if (dataSpec.length != C.LENGTH_UNSET) { + bytesToRead = dataSpec.length; + } else { + long contentLength = getContentLength(connection); + bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) + : C.LENGTH_UNSET; + } + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the response + // will be that of the compressed data, which isn't what we want. Always use the dataSpec + // length in this case. + bytesToRead = dataSpec.length; + } + + try { + inputStream = connection.getInputStream(); + if (isCompressed) { + inputStream = new GZIPInputStream(inputStream); + } + } catch (IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException(e, dataSpec, HttpDataSourceException.TYPE_OPEN); + } + + opened = true; + transferStarted(dataSpec); + + return bytesToRead; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException { + try { + skipInternal(); + return readInternal(buffer, offset, readLength); + } catch (IOException e) { + throw new HttpDataSourceException(e, dataSpec, HttpDataSourceException.TYPE_READ); + } + } + + @Override + public void close() throws HttpDataSourceException { + try { + if (inputStream != null) { + maybeTerminateInputStream(connection, bytesRemaining()); + try { + inputStream.close(); + } catch (IOException e) { + throw new HttpDataSourceException(e, dataSpec, HttpDataSourceException.TYPE_CLOSE); + } + } + } finally { + inputStream = null; + closeConnectionQuietly(); + if (opened) { + opened = false; + transferEnded(); + } + } + } + + /** + * Returns the current connection, or null if the source is not currently opened. + * + * @return The current open connection, or null. + */ + protected final @Nullable HttpURLConnection getConnection() { + return connection; + } + + /** + * Returns the number of bytes that have been skipped since the most recent call to + * {@link #open(DataSpec)}. + * + * @return The number of bytes skipped. + */ + protected final long bytesSkipped() { + return bytesSkipped; + } + + /** + * Returns the number of bytes that have been read since the most recent call to + * {@link #open(DataSpec)}. + * + * @return The number of bytes read. + */ + protected final long bytesRead() { + return bytesRead; + } + + /** + * Returns the number of bytes that are still to be read for the current {@link DataSpec}. + * <p> + * If the total length of the data being read is known, then this length minus {@code bytesRead()} + * is returned. If the total length is unknown, {@link C#LENGTH_UNSET} is returned. + * + * @return The remaining length, or {@link C#LENGTH_UNSET}. + */ + protected final long bytesRemaining() { + return bytesToRead == C.LENGTH_UNSET ? bytesToRead : bytesToRead - bytesRead; + } + + /** + * Establishes a connection, following redirects to do so where permitted. + */ + private HttpURLConnection makeConnection(DataSpec dataSpec) throws IOException, URISyntaxException { + URL url = new URL(dataSpec.uri.toString()); + @HttpMethod int httpMethod = dataSpec.httpMethod; + byte[] httpBody = dataSpec.httpBody; + long position = dataSpec.position; + long length = dataSpec.length; + boolean allowGzip = dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); + + if (!allowCrossProtocolRedirects) { + // HttpURLConnection disallows cross-protocol redirects, but otherwise performs redirection + // automatically. This is the behavior we want, so use it. + return makeConnection( + url, + httpMethod, + httpBody, + position, + length, + allowGzip, + /* followRedirects= */ true, + dataSpec.httpRequestHeaders); + } + + // We need to handle redirects ourselves to allow cross-protocol redirects. + int redirectCount = 0; + while (redirectCount++ <= MAX_REDIRECTS) { + HttpURLConnection connection = + makeConnection( + url, + httpMethod, + httpBody, + position, + length, + allowGzip, + /* followRedirects= */ false, + dataSpec.httpRequestHeaders); + int responseCode = connection.getResponseCode(); + String location = connection.getHeaderField("Location"); + if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) + && (responseCode == HttpURLConnection.HTTP_MULT_CHOICE + || responseCode == HttpURLConnection.HTTP_MOVED_PERM + || responseCode == HttpURLConnection.HTTP_MOVED_TEMP + || responseCode == HttpURLConnection.HTTP_SEE_OTHER + || responseCode == HTTP_STATUS_TEMPORARY_REDIRECT + || responseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { + connection.disconnect(); + url = handleRedirect(url, location); + } else if (httpMethod == DataSpec.HTTP_METHOD_POST + && (responseCode == HttpURLConnection.HTTP_MULT_CHOICE + || responseCode == HttpURLConnection.HTTP_MOVED_PERM + || responseCode == HttpURLConnection.HTTP_MOVED_TEMP + || responseCode == HttpURLConnection.HTTP_SEE_OTHER)) { + // POST request follows the redirect and is transformed into a GET request. + connection.disconnect(); + httpMethod = DataSpec.HTTP_METHOD_GET; + httpBody = null; + url = handleRedirect(url, location); + } else { + return connection; + } + } + + // If we get here we've been redirected more times than are permitted. + throw new NoRouteToHostException("Too many redirects: " + redirectCount); + } + + private 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); + } + + /** + * Configures a connection and opens it. + * + * @param url The url to connect to. + * @param httpMethod The http method. + * @param httpBody The body data. + * @param position The byte offset of the requested data. + * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. + * @param allowGzip Whether to allow the use of gzip. + * @param followRedirects Whether to follow redirects. + * @param requestParameters parameters (HTTP headers) to include in request. + */ + private HttpURLConnection makeConnection( + URL url, + @HttpMethod int httpMethod, + byte[] httpBody, + long position, + long length, + boolean allowGzip, + boolean followRedirects, + Map<String, String> requestParameters) + throws IOException, URISyntaxException { + /** + * Tor Project modified the way the connection object was created. For the sake of + * simplicity, instead of duplicating the whole file we changed the connection object + * to use the ProxySelector. + */ + HttpURLConnection connection = (HttpURLConnection) openConnectionWithProxy(url.toURI()); + + connection.setConnectTimeout(connectTimeoutMillis); + connection.setReadTimeout(readTimeoutMillis); + + Map<String, String> requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(requestParameters); + + for (Map.Entry<String, String> property : requestHeaders.entrySet()) { + connection.setRequestProperty(property.getKey(), property.getValue()); + } + + if (!(position == 0 && length == C.LENGTH_UNSET)) { + String rangeRequest = "bytes=" + position + "-"; + if (length != C.LENGTH_UNSET) { + rangeRequest += (position + length - 1); + } + connection.setRequestProperty("Range", rangeRequest); + } + connection.setRequestProperty("User-Agent", userAgent); + connection.setRequestProperty("Accept-Encoding", allowGzip ? "gzip" : "identity"); + connection.setInstanceFollowRedirects(followRedirects); + connection.setDoOutput(httpBody != null); + connection.setRequestMethod(DataSpec.getStringForHttpMethod(httpMethod)); + + if (httpBody != null) { + connection.setFixedLengthStreamingMode(httpBody.length); + connection.connect(); + OutputStream os = connection.getOutputStream(); + os.write(httpBody); + os.close(); + } else { + connection.connect(); + } + return connection; + } + + /** Creates an {@link HttpURLConnection} that is connected with the {@code url}. */ + @VisibleForTesting + /* package */ HttpURLConnection openConnection(URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + /** + * Handles a redirect. + * + * @param originalUrl The original URL. + * @param location The Location header in the response. + * @return The next URL. + * @throws IOException If redirection isn't possible. + */ + private static URL handleRedirect(URL originalUrl, String location) throws IOException { + if (location == null) { + throw new ProtocolException("Null location redirect"); + } + // Form the new url. + URL url = new URL(originalUrl, location); + // Check that the protocol of the new url is supported. + String protocol = url.getProtocol(); + if (!"https".equals(protocol) && !"http".equals(protocol)) { + throw new ProtocolException("Unsupported protocol redirect: " + protocol); + } + // Currently this method is only called if allowCrossProtocolRedirects is true, and so the code + // below isn't required. If we ever decide to handle redirects ourselves when cross-protocol + // redirects are disabled, we'll need to uncomment this block of code. + // if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { + // throw new ProtocolException("Disallowed cross-protocol redirect (" + // + originalUrl.getProtocol() + " to " + protocol + ")"); + // } + return url; + } + + /** + * Attempts to extract the length of the content from the response headers of an open connection. + * + * @param connection The open connection. + * @return The extracted length, or {@link C#LENGTH_UNSET}. + */ + private static long getContentLength(HttpURLConnection connection) { + long contentLength = C.LENGTH_UNSET; + String contentLengthHeader = connection.getHeaderField("Content-Length"); + if (!TextUtils.isEmpty(contentLengthHeader)) { + try { + contentLength = Long.parseLong(contentLengthHeader); + } catch (NumberFormatException e) { + Log.e(TAG, "Unexpected Content-Length [" + contentLengthHeader + "]"); + } + } + String contentRangeHeader = connection.getHeaderField("Content-Range"); + if (!TextUtils.isEmpty(contentRangeHeader)) { + Matcher matcher = CONTENT_RANGE_HEADER.matcher(contentRangeHeader); + if (matcher.find()) { + try { + long contentLengthFromRange = + Long.parseLong(matcher.group(2)) - Long.parseLong(matcher.group(1)) + 1; + if (contentLength < 0) { + // Some proxy servers strip the Content-Length header. Fall back to the length + // calculated here in this case. + contentLength = contentLengthFromRange; + } else if (contentLength != contentLengthFromRange) { + // If there is a discrepancy between the Content-Length and Content-Range headers, + // assume the one with the larger value is correct. We have seen cases where carrier + // change one of them to reduce the size of a request, but it is unlikely anybody would + // increase it. + Log.w(TAG, "Inconsistent headers [" + contentLengthHeader + "] [" + contentRangeHeader + + "]"); + contentLength = Math.max(contentLength, contentLengthFromRange); + } + } catch (NumberFormatException e) { + Log.e(TAG, "Unexpected Content-Range [" + contentRangeHeader + "]"); + } + } + } + return contentLength; + } + + /** + * Skips any bytes that need skipping. Else does nothing. + * <p> + * This implementation is based roughly on {@code libcore.io.Streams.skipByReading()}. + * + * @throws InterruptedIOException If the thread is interrupted during the operation. + * @throws EOFException If the end of the input stream is reached before the bytes are skipped. + */ + private void skipInternal() throws IOException { + if (bytesSkipped == bytesToSkip) { + return; + } + + // Acquire the shared skip buffer. + byte[] skipBuffer = skipBufferReference.getAndSet(null); + if (skipBuffer == null) { + skipBuffer = new byte[4096]; + } + + while (bytesSkipped != bytesToSkip) { + int readLength = (int) Math.min(bytesToSkip - bytesSkipped, skipBuffer.length); + int read = inputStream.read(skipBuffer, 0, readLength); + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedIOException(); + } + if (read == -1) { + throw new EOFException(); + } + bytesSkipped += read; + bytesTransferred(read); + } + + // Release the shared skip buffer. + skipBufferReference.set(skipBuffer); + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at + * index {@code offset}. + * <p> + * This method blocks until at least one byte of data can be read, the end of the opened range is + * detected, or an exception is thrown. + * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + private int readInternal(byte[] buffer, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesToRead != C.LENGTH_UNSET) { + long bytesRemaining = bytesToRead - bytesRead; + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = (int) Math.min(readLength, bytesRemaining); + } + + int read = inputStream.read(buffer, offset, readLength); + if (read == -1) { + if (bytesToRead != C.LENGTH_UNSET) { + // End of stream reached having not read sufficient data. + throw new EOFException(); + } + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + bytesTransferred(read); + return read; + } + + /** + * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can + * block for a long time if the stream has a lot of data remaining. Call this method before + * closing the input stream to make a best effort to cause the input stream to encounter an + * unexpected end of input, working around this issue. On other platform API levels, the method + * does nothing. + * + * @param connection The connection whose {@link InputStream} should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. {@link C#LENGTH_UNSET} otherwise. + */ + private static void maybeTerminateInputStream(HttpURLConnection connection, long bytesRemaining) { + if (Util.SDK_INT != 19 && Util.SDK_INT != 20) { + return; + } + + try { + InputStream inputStream = connection.getInputStream(); + if (bytesRemaining == C.LENGTH_UNSET) { + // If the input stream has already ended, do nothing. The socket may be re-used. + if (inputStream.read() == -1) { + return; + } + } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { + // There isn't much data left. Prefer to allow it to drain, which may allow the socket to be + // re-used. + return; + } + String className = inputStream.getClass().getName(); + if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream".equals(className) + || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" + .equals(className)) { + Class<?> superclass = inputStream.getClass().getSuperclass(); + Method unexpectedEndOfInput = superclass.getDeclaredMethod("unexpectedEndOfInput"); + unexpectedEndOfInput.setAccessible(true); + unexpectedEndOfInput.invoke(inputStream); + } + } catch (Exception e) { + // If an IOException then the connection didn't ever have an input stream, or it was closed + // already. If another type of exception then something went wrong, most likely the device + // isn't using okhttp. + } + } + + + /** + * Closes the current connection quietly, if there is one. + */ + private void closeConnectionQuietly() { + if (connection != null) { + try { + connection.disconnect(); + } catch (Exception e) { + Log.e(TAG, "Unexpected error while disconnecting", e); + } + connection = null; + } + } + + private static boolean isCompressed(HttpURLConnection connection) { + String contentEncoding = connection.getHeaderField("Content-Encoding"); + return "gzip".equalsIgnoreCase(contentEncoding); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java new file mode 100644 index 0000000000..cf7448fbd0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultHttpDataSourceFactory.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource.BaseFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource.Factory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** A {@link Factory} that produces {@link DefaultHttpDataSource} instances. */ +public final class DefaultHttpDataSourceFactory extends BaseFactory { + + private final String userAgent; + @Nullable private final TransferListener listener; + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + private final boolean allowCrossProtocolRedirects; + + /** + * Constructs a DefaultHttpDataSourceFactory. Sets {@link + * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link + * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + * cross-protocol redirects. + * + * @param userAgent The User-Agent string that should be used. + */ + public DefaultHttpDataSourceFactory(String userAgent) { + this(userAgent, null); + } + + /** + * Constructs a DefaultHttpDataSourceFactory. Sets {@link + * DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS} as the connection timeout, {@link + * DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS} as the read timeout and disables + * cross-protocol redirects. + * + * @param userAgent The User-Agent string that should be used. + * @param listener An optional listener. + * @see #DefaultHttpDataSourceFactory(String, TransferListener, int, int, boolean) + */ + public DefaultHttpDataSourceFactory(String userAgent, @Nullable TransferListener listener) { + this(userAgent, listener, DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, false); + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param connectTimeoutMillis The connection timeout that should be used when requesting remote + * data, in milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param readTimeoutMillis The read timeout that should be used when requesting remote data, in + * milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled. + */ + public DefaultHttpDataSourceFactory( + String userAgent, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects) { + this( + userAgent, + /* listener= */ null, + connectTimeoutMillis, + readTimeoutMillis, + allowCrossProtocolRedirects); + } + + /** + * @param userAgent The User-Agent string that should be used. + * @param listener An optional listener. + * @param connectTimeoutMillis The connection timeout that should be used when requesting remote + * data, in milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param readTimeoutMillis The read timeout that should be used when requesting remote data, in + * milliseconds. A timeout of zero is interpreted as an infinite timeout. + * @param allowCrossProtocolRedirects Whether cross-protocol redirects (i.e. redirects from HTTP + * to HTTPS and vice versa) are enabled. + */ + public DefaultHttpDataSourceFactory( + String userAgent, + @Nullable TransferListener listener, + int connectTimeoutMillis, + int readTimeoutMillis, + boolean allowCrossProtocolRedirects) { + this.userAgent = Assertions.checkNotEmpty(userAgent); + this.listener = listener; + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + } + + @Override + protected DefaultHttpDataSource createDataSourceInternal( + HttpDataSource.RequestProperties defaultRequestProperties) { + DefaultHttpDataSource dataSource = + new DefaultHttpDataSource( + userAgent, + connectTimeoutMillis, + readTimeoutMillis, + allowCrossProtocolRedirects, + defaultRequestProperties); + if (listener != null) { + dataSource.addTransferListener(listener); + } + return dataSource; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java new file mode 100644 index 0000000000..082014b7ef --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DefaultLoadErrorHandlingPolicy.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.UnexpectedLoaderException; +import java.io.FileNotFoundException; +import java.io.IOException; + +/** Default implementation of {@link LoadErrorHandlingPolicy}. */ +public class DefaultLoadErrorHandlingPolicy implements LoadErrorHandlingPolicy { + + /** The default minimum number of times to retry loading data prior to propagating the error. */ + public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT = 3; + /** + * The default minimum number of times to retry loading prior to failing for progressive live + * streams. + */ + public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT_PROGRESSIVE_LIVE = 6; + /** The default duration for which a track is blacklisted in milliseconds. */ + public static final long DEFAULT_TRACK_BLACKLIST_MS = 60000; + + private static final int DEFAULT_BEHAVIOR_MIN_LOADABLE_RETRY_COUNT = -1; + + private final int minimumLoadableRetryCount; + + /** + * Creates an instance with default behavior. + * + * <p>{@link #getMinimumLoadableRetryCount} will return {@link + * #DEFAULT_MIN_LOADABLE_RETRY_COUNT_PROGRESSIVE_LIVE} for {@code dataType} {@link + * C#DATA_TYPE_MEDIA_PROGRESSIVE_LIVE}. For other {@code dataType} values, it will return {@link + * #DEFAULT_MIN_LOADABLE_RETRY_COUNT}. + */ + public DefaultLoadErrorHandlingPolicy() { + this(DEFAULT_BEHAVIOR_MIN_LOADABLE_RETRY_COUNT); + } + + /** + * Creates an instance with the given value for {@link #getMinimumLoadableRetryCount(int)}. + * + * @param minimumLoadableRetryCount See {@link #getMinimumLoadableRetryCount}. + */ + public DefaultLoadErrorHandlingPolicy(int minimumLoadableRetryCount) { + this.minimumLoadableRetryCount = minimumLoadableRetryCount; + } + + /** + * Blacklists resources whose load error was an {@link InvalidResponseCodeException} with response + * code HTTP 404 or 410. The duration of the blacklisting is {@link #DEFAULT_TRACK_BLACKLIST_MS}. + */ + @Override + public long getBlacklistDurationMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount) { + if (exception instanceof InvalidResponseCodeException) { + int responseCode = ((InvalidResponseCodeException) exception).responseCode; + return responseCode == 404 // HTTP 404 Not Found. + || responseCode == 410 // HTTP 410 Gone. + || responseCode == 416 // HTTP 416 Range Not Satisfiable. + ? DEFAULT_TRACK_BLACKLIST_MS + : C.TIME_UNSET; + } + return C.TIME_UNSET; + } + + /** + * Retries for any exception that is not a subclass of {@link ParserException}, {@link + * FileNotFoundException} or {@link UnexpectedLoaderException}. The retry delay is calculated as + * {@code Math.min((errorCount - 1) * 1000, 5000)}. + */ + @Override + public long getRetryDelayMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount) { + return exception instanceof ParserException + || exception instanceof FileNotFoundException + || exception instanceof UnexpectedLoaderException + ? C.TIME_UNSET + : Math.min((errorCount - 1) * 1000, 5000); + } + + /** + * See {@link #DefaultLoadErrorHandlingPolicy()} and {@link #DefaultLoadErrorHandlingPolicy(int)} + * for documentation about the behavior of this method. + */ + @Override + public int getMinimumLoadableRetryCount(int dataType) { + if (minimumLoadableRetryCount == DEFAULT_BEHAVIOR_MIN_LOADABLE_RETRY_COUNT) { + return dataType == C.DATA_TYPE_MEDIA_PROGRESSIVE_LIVE + ? DEFAULT_MIN_LOADABLE_RETRY_COUNT_PROGRESSIVE_LIVE + : DEFAULT_MIN_LOADABLE_RETRY_COUNT; + } else { + return minimumLoadableRetryCount; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DummyDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DummyDataSource.java new file mode 100644 index 0000000000..585c37cc78 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/DummyDataSource.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import java.io.IOException; + +/** + * A dummy DataSource which provides no data. {@link #open(DataSpec)} throws {@link IOException}. + */ +public final class DummyDataSource implements DataSource { + + public static final DummyDataSource INSTANCE = new DummyDataSource(); + + /** A factory that produces {@link DummyDataSource}. */ + public static final Factory FACTORY = DummyDataSource::new; + + private DummyDataSource() {} + + @Override + public void addTransferListener(TransferListener transferListener) { + // Do nothing. + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + throw new IOException("Dummy source"); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) { + throw new UnsupportedOperationException(); + } + + @Override + @Nullable + public Uri getUri() { + return null; + } + + @Override + public void close() { + // do nothing. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/FileDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/FileDataSource.java new file mode 100644 index 0000000000..eee30e668f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/FileDataSource.java @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.EOFException; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; + +/** A {@link DataSource} for reading local files. */ +public final class FileDataSource extends BaseDataSource { + + /** Thrown when a {@link FileDataSource} encounters an error reading a file. */ + public static class FileDataSourceException extends IOException { + + public FileDataSourceException(IOException cause) { + super(cause); + } + + public FileDataSourceException(String message, IOException cause) { + super(message, cause); + } + } + + /** {@link DataSource.Factory} for {@link FileDataSource} instances. */ + public static final class Factory implements DataSource.Factory { + + @Nullable private TransferListener listener; + + /** + * Sets a {@link TransferListener} for {@link FileDataSource} instances created by this factory. + * + * @param listener The {@link TransferListener}. + * @return This factory. + */ + public Factory setListener(@Nullable TransferListener listener) { + this.listener = listener; + return this; + } + + @Override + public FileDataSource createDataSource() { + FileDataSource dataSource = new FileDataSource(); + if (listener != null) { + dataSource.addTransferListener(listener); + } + return dataSource; + } + } + + @Nullable private RandomAccessFile file; + @Nullable private Uri uri; + private long bytesRemaining; + private boolean opened; + + public FileDataSource() { + super(/* isNetwork= */ false); + } + + @Override + public long open(DataSpec dataSpec) throws FileDataSourceException { + try { + Uri uri = dataSpec.uri; + this.uri = uri; + + transferInitializing(dataSpec); + + this.file = openLocalFile(uri); + + file.seek(dataSpec.position); + bytesRemaining = dataSpec.length == C.LENGTH_UNSET ? file.length() - dataSpec.position + : dataSpec.length; + if (bytesRemaining < 0) { + throw new EOFException(); + } + } catch (IOException e) { + throw new FileDataSourceException(e); + } + + opened = true; + transferStarted(dataSpec); + + return bytesRemaining; + } + + private static RandomAccessFile openLocalFile(Uri uri) throws FileDataSourceException { + try { + return new RandomAccessFile(Assertions.checkNotNull(uri.getPath()), "r"); + } catch (FileNotFoundException e) { + if (!TextUtils.isEmpty(uri.getQuery()) || !TextUtils.isEmpty(uri.getFragment())) { + throw new FileDataSourceException( + String.format( + "uri has query and/or fragment, which are not supported. Did you call Uri.parse()" + + " on a string containing '?' or '#'? Use Uri.fromFile(new File(path)) to" + + " avoid this. path=%s,query=%s,fragment=%s", + uri.getPath(), uri.getQuery(), uri.getFragment()), + e); + } + throw new FileDataSourceException(e); + } + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws FileDataSourceException { + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } else { + int bytesRead; + try { + bytesRead = + castNonNull(file).read(buffer, offset, (int) Math.min(bytesRemaining, readLength)); + } catch (IOException e) { + throw new FileDataSourceException(e); + } + + if (bytesRead > 0) { + bytesRemaining -= bytesRead; + bytesTransferred(bytesRead); + } + + return bytesRead; + } + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @Override + public void close() throws FileDataSourceException { + uri = null; + try { + if (file != null) { + file.close(); + } + } catch (IOException e) { + throw new FileDataSourceException(e); + } finally { + file = null; + if (opened) { + opened = false; + transferEnded(); + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/FileDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/FileDataSourceFactory.java new file mode 100644 index 0000000000..660a38161c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/FileDataSourceFactory.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import androidx.annotation.Nullable; + +/** @deprecated Use {@link FileDataSource.Factory}. */ +@Deprecated +public final class FileDataSourceFactory implements DataSource.Factory { + + private final FileDataSource.Factory wrappedFactory; + + public FileDataSourceFactory() { + this(/* listener= */ null); + } + + public FileDataSourceFactory(@Nullable TransferListener listener) { + wrappedFactory = new FileDataSource.Factory().setListener(listener); + } + + @Override + public FileDataSource createDataSource() { + return wrappedFactory.createDataSource(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/HttpDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/HttpDataSource.java new file mode 100644 index 0000000000..ffac1ca893 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/HttpDataSource.java @@ -0,0 +1,379 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.text.TextUtils; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Predicate; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * An HTTP {@link DataSource}. + */ +public interface HttpDataSource extends DataSource { + + /** + * A factory for {@link HttpDataSource} instances. + */ + interface Factory extends DataSource.Factory { + + @Override + HttpDataSource createDataSource(); + + /** + * Gets the default request properties used by all {@link HttpDataSource}s created by the + * factory. Changes to the properties will be reflected in any future requests made by + * {@link HttpDataSource}s created by the factory. + * + * @return The default request properties of the factory. + */ + RequestProperties getDefaultRequestProperties(); + + /** + * Sets a default request header for {@link HttpDataSource} instances created by the factory. + * + * @deprecated Use {@link #getDefaultRequestProperties} instead. + * @param name The name of the header field. + * @param value The value of the field. + */ + @Deprecated + void setDefaultRequestProperty(String name, String value); + + /** + * Clears a default request header for {@link HttpDataSource} instances created by the factory. + * + * @deprecated Use {@link #getDefaultRequestProperties} instead. + * @param name The name of the header field. + */ + @Deprecated + void clearDefaultRequestProperty(String name); + + /** + * Clears all default request headers for all {@link HttpDataSource} instances created by the + * factory. + * + * @deprecated Use {@link #getDefaultRequestProperties} instead. + */ + @Deprecated + void clearAllDefaultRequestProperties(); + + } + + /** + * Stores HTTP request properties (aka HTTP headers) and provides methods to modify the headers + * in a thread safe way to avoid the potential of creating snapshots of an inconsistent or + * unintended state. + */ + final class RequestProperties { + + private final Map<String, String> requestProperties; + private Map<String, String> requestPropertiesSnapshot; + + public RequestProperties() { + requestProperties = new HashMap<>(); + } + + /** + * Sets the specified property {@code value} for the specified {@code name}. If a property for + * this name previously existed, the old value is replaced by the specified value. + * + * @param name The name of the request property. + * @param value The value of the request property. + */ + public synchronized void set(String name, String value) { + requestPropertiesSnapshot = null; + requestProperties.put(name, value); + } + + /** + * Sets the keys and values contained in the map. If a property previously existed, the old + * value is replaced by the specified value. If a property previously existed and is not in the + * map, the property is left unchanged. + * + * @param properties The request properties. + */ + public synchronized void set(Map<String, String> properties) { + requestPropertiesSnapshot = null; + requestProperties.putAll(properties); + } + + /** + * Removes all properties previously existing and sets the keys and values of the map. + * + * @param properties The request properties. + */ + public synchronized void clearAndSet(Map<String, String> properties) { + requestPropertiesSnapshot = null; + requestProperties.clear(); + requestProperties.putAll(properties); + } + + /** + * Removes a request property by name. + * + * @param name The name of the request property to remove. + */ + public synchronized void remove(String name) { + requestPropertiesSnapshot = null; + requestProperties.remove(name); + } + + /** + * Clears all request properties. + */ + public synchronized void clear() { + requestPropertiesSnapshot = null; + requestProperties.clear(); + } + + /** + * Gets a snapshot of the request properties. + * + * @return A snapshot of the request properties. + */ + public synchronized Map<String, String> getSnapshot() { + if (requestPropertiesSnapshot == null) { + requestPropertiesSnapshot = Collections.unmodifiableMap(new HashMap<>(requestProperties)); + } + return requestPropertiesSnapshot; + } + + } + + /** + * Base implementation of {@link Factory} that sets default request properties. + */ + abstract class BaseFactory implements Factory { + + private final RequestProperties defaultRequestProperties; + + public BaseFactory() { + defaultRequestProperties = new RequestProperties(); + } + + @Override + public final HttpDataSource createDataSource() { + return createDataSourceInternal(defaultRequestProperties); + } + + @Override + public final RequestProperties getDefaultRequestProperties() { + return defaultRequestProperties; + } + + /** @deprecated Use {@link #getDefaultRequestProperties} instead. */ + @Deprecated + @Override + public final void setDefaultRequestProperty(String name, String value) { + defaultRequestProperties.set(name, value); + } + + /** @deprecated Use {@link #getDefaultRequestProperties} instead. */ + @Deprecated + @Override + public final void clearDefaultRequestProperty(String name) { + defaultRequestProperties.remove(name); + } + + /** @deprecated Use {@link #getDefaultRequestProperties} instead. */ + @Deprecated + @Override + public final void clearAllDefaultRequestProperties() { + defaultRequestProperties.clear(); + } + + /** + * Called by {@link #createDataSource()} to create a {@link HttpDataSource} instance. + * + * @param defaultRequestProperties The default {@code RequestProperties} to be used by the + * {@link HttpDataSource} instance. + * @return A {@link HttpDataSource} instance. + */ + protected abstract HttpDataSource createDataSourceInternal(RequestProperties + defaultRequestProperties); + + } + + /** A {@link Predicate} that rejects content types often used for pay-walls. */ + Predicate<String> REJECT_PAYWALL_TYPES = + contentType -> { + contentType = Util.toLowerInvariant(contentType); + return !TextUtils.isEmpty(contentType) + && (!contentType.contains("text") || contentType.contains("text/vtt")) + && !contentType.contains("html") + && !contentType.contains("xml"); + }; + + /** + * Thrown when an error is encountered when trying to read from a {@link HttpDataSource}. + */ + class HttpDataSourceException extends IOException { + + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_OPEN, TYPE_READ, TYPE_CLOSE}) + public @interface Type {} + + public static final int TYPE_OPEN = 1; + public static final int TYPE_READ = 2; + public static final int TYPE_CLOSE = 3; + + @Type public final int type; + + /** + * The {@link DataSpec} associated with the current connection. + */ + public final DataSpec dataSpec; + + public HttpDataSourceException(DataSpec dataSpec, @Type int type) { + super(); + this.dataSpec = dataSpec; + this.type = type; + } + + public HttpDataSourceException(String message, DataSpec dataSpec, @Type int type) { + super(message); + this.dataSpec = dataSpec; + this.type = type; + } + + public HttpDataSourceException(IOException cause, DataSpec dataSpec, @Type int type) { + super(cause); + this.dataSpec = dataSpec; + this.type = type; + } + + public HttpDataSourceException(String message, IOException cause, DataSpec dataSpec, + @Type int type) { + super(message, cause); + this.dataSpec = dataSpec; + this.type = type; + } + + } + + /** + * Thrown when the content type is invalid. + */ + final class InvalidContentTypeException extends HttpDataSourceException { + + public final String contentType; + + public InvalidContentTypeException(String contentType, DataSpec dataSpec) { + super("Invalid content type: " + contentType, dataSpec, TYPE_OPEN); + this.contentType = contentType; + } + + } + + /** + * Thrown when an attempt to open a connection results in a response code not in the 2xx range. + */ + final class InvalidResponseCodeException extends HttpDataSourceException { + + /** + * The response code that was outside of the 2xx range. + */ + public final int responseCode; + + /** The http status message. */ + @Nullable public final String responseMessage; + + /** + * An unmodifiable map of the response header fields and values. + */ + public final Map<String, List<String>> headerFields; + + /** @deprecated Use {@link #InvalidResponseCodeException(int, String, Map, DataSpec)}. */ + @Deprecated + public InvalidResponseCodeException( + int responseCode, Map<String, List<String>> headerFields, DataSpec dataSpec) { + this(responseCode, /* responseMessage= */ null, headerFields, dataSpec); + } + + public InvalidResponseCodeException( + int responseCode, + @Nullable String responseMessage, + Map<String, List<String>> headerFields, + DataSpec dataSpec) { + super("Response code: " + responseCode, dataSpec, TYPE_OPEN); + this.responseCode = responseCode; + this.responseMessage = responseMessage; + this.headerFields = headerFields; + } + + } + + /** + * Opens the source to read the specified data. + * + * <p>Note: {@link HttpDataSource} implementations are advised to set request headers passed via + * (in order of decreasing priority) the {@code dataSpec}, {@link #setRequestProperty} and the + * default parameters set in the {@link Factory}. + */ + @Override + long open(DataSpec dataSpec) throws HttpDataSourceException; + + @Override + void close() throws HttpDataSourceException; + + @Override + int read(byte[] buffer, int offset, int readLength) throws HttpDataSourceException; + + /** + * Sets the value of a request header. The value will be used for subsequent connections + * established by the source. + * + * <p>Note: If the same header is set as a default parameter in the {@link Factory}, then the + * header value set with this method should be preferred when connecting with the data source. See + * {@link #open}. + * + * @param name The name of the header field. + * @param value The value of the field. + */ + void setRequestProperty(String name, String value); + + /** + * Clears the value of a request header. The change will apply to subsequent connections + * established by the source. + * + * @param name The name of the header field. + */ + void clearRequestProperty(String name); + + /** + * Clears all request headers that were set by {@link #setRequestProperty(String, String)}. + */ + void clearAllRequestProperties(); + + /** + * When the source is open, returns the HTTP response status code associated with the last {@link + * #open} call. Otherwise, returns a negative value. + */ + int getResponseCode(); + + @Override + Map<String, List<String>> getResponseHeaders(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java new file mode 100644 index 0000000000..03c861c5f1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/LoadErrorHandlingPolicy.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Callback; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import java.io.IOException; + +/** + * Defines how errors encountered by {@link Loader Loaders} are handled. + * + * <p>Loader clients may blacklist a resource when a load error occurs. Blacklisting works around + * load errors by loading an alternative resource. Clients do not try blacklisting when a resource + * does not have an alternative. When a resource does have valid alternatives, {@link + * #getBlacklistDurationMsFor(int, long, IOException, int)} defines whether the resource should be + * blacklisted. Blacklisting will succeed if any of the alternatives is not in the black list. + * + * <p>When blacklisting does not take place, {@link #getRetryDelayMsFor(int, long, IOException, + * int)} defines whether the load is retried. Errors whose load is not retried are propagated. Load + * errors whose load is retried are propagated according to {@link + * #getMinimumLoadableRetryCount(int)}. + * + * <p>Methods are invoked on the playback thread. + */ +public interface LoadErrorHandlingPolicy { + + /** + * Returns the number of milliseconds for which a resource associated to a provided load error + * should be blacklisted, or {@link C#TIME_UNSET} if the resource should not be blacklisted. + * + * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to + * load. + * @param loadDurationMs The duration in milliseconds of the load from the start of the first load + * attempt up to the point at which the error occurred. + * @param exception The load error. + * @param errorCount The number of errors this load has encountered, including this one. + * @return The blacklist duration in milliseconds, or {@link C#TIME_UNSET} if the resource should + * not be blacklisted. + */ + long getBlacklistDurationMsFor( + int dataType, long loadDurationMs, IOException exception, int errorCount); + + /** + * Returns the number of milliseconds to wait before attempting the load again, or {@link + * C#TIME_UNSET} if the error is fatal and should not be retried. + * + * <p>{@link Loader} clients may ignore the retry delay returned by this method in order to wait + * for a specific event before retrying. However, the load is retried if and only if this method + * does not return {@link C#TIME_UNSET}. + * + * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to + * load. + * @param loadDurationMs The duration in milliseconds of the load from the start of the first load + * attempt up to the point at which the error occurred. + * @param exception The load error. + * @param errorCount The number of errors this load has encountered, including this one. + * @return The number of milliseconds to wait before attempting the load again, or {@link + * C#TIME_UNSET} if the error is fatal and should not be retried. + */ + long getRetryDelayMsFor(int dataType, long loadDurationMs, IOException exception, int errorCount); + + /** + * Returns the minimum number of times to retry a load in the case of a load error, before + * propagating the error. + * + * @param dataType One of the {@link C C.DATA_TYPE_*} constants indicating the type of data to + * load. + * @return The minimum number of times to retry a load in the case of a load error, before + * propagating the error. + * @see Loader#startLoading(Loadable, Callback, int) + */ + int getMinimumLoadableRetryCount(int dataType); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Loader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Loader.java new file mode 100644 index 0000000000..0e79759b36 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/Loader.java @@ -0,0 +1,521 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.annotation.SuppressLint; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.ExecutorService; + +/** + * Manages the background loading of {@link Loadable}s. + */ +public final class Loader implements LoaderErrorThrower { + + /** + * Thrown when an unexpected exception or error is encountered during loading. + */ + public static final class UnexpectedLoaderException extends IOException { + + public UnexpectedLoaderException(Throwable cause) { + super("Unexpected " + cause.getClass().getSimpleName() + ": " + cause.getMessage(), cause); + } + + } + + /** + * An object that can be loaded using a {@link Loader}. + */ + public interface Loadable { + + /** + * Cancels the load. + */ + void cancelLoad(); + + /** + * Performs the load, returning on completion or cancellation. + * + * @throws IOException If the input could not be loaded. + * @throws InterruptedException If the thread was interrupted. + */ + void load() throws IOException, InterruptedException; + + } + + /** + * A callback to be notified of {@link Loader} events. + */ + public interface Callback<T extends Loadable> { + + /** + * Called when a load has completed. + * + * <p>Note: There is guaranteed to be a memory barrier between {@link Loadable#load()} exiting + * and this callback being called. + * + * @param loadable The loadable whose load has completed. + * @param elapsedRealtimeMs {@link SystemClock#elapsedRealtime} when the load ended. + * @param loadDurationMs The duration in milliseconds of the load since {@link #startLoading} + * was called. + */ + void onLoadCompleted(T loadable, long elapsedRealtimeMs, long loadDurationMs); + + /** + * Called when a load has been canceled. + * + * <p>Note: If the {@link Loader} has not been released then there is guaranteed to be a memory + * barrier between {@link Loadable#load()} exiting and this callback being called. If the {@link + * Loader} has been released then this callback may be called before {@link Loadable#load()} + * exits. + * + * @param loadable The loadable whose load has been canceled. + * @param elapsedRealtimeMs {@link SystemClock#elapsedRealtime} when the load was canceled. + * @param loadDurationMs The duration in milliseconds of the load since {@link #startLoading} + * was called up to the point at which it was canceled. + * @param released True if the load was canceled because the {@link Loader} was released. False + * otherwise. + */ + void onLoadCanceled(T loadable, long elapsedRealtimeMs, long loadDurationMs, boolean released); + + /** + * Called when a load encounters an error. + * + * <p>Note: There is guaranteed to be a memory barrier between {@link Loadable#load()} exiting + * and this callback being called. + * + * @param loadable The loadable whose load has encountered an error. + * @param elapsedRealtimeMs {@link SystemClock#elapsedRealtime} when the error occurred. + * @param loadDurationMs The duration in milliseconds of the load since {@link #startLoading} + * was called up to the point at which the error occurred. + * @param error The load error. + * @param errorCount The number of errors this load has encountered, including this one. + * @return The desired error handling action. One of {@link Loader#RETRY}, {@link + * Loader#RETRY_RESET_ERROR_COUNT}, {@link Loader#DONT_RETRY}, {@link + * Loader#DONT_RETRY_FATAL} or a retry action created by {@link #createRetryAction}. + */ + LoadErrorAction onLoadError( + T loadable, long elapsedRealtimeMs, long loadDurationMs, IOException error, int errorCount); + } + + /** + * A callback to be notified when a {@link Loader} has finished being released. + */ + public interface ReleaseCallback { + + /** + * Called when the {@link Loader} has finished being released. + */ + void onLoaderReleased(); + + } + + /** Types of action that can be taken in response to a load error. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ACTION_TYPE_RETRY, + ACTION_TYPE_RETRY_AND_RESET_ERROR_COUNT, + ACTION_TYPE_DONT_RETRY, + ACTION_TYPE_DONT_RETRY_FATAL + }) + private @interface RetryActionType {} + + private static final int ACTION_TYPE_RETRY = 0; + private static final int ACTION_TYPE_RETRY_AND_RESET_ERROR_COUNT = 1; + private static final int ACTION_TYPE_DONT_RETRY = 2; + private static final int ACTION_TYPE_DONT_RETRY_FATAL = 3; + + /** Retries the load using the default delay. */ + public static final LoadErrorAction RETRY = + createRetryAction(/* resetErrorCount= */ false, C.TIME_UNSET); + /** Retries the load using the default delay and resets the error count. */ + public static final LoadErrorAction RETRY_RESET_ERROR_COUNT = + createRetryAction(/* resetErrorCount= */ true, C.TIME_UNSET); + /** Discards the failed {@link Loadable} and ignores any errors that have occurred. */ + public static final LoadErrorAction DONT_RETRY = + new LoadErrorAction(ACTION_TYPE_DONT_RETRY, C.TIME_UNSET); + /** + * Discards the failed {@link Loadable}. The next call to {@link #maybeThrowError()} will throw + * the last load error. + */ + public static final LoadErrorAction DONT_RETRY_FATAL = + new LoadErrorAction(ACTION_TYPE_DONT_RETRY_FATAL, C.TIME_UNSET); + + /** + * Action that can be taken in response to {@link Callback#onLoadError(Loadable, long, long, + * IOException, int)}. + */ + public static final class LoadErrorAction { + + private final @RetryActionType int type; + private final long retryDelayMillis; + + private LoadErrorAction(@RetryActionType int type, long retryDelayMillis) { + this.type = type; + this.retryDelayMillis = retryDelayMillis; + } + + /** Returns whether this is a retry action. */ + public boolean isRetry() { + return type == ACTION_TYPE_RETRY || type == ACTION_TYPE_RETRY_AND_RESET_ERROR_COUNT; + } + } + + private final ExecutorService downloadExecutorService; + + @Nullable private LoadTask<? extends Loadable> currentTask; + @Nullable private IOException fatalError; + + /** + * @param threadName A name for the loader's thread. + */ + public Loader(String threadName) { + this.downloadExecutorService = Util.newSingleThreadExecutor(threadName); + } + + /** + * Creates a {@link LoadErrorAction} for retrying with the given parameters. + * + * @param resetErrorCount Whether the previous error count should be set to zero. + * @param retryDelayMillis The number of milliseconds to wait before retrying. + * @return A {@link LoadErrorAction} for retrying with the given parameters. + */ + public static LoadErrorAction createRetryAction(boolean resetErrorCount, long retryDelayMillis) { + return new LoadErrorAction( + resetErrorCount ? ACTION_TYPE_RETRY_AND_RESET_ERROR_COUNT : ACTION_TYPE_RETRY, + retryDelayMillis); + } + + /** + * Whether the last call to {@link #startLoading} resulted in a fatal error. Calling {@link + * #maybeThrowError()} will throw the fatal error. + */ + public boolean hasFatalError() { + return fatalError != null; + } + + /** Clears any stored fatal error. */ + public void clearFatalError() { + fatalError = null; + } + + /** + * Starts loading a {@link Loadable}. + * + * <p>The calling thread must be a {@link Looper} thread, which is the thread on which the {@link + * Callback} will be called. + * + * @param <T> The type of the loadable. + * @param loadable The {@link Loadable} to load. + * @param callback A callback to be called when the load ends. + * @param defaultMinRetryCount The minimum number of times the load must be retried before {@link + * #maybeThrowError()} will propagate an error. + * @throws IllegalStateException If the calling thread does not have an associated {@link Looper}. + * @return {@link SystemClock#elapsedRealtime} when the load started. + */ + public <T extends Loadable> long startLoading( + T loadable, Callback<T> callback, int defaultMinRetryCount) { + Looper looper = Assertions.checkStateNotNull(Looper.myLooper()); + fatalError = null; + long startTimeMs = SystemClock.elapsedRealtime(); + new LoadTask<>(looper, loadable, callback, defaultMinRetryCount, startTimeMs).start(0); + return startTimeMs; + } + + /** Returns whether the loader is currently loading. */ + public boolean isLoading() { + return currentTask != null; + } + + /** + * Cancels the current load. + * + * @throws IllegalStateException If the loader is not currently loading. + */ + public void cancelLoading() { + Assertions.checkStateNotNull(currentTask).cancel(false); + } + + /** Releases the loader. This method should be called when the loader is no longer required. */ + public void release() { + release(null); + } + + /** + * Releases the loader. This method should be called when the loader is no longer required. + * + * @param callback An optional callback to be called on the loading thread once the loader has + * been released. + */ + public void release(@Nullable ReleaseCallback callback) { + if (currentTask != null) { + currentTask.cancel(true); + } + if (callback != null) { + downloadExecutorService.execute(new ReleaseTask(callback)); + } + downloadExecutorService.shutdown(); + } + + // LoaderErrorThrower implementation. + + @Override + public void maybeThrowError() throws IOException { + maybeThrowError(Integer.MIN_VALUE); + } + + @Override + public void maybeThrowError(int minRetryCount) throws IOException { + if (fatalError != null) { + throw fatalError; + } else if (currentTask != null) { + currentTask.maybeThrowError(minRetryCount == Integer.MIN_VALUE + ? currentTask.defaultMinRetryCount : minRetryCount); + } + } + + // Internal classes. + + @SuppressLint("HandlerLeak") + private final class LoadTask<T extends Loadable> extends Handler implements Runnable { + + private static final String TAG = "LoadTask"; + + private static final int MSG_START = 0; + private static final int MSG_CANCEL = 1; + private static final int MSG_END_OF_SOURCE = 2; + private static final int MSG_IO_EXCEPTION = 3; + private static final int MSG_FATAL_ERROR = 4; + + public final int defaultMinRetryCount; + + private final T loadable; + private final long startTimeMs; + + @Nullable private Loader.Callback<T> callback; + @Nullable private IOException currentError; + private int errorCount; + + @Nullable private volatile Thread executorThread; + private volatile boolean canceled; + private volatile boolean released; + + public LoadTask(Looper looper, T loadable, Loader.Callback<T> callback, + int defaultMinRetryCount, long startTimeMs) { + super(looper); + this.loadable = loadable; + this.callback = callback; + this.defaultMinRetryCount = defaultMinRetryCount; + this.startTimeMs = startTimeMs; + } + + public void maybeThrowError(int minRetryCount) throws IOException { + if (currentError != null && errorCount > minRetryCount) { + throw currentError; + } + } + + public void start(long delayMillis) { + Assertions.checkState(currentTask == null); + currentTask = this; + if (delayMillis > 0) { + sendEmptyMessageDelayed(MSG_START, delayMillis); + } else { + execute(); + } + } + + public void cancel(boolean released) { + this.released = released; + currentError = null; + if (hasMessages(MSG_START)) { + removeMessages(MSG_START); + if (!released) { + sendEmptyMessage(MSG_CANCEL); + } + } else { + canceled = true; + loadable.cancelLoad(); + Thread executorThread = this.executorThread; + if (executorThread != null) { + executorThread.interrupt(); + } + } + if (released) { + finish(); + long nowMs = SystemClock.elapsedRealtime(); + Assertions.checkNotNull(callback) + .onLoadCanceled(loadable, nowMs, nowMs - startTimeMs, true); + // If loading, this task will be referenced from a GC root (the loading thread) until + // cancellation completes. The time taken for cancellation to complete depends on the + // implementation of the Loadable that the task is loading. We null the callback reference + // here so that it doesn't prevent garbage collection whilst cancellation is ongoing. + callback = null; + } + } + + @Override + public void run() { + try { + executorThread = Thread.currentThread(); + if (!canceled) { + TraceUtil.beginSection("load:" + loadable.getClass().getSimpleName()); + try { + loadable.load(); + } finally { + TraceUtil.endSection(); + } + } + if (!released) { + sendEmptyMessage(MSG_END_OF_SOURCE); + } + } catch (IOException e) { + if (!released) { + obtainMessage(MSG_IO_EXCEPTION, e).sendToTarget(); + } + } catch (InterruptedException e) { + // The load was canceled. + Assertions.checkState(canceled); + if (!released) { + sendEmptyMessage(MSG_END_OF_SOURCE); + } + } catch (Exception e) { + // This should never happen, but handle it anyway. + Log.e(TAG, "Unexpected exception loading stream", e); + if (!released) { + obtainMessage(MSG_IO_EXCEPTION, new UnexpectedLoaderException(e)).sendToTarget(); + } + } catch (OutOfMemoryError e) { + // This can occur if a stream is malformed in a way that causes an extractor to think it + // needs to allocate a large amount of memory. We don't want the process to die in this + // case, but we do want the playback to fail. + Log.e(TAG, "OutOfMemory error loading stream", e); + if (!released) { + obtainMessage(MSG_IO_EXCEPTION, new UnexpectedLoaderException(e)).sendToTarget(); + } + } catch (Error e) { + // We'd hope that the platform would kill the process if an Error is thrown here, but the + // executor may catch the error (b/20616433). Throw it here, but also pass and throw it from + // the handler thread so that the process dies even if the executor behaves in this way. + Log.e(TAG, "Unexpected error loading stream", e); + if (!released) { + obtainMessage(MSG_FATAL_ERROR, e).sendToTarget(); + } + throw e; + } + } + + @Override + public void handleMessage(Message msg) { + if (released) { + return; + } + if (msg.what == MSG_START) { + execute(); + return; + } + if (msg.what == MSG_FATAL_ERROR) { + throw (Error) msg.obj; + } + finish(); + long nowMs = SystemClock.elapsedRealtime(); + long durationMs = nowMs - startTimeMs; + Loader.Callback<T> callback = Assertions.checkNotNull(this.callback); + if (canceled) { + callback.onLoadCanceled(loadable, nowMs, durationMs, false); + return; + } + switch (msg.what) { + case MSG_CANCEL: + callback.onLoadCanceled(loadable, nowMs, durationMs, false); + break; + case MSG_END_OF_SOURCE: + try { + callback.onLoadCompleted(loadable, nowMs, durationMs); + } catch (RuntimeException e) { + // This should never happen, but handle it anyway. + Log.e(TAG, "Unexpected exception handling load completed", e); + fatalError = new UnexpectedLoaderException(e); + } + break; + case MSG_IO_EXCEPTION: + currentError = (IOException) msg.obj; + errorCount++; + LoadErrorAction action = + callback.onLoadError(loadable, nowMs, durationMs, currentError, errorCount); + if (action.type == ACTION_TYPE_DONT_RETRY_FATAL) { + fatalError = currentError; + } else if (action.type != ACTION_TYPE_DONT_RETRY) { + if (action.type == ACTION_TYPE_RETRY_AND_RESET_ERROR_COUNT) { + errorCount = 1; + } + start( + action.retryDelayMillis != C.TIME_UNSET + ? action.retryDelayMillis + : getRetryDelayMillis()); + } + break; + default: + // Never happens. + break; + } + } + + private void execute() { + currentError = null; + downloadExecutorService.execute(Assertions.checkNotNull(currentTask)); + } + + private void finish() { + currentTask = null; + } + + private long getRetryDelayMillis() { + return Math.min((errorCount - 1) * 1000, 5000); + } + + } + + private static final class ReleaseTask implements Runnable { + + private final ReleaseCallback callback; + + public ReleaseTask(ReleaseCallback callback) { + this.callback = callback; + } + + @Override + public void run() { + callback.onLoaderReleased(); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java new file mode 100644 index 0000000000..9a67f20b84 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/LoaderErrorThrower.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import java.io.IOException; + +/** + * Conditionally throws errors affecting a {@link Loader}. + */ +public interface LoaderErrorThrower { + + /** + * Throws a fatal error, or a non-fatal error if loading is currently backed off and the current + * {@link Loadable} has incurred a number of errors greater than the {@link Loader}s default + * minimum number of retries. Else does nothing. + * + * @throws IOException The error. + */ + void maybeThrowError() throws IOException; + + /** + * Throws a fatal error, or a non-fatal error if loading is currently backed off and the current + * {@link Loadable} has incurred a number of errors greater than the specified minimum number + * of retries. Else does nothing. + * + * @param minRetryCount A minimum retry count that must be exceeded for a non-fatal error to be + * thrown. Should be non-negative. + * @throws IOException The error. + */ + void maybeThrowError(int minRetryCount) throws IOException; + + /** + * A {@link LoaderErrorThrower} that never throws. + */ + final class Dummy implements LoaderErrorThrower { + + @Override + public void maybeThrowError() throws IOException { + // Do nothing. + } + + @Override + public void maybeThrowError(int minRetryCount) throws IOException { + // Do nothing. + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ParsingLoadable.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ParsingLoadable.java new file mode 100644 index 0000000000..3e4192b651 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ParsingLoadable.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.Loader.Loadable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; + +/** + * A {@link Loadable} for objects that can be parsed from binary data using a {@link Parser}. + * + * @param <T> The type of the object being loaded. + */ +public final class ParsingLoadable<T> implements Loadable { + + /** + * Parses an object from loaded data. + */ + public interface Parser<T> { + + /** + * Parses an object from a response. + * + * @param uri The source {@link Uri} of the response, after any redirection. + * @param inputStream An {@link InputStream} from which the response data can be read. + * @return The parsed object. + * @throws ParserException If an error occurs parsing the data. + * @throws IOException If an error occurs reading data from the stream. + */ + T parse(Uri uri, InputStream inputStream) throws IOException; + + } + + /** + * Loads a single parsable object. + * + * @param dataSource The {@link DataSource} through which the object should be read. + * @param parser The {@link Parser} to parse the object from the response. + * @param uri The {@link Uri} of the object to read. + * @param type The type of the data. One of the {@link C}{@code DATA_TYPE_*} constants. + * @return The parsed object + * @throws IOException Thrown if there is an error while loading or parsing. + */ + public static <T> T load(DataSource dataSource, Parser<? extends T> parser, Uri uri, int type) + throws IOException { + ParsingLoadable<T> loadable = new ParsingLoadable<>(dataSource, uri, type, parser); + loadable.load(); + return Assertions.checkNotNull(loadable.getResult()); + } + + /** + * Loads a single parsable object. + * + * @param dataSource The {@link DataSource} through which the object should be read. + * @param parser The {@link Parser} to parse the object from the response. + * @param dataSpec The {@link DataSpec} of the object to read. + * @param type The type of the data. One of the {@link C}{@code DATA_TYPE_*} constants. + * @return The parsed object + * @throws IOException Thrown if there is an error while loading or parsing. + */ + public static <T> T load( + DataSource dataSource, Parser<? extends T> parser, DataSpec dataSpec, int type) + throws IOException { + ParsingLoadable<T> loadable = new ParsingLoadable<>(dataSource, dataSpec, type, parser); + loadable.load(); + return Assertions.checkNotNull(loadable.getResult()); + } + + /** + * The {@link DataSpec} that defines the data to be loaded. + */ + public final DataSpec dataSpec; + /** + * The type of the data. One of the {@code DATA_TYPE_*} constants defined in {@link C}. For + * reporting only. + */ + public final int type; + + private final StatsDataSource dataSource; + private final Parser<? extends T> parser; + + private volatile @Nullable T result; + + /** + * @param dataSource A {@link DataSource} to use when loading the data. + * @param uri The {@link Uri} from which the object should be loaded. + * @param type See {@link #type}. + * @param parser Parses the object from the response. + */ + public ParsingLoadable(DataSource dataSource, Uri uri, int type, Parser<? extends T> parser) { + this(dataSource, new DataSpec(uri, DataSpec.FLAG_ALLOW_GZIP), type, parser); + } + + /** + * @param dataSource A {@link DataSource} to use when loading the data. + * @param dataSpec The {@link DataSpec} from which the object should be loaded. + * @param type See {@link #type}. + * @param parser Parses the object from the response. + */ + public ParsingLoadable(DataSource dataSource, DataSpec dataSpec, int type, + Parser<? extends T> parser) { + this.dataSource = new StatsDataSource(dataSource); + this.dataSpec = dataSpec; + this.type = type; + this.parser = parser; + } + + /** Returns the loaded object, or null if an object has not been loaded. */ + public final @Nullable T getResult() { + return result; + } + + /** + * Returns the number of bytes loaded. In the case that the network response was compressed, the + * value returned is the size of the data <em>after</em> decompression. Must only be called after + * the load completed, failed, or was canceled. + */ + public long bytesLoaded() { + return dataSource.getBytesRead(); + } + + /** + * Returns the {@link Uri} from which data was read. If redirection occurred, this is the + * redirected uri. Must only be called after the load completed, failed, or was canceled. + */ + public Uri getUri() { + return dataSource.getLastOpenedUri(); + } + + /** + * Returns the response headers associated with the load. Must only be called after the load + * completed, failed, or was canceled. + */ + public Map<String, List<String>> getResponseHeaders() { + return dataSource.getLastResponseHeaders(); + } + + @Override + public final void cancelLoad() { + // Do nothing. + } + + @Override + public final void load() throws IOException { + // We always load from the beginning, so reset bytesRead to 0. + dataSource.resetBytesRead(); + DataSourceInputStream inputStream = new DataSourceInputStream(dataSource, dataSpec); + try { + inputStream.open(); + Uri dataSourceUri = Assertions.checkNotNull(dataSource.getUri()); + result = parser.parse(dataSourceUri, inputStream); + } finally { + Util.closeQuietly(inputStream); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/PriorityDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/PriorityDataSource.java new file mode 100644 index 0000000000..18a7fb6238 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/PriorityDataSource.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * A {@link DataSource} that can be used as part of a task registered with a + * {@link PriorityTaskManager}. + * <p> + * Calls to {@link #open(DataSpec)} and {@link #read(byte[], int, int)} are allowed to proceed only + * if there are no higher priority tasks registered to the {@link PriorityTaskManager}. If there + * exists a higher priority task then {@link PriorityTaskManager.PriorityTooLowException} is thrown. + * <p> + * Instances of this class are intended to be used as parts of (possibly larger) tasks that are + * registered with the {@link PriorityTaskManager}, and hence do <em>not</em> register as tasks + * themselves. + */ +public final class PriorityDataSource implements DataSource { + + private final DataSource upstream; + private final PriorityTaskManager priorityTaskManager; + private final int priority; + + /** + * @param upstream The upstream {@link DataSource}. + * @param priorityTaskManager The priority manager to which the task is registered. + * @param priority The priority of the task. + */ + public PriorityDataSource(DataSource upstream, PriorityTaskManager priorityTaskManager, + int priority) { + this.upstream = Assertions.checkNotNull(upstream); + this.priorityTaskManager = Assertions.checkNotNull(priorityTaskManager); + this.priority = priority; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + priorityTaskManager.proceedOrThrow(priority); + return upstream.open(dataSpec); + } + + @Override + public int read(byte[] buffer, int offset, int max) throws IOException { + priorityTaskManager.proceedOrThrow(priority); + return upstream.read(buffer, offset, max); + } + + @Override + @Nullable + public Uri getUri() { + return upstream.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + upstream.close(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/PriorityDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/PriorityDataSourceFactory.java new file mode 100644 index 0000000000..cf9a89f51d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/PriorityDataSourceFactory.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource.Factory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; + +/** + * A {@link DataSource.Factory} that produces {@link PriorityDataSource} instances. + */ +public final class PriorityDataSourceFactory implements Factory { + + private final Factory upstreamFactory; + private final PriorityTaskManager priorityTaskManager; + private final int priority; + + /** + * @param upstreamFactory A {@link DataSource.Factory} to be used to create an upstream {@link + * DataSource} for {@link PriorityDataSource}. + * @param priorityTaskManager The priority manager to which PriorityDataSource task is registered. + * @param priority The priority of PriorityDataSource task. + */ + public PriorityDataSourceFactory(Factory upstreamFactory, PriorityTaskManager priorityTaskManager, + int priority) { + this.upstreamFactory = upstreamFactory; + this.priorityTaskManager = priorityTaskManager; + this.priority = priority; + } + + @Override + public PriorityDataSource createDataSource() { + return new PriorityDataSource(upstreamFactory.createDataSource(), priorityTaskManager, + priority); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/RawResourceDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/RawResourceDataSource.java new file mode 100644 index 0000000000..ec5263d8ac --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/RawResourceDataSource.java @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.EOFException; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * A {@link DataSource} for reading a raw resource inside the APK. + * + * <p>URIs supported by this source are of the form {@code rawresource:///rawResourceId}, where + * rawResourceId is the integer identifier of a raw resource. {@link #buildRawResourceUri(int)} can + * be used to build {@link Uri}s in this format. + */ +public final class RawResourceDataSource extends BaseDataSource { + + /** + * Thrown when an {@link IOException} is encountered reading from a raw resource. + */ + public static class RawResourceDataSourceException extends IOException { + public RawResourceDataSourceException(String message) { + super(message); + } + + public RawResourceDataSourceException(IOException e) { + super(e); + } + } + + /** + * Builds a {@link Uri} for the specified raw resource identifier. + * + * @param rawResourceId A raw resource identifier (i.e. a constant defined in {@code R.raw}). + * @return The corresponding {@link Uri}. + */ + public static Uri buildRawResourceUri(int rawResourceId) { + return Uri.parse(RAW_RESOURCE_SCHEME + ":///" + rawResourceId); + } + + /** The scheme part of a raw resource URI. */ + public static final String RAW_RESOURCE_SCHEME = "rawresource"; + + private final Resources resources; + + @Nullable private Uri uri; + @Nullable private AssetFileDescriptor assetFileDescriptor; + @Nullable private InputStream inputStream; + private long bytesRemaining; + private boolean opened; + + /** + * @param context A context. + */ + public RawResourceDataSource(Context context) { + super(/* isNetwork= */ false); + this.resources = context.getResources(); + } + + @Override + public long open(DataSpec dataSpec) throws RawResourceDataSourceException { + try { + Uri uri = dataSpec.uri; + this.uri = uri; + if (!TextUtils.equals(RAW_RESOURCE_SCHEME, uri.getScheme())) { + throw new RawResourceDataSourceException("URI must use scheme " + RAW_RESOURCE_SCHEME); + } + + int resourceId; + try { + resourceId = Integer.parseInt(Assertions.checkNotNull(uri.getLastPathSegment())); + } catch (NumberFormatException e) { + throw new RawResourceDataSourceException("Resource identifier must be an integer."); + } + + transferInitializing(dataSpec); + AssetFileDescriptor assetFileDescriptor = resources.openRawResourceFd(resourceId); + this.assetFileDescriptor = assetFileDescriptor; + if (assetFileDescriptor == null) { + throw new RawResourceDataSourceException("Resource is compressed: " + uri); + } + FileInputStream inputStream = new FileInputStream(assetFileDescriptor.getFileDescriptor()); + this.inputStream = inputStream; + + inputStream.skip(assetFileDescriptor.getStartOffset()); + long skipped = inputStream.skip(dataSpec.position); + if (skipped < dataSpec.position) { + // We expect the skip to be satisfied in full. If it isn't then we're probably trying to + // skip beyond the end of the data. + throw new EOFException(); + } + if (dataSpec.length != C.LENGTH_UNSET) { + bytesRemaining = dataSpec.length; + } else { + long assetFileDescriptorLength = assetFileDescriptor.getLength(); + // If the length is UNKNOWN_LENGTH then the asset extends to the end of the file. + bytesRemaining = assetFileDescriptorLength == AssetFileDescriptor.UNKNOWN_LENGTH + ? C.LENGTH_UNSET : (assetFileDescriptorLength - dataSpec.position); + } + } catch (IOException e) { + throw new RawResourceDataSourceException(e); + } + + opened = true; + transferStarted(dataSpec); + + return bytesRemaining; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws RawResourceDataSourceException { + if (readLength == 0) { + return 0; + } else if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + + int bytesRead; + try { + int bytesToRead = bytesRemaining == C.LENGTH_UNSET ? readLength + : (int) Math.min(bytesRemaining, readLength); + bytesRead = castNonNull(inputStream).read(buffer, offset, bytesToRead); + } catch (IOException e) { + throw new RawResourceDataSourceException(e); + } + + if (bytesRead == -1) { + if (bytesRemaining != C.LENGTH_UNSET) { + // End of stream reached having not read sufficient data. + throw new RawResourceDataSourceException(new EOFException()); + } + return C.RESULT_END_OF_INPUT; + } + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + bytesTransferred(bytesRead); + return bytesRead; + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @SuppressWarnings("Finally") + @Override + public void close() throws RawResourceDataSourceException { + uri = null; + try { + if (inputStream != null) { + inputStream.close(); + } + } catch (IOException e) { + throw new RawResourceDataSourceException(e); + } finally { + inputStream = null; + try { + if (assetFileDescriptor != null) { + assetFileDescriptor.close(); + } + } catch (IOException e) { + throw new RawResourceDataSourceException(e); + } finally { + assetFileDescriptor = null; + if (opened) { + opened = false; + transferEnded(); + } + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ResolvingDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ResolvingDataSource.java new file mode 100644 index 0000000000..80046e1757 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/ResolvingDataSource.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** {@link DataSource} wrapper allowing just-in-time resolution of {@link DataSpec DataSpecs}. */ +public final class ResolvingDataSource implements DataSource { + + /** Resolves {@link DataSpec DataSpecs}. */ + public interface Resolver { + + /** + * Resolves a {@link DataSpec} before forwarding it to the wrapped {@link DataSource}. This + * method is allowed to block until the {@link DataSpec} has been resolved. + * + * <p>Note that this method is called for every new connection, so caching of results is + * recommended, especially if network operations are involved. + * + * @param dataSpec The original {@link DataSpec}. + * @return The resolved {@link DataSpec}. + * @throws IOException If an {@link IOException} occurred while resolving the {@link DataSpec}. + */ + DataSpec resolveDataSpec(DataSpec dataSpec) throws IOException; + + /** + * Resolves a URI reported by {@link DataSource#getUri()} for event reporting and caching + * purposes. + * + * <p>Implementations do not need to overwrite this method unless they want to change the + * reported URI. + * + * <p>This method is <em>not</em> allowed to block. + * + * @param uri The URI as reported by {@link DataSource#getUri()}. + * @return The resolved URI used for event reporting and caching. + */ + default Uri resolveReportedUri(Uri uri) { + return uri; + } + } + + /** {@link DataSource.Factory} for {@link ResolvingDataSource} instances. */ + public static final class Factory implements DataSource.Factory { + + private final DataSource.Factory upstreamFactory; + private final Resolver resolver; + + /** + * @param upstreamFactory The wrapped {@link DataSource.Factory} for handling resolved {@link + * DataSpec DataSpecs}. + * @param resolver The {@link Resolver} to resolve the {@link DataSpec DataSpecs}. + */ + public Factory(DataSource.Factory upstreamFactory, Resolver resolver) { + this.upstreamFactory = upstreamFactory; + this.resolver = resolver; + } + + @Override + public ResolvingDataSource createDataSource() { + return new ResolvingDataSource(upstreamFactory.createDataSource(), resolver); + } + } + + private final DataSource upstreamDataSource; + private final Resolver resolver; + + private boolean upstreamOpened; + + /** + * @param upstreamDataSource The wrapped {@link DataSource}. + * @param resolver The {@link Resolver} to resolve the {@link DataSpec DataSpecs}. + */ + public ResolvingDataSource(DataSource upstreamDataSource, Resolver resolver) { + this.upstreamDataSource = upstreamDataSource; + this.resolver = resolver; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstreamDataSource.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + DataSpec resolvedDataSpec = resolver.resolveDataSpec(dataSpec); + upstreamOpened = true; + return upstreamDataSource.open(resolvedDataSpec); + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + return upstreamDataSource.read(buffer, offset, readLength); + } + + @Nullable + @Override + public Uri getUri() { + Uri reportedUri = upstreamDataSource.getUri(); + return reportedUri == null ? null : resolver.resolveReportedUri(reportedUri); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return upstreamDataSource.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + if (upstreamOpened) { + upstreamOpened = false; + upstreamDataSource.close(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/StatsDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/StatsDataSource.java new file mode 100644 index 0000000000..e2a179cc9d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/StatsDataSource.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * {@link DataSource} wrapper which keeps track of bytes transferred, redirected uris, and response + * headers. + */ +public final class StatsDataSource implements DataSource { + + private final DataSource dataSource; + + private long bytesRead; + private Uri lastOpenedUri; + private Map<String, List<String>> lastResponseHeaders; + + /** + * Creates the stats data source. + * + * @param dataSource The wrapped {@link DataSource}. + */ + public StatsDataSource(DataSource dataSource) { + this.dataSource = Assertions.checkNotNull(dataSource); + lastOpenedUri = Uri.EMPTY; + lastResponseHeaders = Collections.emptyMap(); + } + + /** Resets the number of bytes read as returned from {@link #getBytesRead()} to zero. */ + public void resetBytesRead() { + bytesRead = 0; + } + + /** Returns the total number of bytes that have been read from the data source. */ + public long getBytesRead() { + return bytesRead; + } + + /** + * Returns the {@link Uri} associated with the last {@link #open(DataSpec)} call. If redirection + * occurred, this is the redirected uri. + */ + public Uri getLastOpenedUri() { + return lastOpenedUri; + } + + /** Returns the response headers associated with the last {@link #open(DataSpec)} call. */ + public Map<String, List<String>> getLastResponseHeaders() { + return lastResponseHeaders; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + dataSource.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + // Reassign defaults in case dataSource.open throws an exception. + lastOpenedUri = dataSpec.uri; + lastResponseHeaders = Collections.emptyMap(); + long availableBytes = dataSource.open(dataSpec); + lastOpenedUri = Assertions.checkNotNull(getUri()); + lastResponseHeaders = getResponseHeaders(); + return availableBytes; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + int bytesRead = dataSource.read(buffer, offset, readLength); + if (bytesRead != C.RESULT_END_OF_INPUT) { + this.bytesRead += bytesRead; + } + return bytesRead; + } + + @Override + @Nullable + public Uri getUri() { + return dataSource.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return dataSource.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + dataSource.close(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/TeeDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/TeeDataSource.java new file mode 100644 index 0000000000..c6063b916f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/TeeDataSource.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +/** + * Tees data into a {@link DataSink} as the data is read. + */ +public final class TeeDataSource implements DataSource { + + private final DataSource upstream; + private final DataSink dataSink; + + private boolean dataSinkNeedsClosing; + private long bytesRemaining; + + /** + * @param upstream The upstream {@link DataSource}. + * @param dataSink The {@link DataSink} into which data is written. + */ + public TeeDataSource(DataSource upstream, DataSink dataSink) { + this.upstream = Assertions.checkNotNull(upstream); + this.dataSink = Assertions.checkNotNull(dataSink); + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + bytesRemaining = upstream.open(dataSpec); + if (bytesRemaining == 0) { + return 0; + } + if (dataSpec.length == C.LENGTH_UNSET && bytesRemaining != C.LENGTH_UNSET) { + // Reconstruct dataSpec in order to provide the resolved length to the sink. + dataSpec = dataSpec.subrange(0, bytesRemaining); + } + dataSinkNeedsClosing = true; + dataSink.open(dataSpec); + return bytesRemaining; + } + + @Override + public int read(byte[] buffer, int offset, int max) throws IOException { + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + int bytesRead = upstream.read(buffer, offset, max); + if (bytesRead > 0) { + // TODO: Consider continuing even if writes to the sink fail. + dataSink.write(buffer, offset, bytesRead); + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + } + return bytesRead; + } + + @Override + @Nullable + public Uri getUri() { + return upstream.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + try { + upstream.close(); + } finally { + if (dataSinkNeedsClosing) { + dataSinkNeedsClosing = false; + dataSink.close(); + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/TransferListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/TransferListener.java new file mode 100644 index 0000000000..f6574120ff --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/TransferListener.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +/** + * A listener of data transfer events. + * + * <p>A transfer usually progresses through multiple steps: + * + * <ol> + * <li>Initializing the underlying resource (e.g. opening a HTTP connection). {@link + * #onTransferInitializing(DataSource, DataSpec, boolean)} is called before the initialization + * starts. + * <li>Starting the transfer after successfully initializing the resource. {@link + * #onTransferStart(DataSource, DataSpec, boolean)} is called. Note that this only happens if + * the initialization was successful. + * <li>Transferring data. {@link #onBytesTransferred(DataSource, DataSpec, boolean, int)} is + * called frequently during the transfer to indicate progress. + * <li>Closing the transfer and the underlying resource. {@link #onTransferEnd(DataSource, + * DataSpec, boolean)} is called. Note that each {@link #onTransferStart(DataSource, DataSpec, + * boolean)} will have exactly one corresponding call to {@link #onTransferEnd(DataSource, + * DataSpec, boolean)}. + * </ol> + */ +public interface TransferListener { + + /** + * Called when a transfer is being initialized. + * + * @param source The source performing the transfer. + * @param dataSpec Describes the data for which the transfer is initialized. + * @param isNetwork Whether the data is transferred through a network. + */ + void onTransferInitializing(DataSource source, DataSpec dataSpec, boolean isNetwork); + + /** + * Called when a transfer starts. + * + * @param source The source performing the transfer. + * @param dataSpec Describes the data being transferred. + * @param isNetwork Whether the data is transferred through a network. + */ + void onTransferStart(DataSource source, DataSpec dataSpec, boolean isNetwork); + + /** + * Called incrementally during a transfer. + * + * @param source The source performing the transfer. + * @param dataSpec Describes the data being transferred. + * @param isNetwork Whether the data is transferred through a network. + * @param bytesTransferred The number of bytes transferred since the previous call to this method + */ + void onBytesTransferred( + DataSource source, DataSpec dataSpec, boolean isNetwork, int bytesTransferred); + + /** + * Called when a transfer ends. + * + * @param source The source performing the transfer. + * @param dataSpec Describes the data being transferred. + * @param isNetwork Whether the data is transferred through a network. + */ + void onTransferEnd(DataSource source, DataSpec dataSpec, boolean isNetwork); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/UdpDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/UdpDataSource.java new file mode 100644 index 0000000000..8e9b44563c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/UdpDataSource.java @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.MulticastSocket; +import java.net.SocketException; + +/** A UDP {@link DataSource}. */ +public final class UdpDataSource extends BaseDataSource { + + /** + * Thrown when an error is encountered when trying to read from a {@link UdpDataSource}. + */ + public static final class UdpDataSourceException extends IOException { + + public UdpDataSourceException(IOException cause) { + super(cause); + } + + } + + /** + * The default maximum datagram packet size, in bytes. + */ + public static final int DEFAULT_MAX_PACKET_SIZE = 2000; + + /** The default socket timeout, in milliseconds. */ + public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 8 * 1000; + + private final int socketTimeoutMillis; + private final byte[] packetBuffer; + private final DatagramPacket packet; + + @Nullable private Uri uri; + @Nullable private DatagramSocket socket; + @Nullable private MulticastSocket multicastSocket; + @Nullable private InetAddress address; + @Nullable private InetSocketAddress socketAddress; + private boolean opened; + + private int packetRemaining; + + public UdpDataSource() { + this(DEFAULT_MAX_PACKET_SIZE); + } + + /** + * Constructs a new instance. + * + * @param maxPacketSize The maximum datagram packet size, in bytes. + */ + public UdpDataSource(int maxPacketSize) { + this(maxPacketSize, DEFAULT_SOCKET_TIMEOUT_MILLIS); + } + + /** + * Constructs a new instance. + * + * @param maxPacketSize The maximum datagram packet size, in bytes. + * @param socketTimeoutMillis The socket timeout in milliseconds. A timeout of zero is interpreted + * as an infinite timeout. + */ + public UdpDataSource(int maxPacketSize, int socketTimeoutMillis) { + super(/* isNetwork= */ true); + this.socketTimeoutMillis = socketTimeoutMillis; + packetBuffer = new byte[maxPacketSize]; + packet = new DatagramPacket(packetBuffer, 0, maxPacketSize); + } + + @Override + public long open(DataSpec dataSpec) throws UdpDataSourceException { + uri = dataSpec.uri; + String host = uri.getHost(); + int port = uri.getPort(); + transferInitializing(dataSpec); + try { + address = InetAddress.getByName(host); + socketAddress = new InetSocketAddress(address, port); + if (address.isMulticastAddress()) { + multicastSocket = new MulticastSocket(socketAddress); + multicastSocket.joinGroup(address); + socket = multicastSocket; + } else { + socket = new DatagramSocket(socketAddress); + } + } catch (IOException e) { + throw new UdpDataSourceException(e); + } + + try { + socket.setSoTimeout(socketTimeoutMillis); + } catch (SocketException e) { + throw new UdpDataSourceException(e); + } + + opened = true; + transferStarted(dataSpec); + return C.LENGTH_UNSET; + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws UdpDataSourceException { + if (readLength == 0) { + return 0; + } + + if (packetRemaining == 0) { + // We've read all of the data from the current packet. Get another. + try { + socket.receive(packet); + } catch (IOException e) { + throw new UdpDataSourceException(e); + } + packetRemaining = packet.getLength(); + bytesTransferred(packetRemaining); + } + + int packetOffset = packet.getLength() - packetRemaining; + int bytesToRead = Math.min(packetRemaining, readLength); + System.arraycopy(packetBuffer, packetOffset, buffer, offset, bytesToRead); + packetRemaining -= bytesToRead; + return bytesToRead; + } + + @Override + @Nullable + public Uri getUri() { + return uri; + } + + @Override + public void close() { + uri = null; + if (multicastSocket != null) { + try { + multicastSocket.leaveGroup(address); + } catch (IOException e) { + // Do nothing. + } + multicastSocket = null; + } + if (socket != null) { + socket.close(); + socket = null; + } + address = null; + socketAddress = null; + packetRemaining = 0; + if (opened) { + opened = false; + transferEnded(); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java new file mode 100644 index 0000000000..cb90d95bb4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/Cache.java @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.File; +import java.io.IOException; +import java.util.NavigableSet; +import java.util.Set; + +/** + * An interface for cache. + */ +public interface Cache { + + /** + * Listener of {@link Cache} events. + */ + interface Listener { + + /** + * Called when a {@link CacheSpan} is added to the cache. + * + * @param cache The source of the event. + * @param span The added {@link CacheSpan}. + */ + void onSpanAdded(Cache cache, CacheSpan span); + + /** + * Called when a {@link CacheSpan} is removed from the cache. + * + * @param cache The source of the event. + * @param span The removed {@link CacheSpan}. + */ + void onSpanRemoved(Cache cache, CacheSpan span); + + /** + * Called when an existing {@link CacheSpan} is touched, causing it to be replaced. The new + * {@link CacheSpan} is guaranteed to represent the same data as the one it replaces, however + * {@link CacheSpan#file} and {@link CacheSpan#lastTouchTimestamp} may have changed. + * + * <p>Note that for span replacement, {@link #onSpanAdded(Cache, CacheSpan)} and {@link + * #onSpanRemoved(Cache, CacheSpan)} are not called in addition to this method. + * + * @param cache The source of the event. + * @param oldSpan The old {@link CacheSpan}, which has been removed from the cache. + * @param newSpan The new {@link CacheSpan}, which has been added to the cache. + */ + void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan); + } + + /** + * Thrown when an error is encountered when writing data. + */ + class CacheException extends IOException { + + public CacheException(String message) { + super(message); + } + + public CacheException(Throwable cause) { + super(cause); + } + + public CacheException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Returned by {@link #getUid()} if initialization failed before the unique identifier was read or + * generated. + */ + long UID_UNSET = -1; + + /** + * Returns a non-negative unique identifier for the cache, or {@link #UID_UNSET} if initialization + * failed before the unique identifier was determined. + * + * <p>Implementations are expected to generate and store the unique identifier alongside the + * cached content. If the location of the cache is deleted or swapped, it is expected that a new + * unique identifier will be generated when the cache is recreated. + */ + long getUid(); + + /** + * Releases the cache. This method must be called when the cache is no longer required. The cache + * must not be used after calling this method. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + */ + @WorkerThread + void release(); + + /** + * Registers a listener to listen for changes to a given key. + * + * <p>No guarantees are made about the thread or threads on which the listener is called, but it + * is guaranteed that listener methods will be called in a serial fashion (i.e. one at a time) and + * in the same order as events occurred. + * + * @param key The key to listen to. + * @param listener The listener to add. + * @return The current spans for the key. + */ + NavigableSet<CacheSpan> addListener(String key, Listener listener); + + /** + * Unregisters a listener. + * + * @param key The key to stop listening to. + * @param listener The listener to remove. + */ + void removeListener(String key, Listener listener); + + /** + * Returns the cached spans for a given cache key. + * + * @param key The key for which spans should be returned. + * @return The spans for the key. + */ + NavigableSet<CacheSpan> getCachedSpans(String key); + + /** + * Returns all keys in the cache. + * + * @return All the keys in the cache. + */ + Set<String> getKeys(); + + /** + * Returns the total disk space in bytes used by the cache. + * + * @return The total disk space in bytes. + */ + long getCacheSpace(); + + /** + * A caller should invoke this method when they require data from a given position for a given + * key. + * + * <p>If there is a cache entry that overlaps the position, then the returned {@link CacheSpan} + * defines the file in which the data is stored. {@link CacheSpan#isCached} is true. The caller + * may read from the cache file, but does not acquire any locks. + * + * <p>If there is no cache entry overlapping {@code offset}, then the returned {@link CacheSpan} + * defines a hole in the cache starting at {@code position} into which the caller may write as it + * obtains the data from some other source. The returned {@link CacheSpan} serves as a lock. + * Whilst the caller holds the lock it may write data into the hole. It may split data into + * multiple files. When the caller has finished writing a file it should commit it to the cache by + * calling {@link #commitFile(File, long)}. When the caller has finished writing, it must release + * the lock by calling {@link #releaseHoleSpan}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param key The key of the data being requested. + * @param position The position of the data being requested. + * @return The {@link CacheSpan}. + * @throws InterruptedException If the thread was interrupted. + * @throws CacheException If an error is encountered. + */ + @WorkerThread + CacheSpan startReadWrite(String key, long position) throws InterruptedException, CacheException; + + /** + * Same as {@link #startReadWrite(String, long)}. However, if the cache entry is locked, then + * instead of blocking, this method will return null as the {@link CacheSpan}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param key The key of the data being requested. + * @param position The position of the data being requested. + * @return The {@link CacheSpan}. Or null if the cache entry is locked. + * @throws CacheException If an error is encountered. + */ + @WorkerThread + @Nullable + CacheSpan startReadWriteNonBlocking(String key, long position) throws CacheException; + + /** + * Obtains a cache file into which data can be written. Must only be called when holding a + * corresponding hole {@link CacheSpan} obtained from {@link #startReadWrite(String, long)}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param key The cache key for the data. + * @param position The starting position of the data. + * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. Used + * only to ensure that there is enough space in the cache. + * @return The file into which data should be written. + * @throws CacheException If an error is encountered. + */ + @WorkerThread + File startFile(String key, long position, long length) throws CacheException; + + /** + * Commits a file into the cache. Must only be called when holding a corresponding hole {@link + * CacheSpan} obtained from {@link #startReadWrite(String, long)}. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param file A newly written cache file. + * @param length The length of the newly written cache file in bytes. + * @throws CacheException If an error is encountered. + */ + @WorkerThread + void commitFile(File file, long length) throws CacheException; + + /** + * Releases a {@link CacheSpan} obtained from {@link #startReadWrite(String, long)} which + * corresponded to a hole in the cache. + * + * @param holeSpan The {@link CacheSpan} being released. + */ + void releaseHoleSpan(CacheSpan holeSpan); + + /** + * Removes a cached {@link CacheSpan} from the cache, deleting the underlying file. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param span The {@link CacheSpan} to remove. + * @throws CacheException If an error is encountered. + */ + @WorkerThread + void removeSpan(CacheSpan span) throws CacheException; + + /** + * Queries if a range is entirely available in the cache. + * + * @param key The cache key for the data. + * @param position The starting position of the data. + * @param length The length of the data. + * @return true if the data is available in the Cache otherwise false; + */ + boolean isCached(String key, long position, long length); + + /** + * Returns the length of the cached data block starting from the {@code position} to the block end + * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap + * to the next cached data up to {@code length} bytes) is returned. + * + * @param key The cache key for the data. + * @param position The starting position of the data. + * @param length The maximum length of the data to be returned. + * @return The length of the cached or not cached data block length. + */ + long getCachedLength(String key, long position, long length); + + /** + * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link + * CachedContent} is added if there isn't one already with the given key. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param key The cache key for the data. + * @param mutations Contains mutations to be applied to the metadata. + * @throws CacheException If an error is encountered. + */ + @WorkerThread + void applyContentMetadataMutations(String key, ContentMetadataMutations mutations) + throws CacheException; + + /** + * Returns a {@link ContentMetadata} for the given key. + * + * @param key The cache key for the data. + * @return A {@link ContentMetadata} for the given key. + */ + ContentMetadata getContentMetadata(String key); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java new file mode 100644 index 0000000000..e372a02851 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSink.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ReusableBufferedOutputStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * Writes data into a cache. + * + * <p>If the {@link DataSpec} passed to {@link #open(DataSpec)} has the {@code length} field set to + * {@link C#LENGTH_UNSET} and {@link DataSpec#FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN} set, then {@link + * #write(byte[], int, int)} calls are ignored. + */ +public final class CacheDataSink implements DataSink { + + /** Default {@code fragmentSize} recommended for caching use cases. */ + public static final long DEFAULT_FRAGMENT_SIZE = 5 * 1024 * 1024; + /** Default buffer size in bytes. */ + public static final int DEFAULT_BUFFER_SIZE = 20 * 1024; + + private static final long MIN_RECOMMENDED_FRAGMENT_SIZE = 2 * 1024 * 1024; + private static final String TAG = "CacheDataSink"; + + private final Cache cache; + private final long fragmentSize; + private final int bufferSize; + + private DataSpec dataSpec; + private long dataSpecFragmentSize; + private File file; + private OutputStream outputStream; + private long outputStreamBytesWritten; + private long dataSpecBytesWritten; + private ReusableBufferedOutputStream bufferedOutputStream; + + /** + * Thrown when IOException is encountered when writing data into sink. + */ + public static class CacheDataSinkException extends CacheException { + + public CacheDataSinkException(IOException cause) { + super(cause); + } + + } + + /** + * Constructs an instance using {@link #DEFAULT_BUFFER_SIZE}. + * + * @param cache The cache into which data should be written. + * @param fragmentSize For requests that should be fragmented into multiple cache files, this is + * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no + * fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. + */ + public CacheDataSink(Cache cache, long fragmentSize) { + this(cache, fragmentSize, DEFAULT_BUFFER_SIZE); + } + + /** + * @param cache The cache into which data should be written. + * @param fragmentSize For requests that should be fragmented into multiple cache files, this is + * the maximum size of a cache file in bytes. If set to {@link C#LENGTH_UNSET} then no + * fragmentation will occur. Using a small value allows for finer-grained cache eviction + * policies, at the cost of increased overhead both on the cache implementation and the file + * system. Values under {@code (2 * 1024 * 1024)} are not recommended. + * @param bufferSize The buffer size in bytes for writing to a cache file. A zero or negative + * value disables buffering. + */ + public CacheDataSink(Cache cache, long fragmentSize, int bufferSize) { + Assertions.checkState( + fragmentSize > 0 || fragmentSize == C.LENGTH_UNSET, + "fragmentSize must be positive or C.LENGTH_UNSET."); + if (fragmentSize != C.LENGTH_UNSET && fragmentSize < MIN_RECOMMENDED_FRAGMENT_SIZE) { + Log.w( + TAG, + "fragmentSize is below the minimum recommended value of " + + MIN_RECOMMENDED_FRAGMENT_SIZE + + ". This may cause poor cache performance."); + } + this.cache = Assertions.checkNotNull(cache); + this.fragmentSize = fragmentSize == C.LENGTH_UNSET ? Long.MAX_VALUE : fragmentSize; + this.bufferSize = bufferSize; + } + + @Override + public void open(DataSpec dataSpec) throws CacheDataSinkException { + if (dataSpec.length == C.LENGTH_UNSET + && dataSpec.isFlagSet(DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN)) { + this.dataSpec = null; + return; + } + this.dataSpec = dataSpec; + this.dataSpecFragmentSize = + dataSpec.isFlagSet(DataSpec.FLAG_ALLOW_CACHE_FRAGMENTATION) ? fragmentSize : Long.MAX_VALUE; + dataSpecBytesWritten = 0; + try { + openNextOutputStream(); + } catch (IOException e) { + throw new CacheDataSinkException(e); + } + } + + @Override + public void write(byte[] buffer, int offset, int length) throws CacheDataSinkException { + if (dataSpec == null) { + return; + } + try { + int bytesWritten = 0; + while (bytesWritten < length) { + if (outputStreamBytesWritten == dataSpecFragmentSize) { + closeCurrentOutputStream(); + openNextOutputStream(); + } + int bytesToWrite = + (int) Math.min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten); + outputStream.write(buffer, offset + bytesWritten, bytesToWrite); + bytesWritten += bytesToWrite; + outputStreamBytesWritten += bytesToWrite; + dataSpecBytesWritten += bytesToWrite; + } + } catch (IOException e) { + throw new CacheDataSinkException(e); + } + } + + @Override + public void close() throws CacheDataSinkException { + if (dataSpec == null) { + return; + } + try { + closeCurrentOutputStream(); + } catch (IOException e) { + throw new CacheDataSinkException(e); + } + } + + private void openNextOutputStream() throws IOException { + long length = + dataSpec.length == C.LENGTH_UNSET + ? C.LENGTH_UNSET + : Math.min(dataSpec.length - dataSpecBytesWritten, dataSpecFragmentSize); + file = + cache.startFile( + dataSpec.key, dataSpec.absoluteStreamPosition + dataSpecBytesWritten, length); + FileOutputStream underlyingFileOutputStream = new FileOutputStream(file); + if (bufferSize > 0) { + if (bufferedOutputStream == null) { + bufferedOutputStream = new ReusableBufferedOutputStream(underlyingFileOutputStream, + bufferSize); + } else { + bufferedOutputStream.reset(underlyingFileOutputStream); + } + outputStream = bufferedOutputStream; + } else { + outputStream = underlyingFileOutputStream; + } + outputStreamBytesWritten = 0; + } + + private void closeCurrentOutputStream() throws IOException { + if (outputStream == null) { + return; + } + + boolean success = false; + try { + outputStream.flush(); + success = true; + } finally { + Util.closeQuietly(outputStream); + outputStream = null; + File fileToCommit = file; + file = null; + if (success) { + cache.commitFile(fileToCommit, outputStreamBytesWritten); + } else { + fileToCommit.delete(); + } + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java new file mode 100644 index 0000000000..51ba6f4294 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSinkFactory.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink; + +/** + * A {@link DataSink.Factory} that produces {@link CacheDataSink}. + */ +public final class CacheDataSinkFactory implements DataSink.Factory { + + private final Cache cache; + private final long fragmentSize; + private final int bufferSize; + + /** @see CacheDataSink#CacheDataSink(Cache, long) */ + public CacheDataSinkFactory(Cache cache, long fragmentSize) { + this(cache, fragmentSize, CacheDataSink.DEFAULT_BUFFER_SIZE); + } + + /** @see CacheDataSink#CacheDataSink(Cache, long, int) */ + public CacheDataSinkFactory(Cache cache, long fragmentSize, int bufferSize) { + this.cache = cache; + this.fragmentSize = fragmentSize; + this.bufferSize = bufferSize; + } + + @Override + public DataSink createDataSink() { + return new CacheDataSink(cache, fragmentSize, bufferSize); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java new file mode 100644 index 0000000000..19fb8191e4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSource.java @@ -0,0 +1,580 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.net.Uri; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.FileDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TeeDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * A {@link DataSource} that reads and writes a {@link Cache}. Requests are fulfilled from the cache + * when possible. When data is not cached it is requested from an upstream {@link DataSource} and + * written into the cache. + */ +public final class CacheDataSource implements DataSource { + + /** + * Flags controlling the CacheDataSource's behavior. Possible flag values are {@link + * #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} and {@link + * #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FLAG_BLOCK_ON_CACHE, + FLAG_IGNORE_CACHE_ON_ERROR, + FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS + }) + public @interface Flags {} + /** + * A flag indicating whether we will block reads if the cache key is locked. If unset then data is + * read from upstream if the cache key is locked, regardless of whether the data is cached. + */ + public static final int FLAG_BLOCK_ON_CACHE = 1; + + /** + * A flag indicating whether the cache is bypassed following any cache related error. If set + * then cache related exceptions may be thrown for one cycle of open, read and close calls. + * Subsequent cycles of these calls will then bypass the cache. + */ + public static final int FLAG_IGNORE_CACHE_ON_ERROR = 1 << 1; // 2 + + /** + * A flag indicating that the cache should be bypassed for requests whose lengths are unset. This + * flag is provided for legacy reasons only. + */ + public static final int FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS = 1 << 2; // 4 + + /** + * Reasons the cache may be ignored. One of {@link #CACHE_IGNORED_REASON_ERROR} or {@link + * #CACHE_IGNORED_REASON_UNSET_LENGTH}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({CACHE_IGNORED_REASON_ERROR, CACHE_IGNORED_REASON_UNSET_LENGTH}) + public @interface CacheIgnoredReason {} + + /** Cache not ignored. */ + private static final int CACHE_NOT_IGNORED = -1; + + /** Cache ignored due to a cache related error. */ + public static final int CACHE_IGNORED_REASON_ERROR = 0; + + /** Cache ignored due to a request with an unset length. */ + public static final int CACHE_IGNORED_REASON_UNSET_LENGTH = 1; + + /** + * Listener of {@link CacheDataSource} events. + */ + public interface EventListener { + + /** + * Called when bytes have been read from the cache. + * + * @param cacheSizeBytes Current cache size in bytes. + * @param cachedBytesRead Total bytes read from the cache since this method was last called. + */ + void onCachedBytesRead(long cacheSizeBytes, long cachedBytesRead); + + /** + * Called when the current request ignores cache. + * + * @param reason Reason cache is bypassed. + */ + void onCacheIgnored(@CacheIgnoredReason int reason); + } + + /** Minimum number of bytes to read before checking cache for availability. */ + private static final long MIN_READ_BEFORE_CHECKING_CACHE = 100 * 1024; + + private final Cache cache; + private final DataSource cacheReadDataSource; + @Nullable private final DataSource cacheWriteDataSource; + private final DataSource upstreamDataSource; + private final CacheKeyFactory cacheKeyFactory; + @Nullable private final EventListener eventListener; + + private final boolean blockOnCache; + private final boolean ignoreCacheOnError; + private final boolean ignoreCacheForUnsetLengthRequests; + + @Nullable private DataSource currentDataSource; + private boolean currentDataSpecLengthUnset; + @Nullable private Uri uri; + @Nullable private Uri actualUri; + @HttpMethod private int httpMethod; + @Nullable private byte[] httpBody; + private Map<String, String> httpRequestHeaders = Collections.emptyMap(); + @DataSpec.Flags private int flags; + @Nullable private String key; + private long readPosition; + private long bytesRemaining; + @Nullable private CacheSpan currentHoleSpan; + private boolean seenCacheError; + private boolean currentRequestIgnoresCache; + private long totalCachedBytesRead; + private long checkCachePosition; + + /** + * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. + * + * @param cache The cache. + * @param upstream A {@link DataSource} for reading data not in the cache. + */ + public CacheDataSource(Cache cache, DataSource upstream) { + this(cache, upstream, /* flags= */ 0); + } + + /** + * Constructs an instance with default {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. + * + * @param cache The cache. + * @param upstream A {@link DataSource} for reading data not in the cache. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} + * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. + */ + public CacheDataSource(Cache cache, DataSource upstream, @Flags int flags) { + this( + cache, + upstream, + new FileDataSource(), + new CacheDataSink(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), + flags, + /* eventListener= */ null); + } + + /** + * Constructs an instance with arbitrary {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. One use of this constructor is to allow data to be transformed + * before it is written to disk. + * + * @param cache The cache. + * @param upstream A {@link DataSource} for reading data not in the cache. + * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. + * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is + * accessed read-only. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} + * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. + * @param eventListener An optional {@link EventListener} to receive events. + */ + public CacheDataSource( + Cache cache, + DataSource upstream, + DataSource cacheReadDataSource, + @Nullable DataSink cacheWriteDataSink, + @Flags int flags, + @Nullable EventListener eventListener) { + this( + cache, + upstream, + cacheReadDataSource, + cacheWriteDataSink, + flags, + eventListener, + /* cacheKeyFactory= */ null); + } + + /** + * Constructs an instance with arbitrary {@link DataSource} and {@link DataSink} instances for + * reading and writing the cache. One use of this constructor is to allow data to be transformed + * before it is written to disk. + * + * @param cache The cache. + * @param upstream A {@link DataSource} for reading data not in the cache. + * @param cacheReadDataSource A {@link DataSource} for reading data from the cache. + * @param cacheWriteDataSink A {@link DataSink} for writing data to the cache. If null, cache is + * accessed read-only. + * @param flags A combination of {@link #FLAG_BLOCK_ON_CACHE}, {@link #FLAG_IGNORE_CACHE_ON_ERROR} + * and {@link #FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS}, or 0. + * @param eventListener An optional {@link EventListener} to receive events. + * @param cacheKeyFactory An optional factory for cache keys. + */ + public CacheDataSource( + Cache cache, + DataSource upstream, + DataSource cacheReadDataSource, + @Nullable DataSink cacheWriteDataSink, + @Flags int flags, + @Nullable EventListener eventListener, + @Nullable CacheKeyFactory cacheKeyFactory) { + this.cache = cache; + this.cacheReadDataSource = cacheReadDataSource; + this.cacheKeyFactory = + cacheKeyFactory != null ? cacheKeyFactory : CacheUtil.DEFAULT_CACHE_KEY_FACTORY; + this.blockOnCache = (flags & FLAG_BLOCK_ON_CACHE) != 0; + this.ignoreCacheOnError = (flags & FLAG_IGNORE_CACHE_ON_ERROR) != 0; + this.ignoreCacheForUnsetLengthRequests = + (flags & FLAG_IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS) != 0; + this.upstreamDataSource = upstream; + if (cacheWriteDataSink != null) { + this.cacheWriteDataSource = new TeeDataSource(upstream, cacheWriteDataSink); + } else { + this.cacheWriteDataSource = null; + } + this.eventListener = eventListener; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + cacheReadDataSource.addTransferListener(transferListener); + upstreamDataSource.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + try { + key = cacheKeyFactory.buildCacheKey(dataSpec); + uri = dataSpec.uri; + actualUri = getRedirectedUriOrDefault(cache, key, /* defaultUri= */ uri); + httpMethod = dataSpec.httpMethod; + httpBody = dataSpec.httpBody; + httpRequestHeaders = dataSpec.httpRequestHeaders; + flags = dataSpec.flags; + readPosition = dataSpec.position; + + int reason = shouldIgnoreCacheForRequest(dataSpec); + currentRequestIgnoresCache = reason != CACHE_NOT_IGNORED; + if (currentRequestIgnoresCache) { + notifyCacheIgnored(reason); + } + + if (dataSpec.length != C.LENGTH_UNSET || currentRequestIgnoresCache) { + bytesRemaining = dataSpec.length; + } else { + bytesRemaining = ContentMetadata.getContentLength(cache.getContentMetadata(key)); + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= dataSpec.position; + if (bytesRemaining <= 0) { + throw new DataSourceException(DataSourceException.POSITION_OUT_OF_RANGE); + } + } + } + openNextSource(false); + return bytesRemaining; + } catch (Throwable e) { + handleBeforeThrow(e); + throw e; + } + } + + @Override + public int read(byte[] buffer, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + try { + if (readPosition >= checkCachePosition) { + openNextSource(true); + } + int bytesRead = currentDataSource.read(buffer, offset, readLength); + if (bytesRead != C.RESULT_END_OF_INPUT) { + if (isReadingFromCache()) { + totalCachedBytesRead += bytesRead; + } + readPosition += bytesRead; + if (bytesRemaining != C.LENGTH_UNSET) { + bytesRemaining -= bytesRead; + } + } else if (currentDataSpecLengthUnset) { + setNoBytesRemainingAndMaybeStoreLength(); + } else if (bytesRemaining > 0 || bytesRemaining == C.LENGTH_UNSET) { + closeCurrentSource(); + openNextSource(false); + return read(buffer, offset, readLength); + } + return bytesRead; + } catch (IOException e) { + if (currentDataSpecLengthUnset && CacheUtil.isCausedByPositionOutOfRange(e)) { + setNoBytesRemainingAndMaybeStoreLength(); + return C.RESULT_END_OF_INPUT; + } + handleBeforeThrow(e); + throw e; + } catch (Throwable e) { + handleBeforeThrow(e); + throw e; + } + } + + @Override + @Nullable + public Uri getUri() { + return actualUri; + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + // TODO: Implement. + return isReadingFromUpstream() + ? upstreamDataSource.getResponseHeaders() + : Collections.emptyMap(); + } + + @Override + public void close() throws IOException { + uri = null; + actualUri = null; + httpMethod = DataSpec.HTTP_METHOD_GET; + httpBody = null; + httpRequestHeaders = Collections.emptyMap(); + flags = 0; + readPosition = 0; + key = null; + notifyBytesRead(); + try { + closeCurrentSource(); + } catch (Throwable e) { + handleBeforeThrow(e); + throw e; + } + } + + /** + * Opens the next source. If the cache contains data spanning the current read position then + * {@link #cacheReadDataSource} is opened to read from it. Else {@link #upstreamDataSource} is + * opened to read from the upstream source and write into the cache. + * + * <p>There must not be a currently open source when this method is called, except in the case + * that {@code checkCache} is true. If {@code checkCache} is true then there must be a currently + * open source, and it must be {@link #upstreamDataSource}. It will be closed and a new source + * opened if it's possible to switch to reading from or writing to the cache. If a switch isn't + * possible then the current source is left unchanged. + * + * @param checkCache If true tries to switch to reading from or writing to cache instead of + * reading from {@link #upstreamDataSource}, which is the currently open source. + */ + private void openNextSource(boolean checkCache) throws IOException { + CacheSpan nextSpan; + if (currentRequestIgnoresCache) { + nextSpan = null; + } else if (blockOnCache) { + try { + nextSpan = cache.startReadWrite(key, readPosition); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new InterruptedIOException(); + } + } else { + nextSpan = cache.startReadWriteNonBlocking(key, readPosition); + } + + DataSpec nextDataSpec; + DataSource nextDataSource; + if (nextSpan == null) { + // The data is locked in the cache, or we're ignoring the cache. Bypass the cache and read + // from upstream. + nextDataSource = upstreamDataSource; + nextDataSpec = + new DataSpec( + uri, + httpMethod, + httpBody, + readPosition, + readPosition, + bytesRemaining, + key, + flags, + httpRequestHeaders); + } else if (nextSpan.isCached) { + // Data is cached, read from cache. + Uri fileUri = Uri.fromFile(nextSpan.file); + long filePosition = readPosition - nextSpan.position; + long length = nextSpan.length - filePosition; + if (bytesRemaining != C.LENGTH_UNSET) { + length = Math.min(length, bytesRemaining); + } + // Deliberately skip the HTTP-related parameters since we're reading from the cache, not + // making an HTTP request. + nextDataSpec = new DataSpec(fileUri, readPosition, filePosition, length, key, flags); + nextDataSource = cacheReadDataSource; + } else { + // Data is not cached, and data is not locked, read from upstream with cache backing. + long length; + if (nextSpan.isOpenEnded()) { + length = bytesRemaining; + } else { + length = nextSpan.length; + if (bytesRemaining != C.LENGTH_UNSET) { + length = Math.min(length, bytesRemaining); + } + } + nextDataSpec = + new DataSpec( + uri, + httpMethod, + httpBody, + readPosition, + readPosition, + length, + key, + flags, + httpRequestHeaders); + if (cacheWriteDataSource != null) { + nextDataSource = cacheWriteDataSource; + } else { + nextDataSource = upstreamDataSource; + cache.releaseHoleSpan(nextSpan); + nextSpan = null; + } + } + + checkCachePosition = + !currentRequestIgnoresCache && nextDataSource == upstreamDataSource + ? readPosition + MIN_READ_BEFORE_CHECKING_CACHE + : Long.MAX_VALUE; + if (checkCache) { + Assertions.checkState(isBypassingCache()); + if (nextDataSource == upstreamDataSource) { + // Continue reading from upstream. + return; + } + // We're switching to reading from or writing to the cache. + try { + closeCurrentSource(); + } catch (Throwable e) { + if (nextSpan.isHoleSpan()) { + // Release the hole span before throwing, else we'll hold it forever. + cache.releaseHoleSpan(nextSpan); + } + throw e; + } + } + + if (nextSpan != null && nextSpan.isHoleSpan()) { + currentHoleSpan = nextSpan; + } + currentDataSource = nextDataSource; + currentDataSpecLengthUnset = nextDataSpec.length == C.LENGTH_UNSET; + long resolvedLength = nextDataSource.open(nextDataSpec); + + // Update bytesRemaining, actualUri and (if writing to cache) the cache metadata. + ContentMetadataMutations mutations = new ContentMetadataMutations(); + if (currentDataSpecLengthUnset && resolvedLength != C.LENGTH_UNSET) { + bytesRemaining = resolvedLength; + ContentMetadataMutations.setContentLength(mutations, readPosition + bytesRemaining); + } + if (isReadingFromUpstream()) { + actualUri = currentDataSource.getUri(); + boolean isRedirected = !uri.equals(actualUri); + ContentMetadataMutations.setRedirectedUri(mutations, isRedirected ? actualUri : null); + } + if (isWritingToCache()) { + cache.applyContentMetadataMutations(key, mutations); + } + } + + private void setNoBytesRemainingAndMaybeStoreLength() throws IOException { + bytesRemaining = 0; + if (isWritingToCache()) { + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setContentLength(mutations, readPosition); + cache.applyContentMetadataMutations(key, mutations); + } + } + + private static Uri getRedirectedUriOrDefault(Cache cache, String key, Uri defaultUri) { + Uri redirectedUri = ContentMetadata.getRedirectedUri(cache.getContentMetadata(key)); + return redirectedUri != null ? redirectedUri : defaultUri; + } + + private boolean isReadingFromUpstream() { + return !isReadingFromCache(); + } + + private boolean isBypassingCache() { + return currentDataSource == upstreamDataSource; + } + + private boolean isReadingFromCache() { + return currentDataSource == cacheReadDataSource; + } + + private boolean isWritingToCache() { + return currentDataSource == cacheWriteDataSource; + } + + private void closeCurrentSource() throws IOException { + if (currentDataSource == null) { + return; + } + try { + currentDataSource.close(); + } finally { + currentDataSource = null; + currentDataSpecLengthUnset = false; + if (currentHoleSpan != null) { + cache.releaseHoleSpan(currentHoleSpan); + currentHoleSpan = null; + } + } + } + + private void handleBeforeThrow(Throwable exception) { + if (isReadingFromCache() || exception instanceof CacheException) { + seenCacheError = true; + } + } + + private int shouldIgnoreCacheForRequest(DataSpec dataSpec) { + if (ignoreCacheOnError && seenCacheError) { + return CACHE_IGNORED_REASON_ERROR; + } else if (ignoreCacheForUnsetLengthRequests && dataSpec.length == C.LENGTH_UNSET) { + return CACHE_IGNORED_REASON_UNSET_LENGTH; + } else { + return CACHE_NOT_IGNORED; + } + } + + private void notifyCacheIgnored(@CacheIgnoredReason int reason) { + if (eventListener != null) { + eventListener.onCacheIgnored(reason); + } + } + + private void notifyBytesRead() { + if (eventListener != null && totalCachedBytesRead > 0) { + eventListener.onCachedBytesRead(cache.getCacheSpace(), totalCachedBytesRead); + totalCachedBytesRead = 0; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java new file mode 100644 index 0000000000..21aef3f93a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheDataSourceFactory.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.FileDataSource; + +/** A {@link DataSource.Factory} that produces {@link CacheDataSource}. */ +public final class CacheDataSourceFactory implements DataSource.Factory { + + private final Cache cache; + private final DataSource.Factory upstreamFactory; + private final DataSource.Factory cacheReadDataSourceFactory; + @CacheDataSource.Flags private final int flags; + @Nullable private final DataSink.Factory cacheWriteDataSinkFactory; + @Nullable private final CacheDataSource.EventListener eventListener; + @Nullable private final CacheKeyFactory cacheKeyFactory; + + /** + * Constructs a factory which creates {@link CacheDataSource} instances with default {@link + * DataSource} and {@link DataSink} instances for reading and writing the cache. + * + * @param cache The cache. + * @param upstreamFactory A {@link DataSource.Factory} for creating upstream {@link DataSource}s + * for reading data not in the cache. + */ + public CacheDataSourceFactory(Cache cache, DataSource.Factory upstreamFactory) { + this(cache, upstreamFactory, /* flags= */ 0); + } + + /** @see CacheDataSource#CacheDataSource(Cache, DataSource, int) */ + public CacheDataSourceFactory( + Cache cache, DataSource.Factory upstreamFactory, @CacheDataSource.Flags int flags) { + this( + cache, + upstreamFactory, + new FileDataSource.Factory(), + new CacheDataSinkFactory(cache, CacheDataSink.DEFAULT_FRAGMENT_SIZE), + flags, + /* eventListener= */ null); + } + + /** + * @see CacheDataSource#CacheDataSource(Cache, DataSource, DataSource, DataSink, int, + * CacheDataSource.EventListener) + */ + public CacheDataSourceFactory( + Cache cache, + DataSource.Factory upstreamFactory, + DataSource.Factory cacheReadDataSourceFactory, + @Nullable DataSink.Factory cacheWriteDataSinkFactory, + @CacheDataSource.Flags int flags, + @Nullable CacheDataSource.EventListener eventListener) { + this( + cache, + upstreamFactory, + cacheReadDataSourceFactory, + cacheWriteDataSinkFactory, + flags, + eventListener, + /* cacheKeyFactory= */ null); + } + + /** + * @see CacheDataSource#CacheDataSource(Cache, DataSource, DataSource, DataSink, int, + * CacheDataSource.EventListener, CacheKeyFactory) + */ + public CacheDataSourceFactory( + Cache cache, + DataSource.Factory upstreamFactory, + DataSource.Factory cacheReadDataSourceFactory, + @Nullable DataSink.Factory cacheWriteDataSinkFactory, + @CacheDataSource.Flags int flags, + @Nullable CacheDataSource.EventListener eventListener, + @Nullable CacheKeyFactory cacheKeyFactory) { + this.cache = cache; + this.upstreamFactory = upstreamFactory; + this.cacheReadDataSourceFactory = cacheReadDataSourceFactory; + this.cacheWriteDataSinkFactory = cacheWriteDataSinkFactory; + this.flags = flags; + this.eventListener = eventListener; + this.cacheKeyFactory = cacheKeyFactory; + } + + @Override + public CacheDataSource createDataSource() { + return new CacheDataSource( + cache, + upstreamFactory.createDataSource(), + cacheReadDataSourceFactory.createDataSource(), + cacheWriteDataSinkFactory == null ? null : cacheWriteDataSinkFactory.createDataSink(), + flags, + eventListener, + cacheKeyFactory); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java new file mode 100644 index 0000000000..017e84c8c8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheEvictor.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** + * Evicts data from a {@link Cache}. Implementations should call {@link Cache#removeSpan(CacheSpan)} + * to evict cache entries based on their eviction policies. + */ +public interface CacheEvictor extends Cache.Listener { + + /** + * Returns whether the evictor requires the {@link Cache} to touch {@link CacheSpan CacheSpans} + * when it accesses them. Implementations that do not use {@link CacheSpan#lastTouchTimestamp} + * should return {@code false}. + */ + boolean requiresCacheSpanTouches(); + + /** + * Called when cache has been initialized. + */ + void onCacheInitialized(); + + /** + * Called when a writer starts writing to the cache. + * + * @param cache The source of the event. + * @param key The key being written. + * @param position The starting position of the data being written. + * @param length The length of the data being written, or {@link C#LENGTH_UNSET} if unknown. + */ + void onStartFile(Cache cache, String key, long position, long length); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java new file mode 100644 index 0000000000..2618a3ef6a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadata.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +/** Metadata associated with a cache file. */ +/* package */ final class CacheFileMetadata { + + public final long length; + public final long lastTouchTimestamp; + + public CacheFileMetadata(long length, long lastTouchTimestamp) { + this.length = length; + this.lastTouchTimestamp = lastTouchTimestamp; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java new file mode 100644 index 0000000000..cd69336ff4 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheFileMetadataIndex.java @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import androidx.annotation.WorkerThread; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** Maintains an index of cache file metadata. */ +/* package */ final class CacheFileMetadataIndex { + + private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "CacheFileMetadata"; + private static final int TABLE_VERSION = 1; + + private static final String COLUMN_NAME = "name"; + private static final String COLUMN_LENGTH = "length"; + private static final String COLUMN_LAST_TOUCH_TIMESTAMP = "last_touch_timestamp"; + + private static final int COLUMN_INDEX_NAME = 0; + private static final int COLUMN_INDEX_LENGTH = 1; + private static final int COLUMN_INDEX_LAST_TOUCH_TIMESTAMP = 2; + + private static final String WHERE_NAME_EQUALS = COLUMN_NAME + " = ?"; + + private static final String[] COLUMNS = + new String[] { + COLUMN_NAME, COLUMN_LENGTH, COLUMN_LAST_TOUCH_TIMESTAMP, + }; + private static final String TABLE_SCHEMA = + "(" + + COLUMN_NAME + + " TEXT PRIMARY KEY NOT NULL," + + COLUMN_LENGTH + + " INTEGER NOT NULL," + + COLUMN_LAST_TOUCH_TIMESTAMP + + " INTEGER NOT NULL)"; + + private final DatabaseProvider databaseProvider; + + private @MonotonicNonNull String tableName; + + /** + * Deletes index data for the specified cache. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param databaseProvider Provides the database in which the index is stored. + * @param uid The cache UID. + * @throws DatabaseIOException If an error occurs deleting the index data. + */ + @WorkerThread + public static void delete(DatabaseProvider databaseProvider, long uid) + throws DatabaseIOException { + String hexUid = Long.toHexString(uid); + try { + String tableName = getTableName(hexUid); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + VersionTable.removeVersion( + writableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, hexUid); + dropTable(writableDatabase, tableName); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** @param databaseProvider Provides the database in which the index is stored. */ + public CacheFileMetadataIndex(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + /** + * Initializes the index for the given cache UID. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param uid The cache UID. + * @throws DatabaseIOException If an error occurs initializing the index. + */ + @WorkerThread + public void initialize(long uid) throws DatabaseIOException { + try { + String hexUid = Long.toHexString(uid); + tableName = getTableName(hexUid); + SQLiteDatabase readableDatabase = databaseProvider.getReadableDatabase(); + int version = + VersionTable.getVersion( + readableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, hexUid); + if (version != TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + VersionTable.setVersion( + writableDatabase, VersionTable.FEATURE_CACHE_FILE_METADATA, hexUid, TABLE_VERSION); + dropTable(writableDatabase, tableName); + writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** + * Returns all file metadata keyed by file name. The returned map is mutable and may be modified + * by the caller. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @return The file metadata keyed by file name. + * @throws DatabaseIOException If an error occurs loading the metadata. + */ + @WorkerThread + public Map<String, CacheFileMetadata> getAll() throws DatabaseIOException { + try (Cursor cursor = getCursor()) { + Map<String, CacheFileMetadata> fileMetadata = new HashMap<>(cursor.getCount()); + while (cursor.moveToNext()) { + String name = cursor.getString(COLUMN_INDEX_NAME); + long length = cursor.getLong(COLUMN_INDEX_LENGTH); + long lastTouchTimestamp = cursor.getLong(COLUMN_INDEX_LAST_TOUCH_TIMESTAMP); + fileMetadata.put(name, new CacheFileMetadata(length, lastTouchTimestamp)); + } + return fileMetadata; + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** + * Sets metadata for a given file. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param name The name of the file. + * @param length The file length. + * @param lastTouchTimestamp The file last touch timestamp. + * @throws DatabaseIOException If an error occurs setting the metadata. + */ + @WorkerThread + public void set(String name, long length, long lastTouchTimestamp) throws DatabaseIOException { + Assertions.checkNotNull(tableName); + try { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + ContentValues values = new ContentValues(); + values.put(COLUMN_NAME, name); + values.put(COLUMN_LENGTH, length); + values.put(COLUMN_LAST_TOUCH_TIMESTAMP, lastTouchTimestamp); + writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** + * Removes metadata. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param name The name of the file whose metadata is to be removed. + * @throws DatabaseIOException If an error occurs removing the metadata. + */ + @WorkerThread + public void remove(String name) throws DatabaseIOException { + Assertions.checkNotNull(tableName); + try { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.delete(tableName, WHERE_NAME_EQUALS, new String[] {name}); + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + /** + * Removes metadata. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param names The names of the files whose metadata is to be removed. + * @throws DatabaseIOException If an error occurs removing the metadata. + */ + @WorkerThread + public void removeAll(Set<String> names) throws DatabaseIOException { + Assertions.checkNotNull(tableName); + try { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + for (String name : names) { + writableDatabase.delete(tableName, WHERE_NAME_EQUALS, new String[] {name}); + } + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + private Cursor getCursor() { + Assertions.checkNotNull(tableName); + return databaseProvider + .getReadableDatabase() + .query( + tableName, + COLUMNS, + /* selection */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null); + } + + private static void dropTable(SQLiteDatabase writableDatabase, String tableName) { + writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName); + } + + private static String getTableName(String hexUid) { + return TABLE_PREFIX + hexUid; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java new file mode 100644 index 0000000000..1c30a4b03e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheKeyFactory.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; + +/** Factory for cache keys. */ +public interface CacheKeyFactory { + + /** + * Returns a cache key for the given {@link DataSpec}. + * + * @param dataSpec The data being cached. + */ + String buildCacheKey(DataSpec dataSpec); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java new file mode 100644 index 0000000000..f57544f12b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheSpan.java @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.io.File; + +/** + * Defines a span of data that may or may not be cached (as indicated by {@link #isCached}). + */ +public class CacheSpan implements Comparable<CacheSpan> { + + /** + * The cache key that uniquely identifies the original stream. + */ + public final String key; + /** + * The position of the {@link CacheSpan} in the original stream. + */ + public final long position; + /** + * The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an open-ended hole. + */ + public final long length; + /** + * Whether the {@link CacheSpan} is cached. + */ + public final boolean isCached; + /** The file corresponding to this {@link CacheSpan}, or null if {@link #isCached} is false. */ + @Nullable public final File file; + /** The last touch timestamp, or {@link C#TIME_UNSET} if {@link #isCached} is false. */ + public final long lastTouchTimestamp; + + /** + * Creates a hole CacheSpan which isn't cached, has no last touch timestamp and no file + * associated. + * + * @param key The cache key that uniquely identifies the original stream. + * @param position The position of the {@link CacheSpan} in the original stream. + * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an + * open-ended hole. + */ + public CacheSpan(String key, long position, long length) { + this(key, position, length, C.TIME_UNSET, null); + } + + /** + * Creates a CacheSpan. + * + * @param key The cache key that uniquely identifies the original stream. + * @param position The position of the {@link CacheSpan} in the original stream. + * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an + * open-ended hole. + * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link + * #isCached} is false. + * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole. + */ + public CacheSpan( + String key, long position, long length, long lastTouchTimestamp, @Nullable File file) { + this.key = key; + this.position = position; + this.length = length; + this.isCached = file != null; + this.file = file; + this.lastTouchTimestamp = lastTouchTimestamp; + } + + /** + * Returns whether this is an open-ended {@link CacheSpan}. + */ + public boolean isOpenEnded() { + return length == C.LENGTH_UNSET; + } + + /** + * Returns whether this is a hole {@link CacheSpan}. + */ + public boolean isHoleSpan() { + return !isCached; + } + + @Override + public int compareTo(@NonNull CacheSpan another) { + if (!key.equals(another.key)) { + return key.compareTo(another.key); + } + long startOffsetDiff = position - another.position; + return startOffsetDiff == 0 ? 0 : ((startOffsetDiff < 0) ? -1 : 1); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java new file mode 100644 index 0000000000..01fef2b605 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CacheUtil.java @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.net.Uri; +import android.util.Pair; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSourceException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.PriorityTaskManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.EOFException; +import java.io.IOException; +import java.util.NavigableSet; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Caching related utility methods. + */ +public final class CacheUtil { + + /** Receives progress updates during cache operations. */ + public interface ProgressListener { + + /** + * Called when progress is made during a cache operation. + * + * @param requestLength The length of the content being cached in bytes, or {@link + * C#LENGTH_UNSET} if unknown. + * @param bytesCached The number of bytes that are cached. + * @param newBytesCached The number of bytes that have been newly cached since the last progress + * update. + */ + void onProgress(long requestLength, long bytesCached, long newBytesCached); + } + + /** Default buffer size to be used while caching. */ + public static final int DEFAULT_BUFFER_SIZE_BYTES = 128 * 1024; + + /** Default {@link CacheKeyFactory}. */ + public static final CacheKeyFactory DEFAULT_CACHE_KEY_FACTORY = + (dataSpec) -> dataSpec.key != null ? dataSpec.key : generateKey(dataSpec.uri); + + /** + * Generates a cache key out of the given {@link Uri}. + * + * @param uri Uri of a content which the requested key is for. + */ + public static String generateKey(Uri uri) { + return uri.toString(); + } + + /** + * Queries the cache to obtain the request length and the number of bytes already cached for a + * given {@link DataSpec}. + * + * @param dataSpec Defines the data to be checked. + * @param cache A {@link Cache} which has the data. + * @param cacheKeyFactory An optional factory for cache keys. + * @return A pair containing the request length and the number of bytes that are already cached. + */ + public static Pair<Long, Long> getCached( + DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) { + String key = buildCacheKey(dataSpec, cacheKeyFactory); + long position = dataSpec.absoluteStreamPosition; + long requestLength = getRequestLength(dataSpec, cache, key); + long bytesAlreadyCached = 0; + long bytesLeft = requestLength; + while (bytesLeft != 0) { + long blockLength = + cache.getCachedLength( + key, position, bytesLeft != C.LENGTH_UNSET ? bytesLeft : Long.MAX_VALUE); + if (blockLength > 0) { + bytesAlreadyCached += blockLength; + } else { + blockLength = -blockLength; + if (blockLength == Long.MAX_VALUE) { + break; + } + } + position += blockLength; + bytesLeft -= bytesLeft == C.LENGTH_UNSET ? 0 : blockLength; + } + return Pair.create(requestLength, bytesAlreadyCached); + } + + /** + * Caches the data defined by {@code dataSpec}, skipping already cached data. Caching stops early + * if the end of the input is reached. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param dataSpec Defines the data to be cached. + * @param cache A {@link Cache} to store the data. + * @param cacheKeyFactory An optional factory for cache keys. + * @param upstream A {@link DataSource} for reading data not in the cache. + * @param progressListener A listener to receive progress updates, or {@code null}. + * @param isCanceled An optional flag that will interrupt caching if set to true. + * @throws IOException If an error occurs reading from the source. + * @throws InterruptedException If the thread was interrupted directly or via {@code isCanceled}. + */ + @WorkerThread + public static void cache( + DataSpec dataSpec, + Cache cache, + @Nullable CacheKeyFactory cacheKeyFactory, + DataSource upstream, + @Nullable ProgressListener progressListener, + @Nullable AtomicBoolean isCanceled) + throws IOException, InterruptedException { + cache( + dataSpec, + cache, + cacheKeyFactory, + new CacheDataSource(cache, upstream), + new byte[DEFAULT_BUFFER_SIZE_BYTES], + /* priorityTaskManager= */ null, + /* priority= */ 0, + progressListener, + isCanceled, + /* enableEOFException= */ false); + } + + /** + * Caches the data defined by {@code dataSpec} while skipping already cached data. Caching stops + * early if end of input is reached and {@code enableEOFException} is false. + * + * <p>If a {@link PriorityTaskManager} is given, it's used to pause and resume caching depending + * on {@code priority} and the priority of other tasks registered to the PriorityTaskManager. + * Please note that it's the responsibility of the calling code to call {@link + * PriorityTaskManager#add} to register with the manager before calling this method, and to call + * {@link PriorityTaskManager#remove} afterwards to unregister. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param dataSpec Defines the data to be cached. + * @param cache A {@link Cache} to store the data. + * @param cacheKeyFactory An optional factory for cache keys. + * @param dataSource A {@link CacheDataSource} that works on the {@code cache}. + * @param buffer The buffer to be used while caching. + * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with + * caching. + * @param priority The priority of this task. Used with {@code priorityTaskManager}. + * @param progressListener A listener to receive progress updates, or {@code null}. + * @param isCanceled An optional flag that will interrupt caching if set to true. + * @param enableEOFException Whether to throw an {@link EOFException} if end of input has been + * reached unexpectedly. + * @throws IOException If an error occurs reading from the source. + * @throws InterruptedException If the thread was interrupted directly or via {@code isCanceled}. + */ + @WorkerThread + public static void cache( + DataSpec dataSpec, + Cache cache, + @Nullable CacheKeyFactory cacheKeyFactory, + CacheDataSource dataSource, + byte[] buffer, + @Nullable PriorityTaskManager priorityTaskManager, + int priority, + @Nullable ProgressListener progressListener, + @Nullable AtomicBoolean isCanceled, + boolean enableEOFException) + throws IOException, InterruptedException { + Assertions.checkNotNull(dataSource); + Assertions.checkNotNull(buffer); + + String key = buildCacheKey(dataSpec, cacheKeyFactory); + long bytesLeft; + ProgressNotifier progressNotifier = null; + if (progressListener != null) { + progressNotifier = new ProgressNotifier(progressListener); + Pair<Long, Long> lengthAndBytesAlreadyCached = getCached(dataSpec, cache, cacheKeyFactory); + progressNotifier.init(lengthAndBytesAlreadyCached.first, lengthAndBytesAlreadyCached.second); + bytesLeft = lengthAndBytesAlreadyCached.first; + } else { + bytesLeft = getRequestLength(dataSpec, cache, key); + } + + long position = dataSpec.absoluteStreamPosition; + boolean lengthUnset = bytesLeft == C.LENGTH_UNSET; + while (bytesLeft != 0) { + throwExceptionIfInterruptedOrCancelled(isCanceled); + long blockLength = + cache.getCachedLength(key, position, lengthUnset ? Long.MAX_VALUE : bytesLeft); + if (blockLength > 0) { + // Skip already cached data. + } else { + // There is a hole in the cache which is at least "-blockLength" long. + blockLength = -blockLength; + long length = blockLength == Long.MAX_VALUE ? C.LENGTH_UNSET : blockLength; + boolean isLastBlock = length == bytesLeft; + long read = + readAndDiscard( + dataSpec, + position, + length, + dataSource, + buffer, + priorityTaskManager, + priority, + progressNotifier, + isLastBlock, + isCanceled); + if (read < blockLength) { + // Reached to the end of the data. + if (enableEOFException && !lengthUnset) { + throw new EOFException(); + } + break; + } + } + position += blockLength; + if (!lengthUnset) { + bytesLeft -= blockLength; + } + } + } + + private static long getRequestLength(DataSpec dataSpec, Cache cache, String key) { + if (dataSpec.length != C.LENGTH_UNSET) { + return dataSpec.length; + } else { + long contentLength = ContentMetadata.getContentLength(cache.getContentMetadata(key)); + return contentLength == C.LENGTH_UNSET + ? C.LENGTH_UNSET + : contentLength - dataSpec.absoluteStreamPosition; + } + } + + /** + * Reads and discards all data specified by the {@code dataSpec}. + * + * @param dataSpec Defines the data to be read. {@code absoluteStreamPosition} and {@code length} + * fields are overwritten by the following parameters. + * @param absoluteStreamPosition The absolute position of the data to be read. + * @param length Length of the data to be read, or {@link C#LENGTH_UNSET} if it is unknown. + * @param dataSource The {@link DataSource} to read the data from. + * @param buffer The buffer to be used while downloading. + * @param priorityTaskManager If not null it's used to check whether it is allowed to proceed with + * caching. + * @param priority The priority of this task. + * @param progressNotifier A notifier through which to report progress updates, or {@code null}. + * @param isLastBlock Whether this read block is the last block of the content. + * @param isCanceled An optional flag that will interrupt caching if set to true. + * @return Number of read bytes, or 0 if no data is available because the end of the opened range + * has been reached. + */ + private static long readAndDiscard( + DataSpec dataSpec, + long absoluteStreamPosition, + long length, + DataSource dataSource, + byte[] buffer, + @Nullable PriorityTaskManager priorityTaskManager, + int priority, + @Nullable ProgressNotifier progressNotifier, + boolean isLastBlock, + @Nullable AtomicBoolean isCanceled) + throws IOException, InterruptedException { + long positionOffset = absoluteStreamPosition - dataSpec.absoluteStreamPosition; + long initialPositionOffset = positionOffset; + long endOffset = length != C.LENGTH_UNSET ? positionOffset + length : C.POSITION_UNSET; + while (true) { + if (priorityTaskManager != null) { + // Wait for any other thread with higher priority to finish its job. + priorityTaskManager.proceed(priority); + } + throwExceptionIfInterruptedOrCancelled(isCanceled); + try { + long resolvedLength = C.LENGTH_UNSET; + boolean isDataSourceOpen = false; + if (endOffset != C.POSITION_UNSET) { + // If a specific length is given, first try to open the data source for that length to + // avoid more data then required to be requested. If the given length exceeds the end of + // input we will get a "position out of range" error. In that case try to open the source + // again with unset length. + try { + resolvedLength = + dataSource.open(dataSpec.subrange(positionOffset, endOffset - positionOffset)); + isDataSourceOpen = true; + } catch (IOException exception) { + if (!isLastBlock || !isCausedByPositionOutOfRange(exception)) { + throw exception; + } + Util.closeQuietly(dataSource); + } + } + if (!isDataSourceOpen) { + resolvedLength = dataSource.open(dataSpec.subrange(positionOffset, C.LENGTH_UNSET)); + } + if (isLastBlock && progressNotifier != null && resolvedLength != C.LENGTH_UNSET) { + progressNotifier.onRequestLengthResolved(positionOffset + resolvedLength); + } + while (positionOffset != endOffset) { + throwExceptionIfInterruptedOrCancelled(isCanceled); + int bytesRead = + dataSource.read( + buffer, + 0, + endOffset != C.POSITION_UNSET + ? (int) Math.min(buffer.length, endOffset - positionOffset) + : buffer.length); + if (bytesRead == C.RESULT_END_OF_INPUT) { + if (progressNotifier != null) { + progressNotifier.onRequestLengthResolved(positionOffset); + } + break; + } + positionOffset += bytesRead; + if (progressNotifier != null) { + progressNotifier.onBytesCached(bytesRead); + } + } + return positionOffset - initialPositionOffset; + } catch (PriorityTaskManager.PriorityTooLowException exception) { + // catch and try again + } finally { + Util.closeQuietly(dataSource); + } + } + } + + /** + * Removes all of the data specified by the {@code dataSpec}. + * + * <p>This methods blocks until the operation is complete. + * + * @param dataSpec Defines the data to be removed. + * @param cache A {@link Cache} to store the data. + * @param cacheKeyFactory An optional factory for cache keys. + */ + @WorkerThread + public static void remove( + DataSpec dataSpec, Cache cache, @Nullable CacheKeyFactory cacheKeyFactory) { + remove(cache, buildCacheKey(dataSpec, cacheKeyFactory)); + } + + /** + * Removes all of the data specified by the {@code key}. + * + * <p>This methods blocks until the operation is complete. + * + * @param cache A {@link Cache} to store the data. + * @param key The key whose data should be removed. + */ + @WorkerThread + public static void remove(Cache cache, String key) { + NavigableSet<CacheSpan> cachedSpans = cache.getCachedSpans(key); + for (CacheSpan cachedSpan : cachedSpans) { + try { + cache.removeSpan(cachedSpan); + } catch (Cache.CacheException e) { + // Do nothing. + } + } + } + + /* package */ static boolean isCausedByPositionOutOfRange(IOException e) { + Throwable cause = e; + while (cause != null) { + if (cause instanceof DataSourceException) { + int reason = ((DataSourceException) cause).reason; + if (reason == DataSourceException.POSITION_OUT_OF_RANGE) { + return true; + } + } + cause = cause.getCause(); + } + return false; + } + + private static String buildCacheKey( + DataSpec dataSpec, @Nullable CacheKeyFactory cacheKeyFactory) { + return (cacheKeyFactory != null ? cacheKeyFactory : DEFAULT_CACHE_KEY_FACTORY) + .buildCacheKey(dataSpec); + } + + private static void throwExceptionIfInterruptedOrCancelled(@Nullable AtomicBoolean isCanceled) + throws InterruptedException { + if (Thread.interrupted() || (isCanceled != null && isCanceled.get())) { + throw new InterruptedException(); + } + } + + private CacheUtil() {} + + private static final class ProgressNotifier { + /** The listener to notify when progress is made. */ + private final ProgressListener listener; + /** The length of the content being cached in bytes, or {@link C#LENGTH_UNSET} if unknown. */ + private long requestLength; + /** The number of bytes that are cached. */ + private long bytesCached; + + public ProgressNotifier(ProgressListener listener) { + this.listener = listener; + } + + public void init(long requestLength, long bytesCached) { + this.requestLength = requestLength; + this.bytesCached = bytesCached; + listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0); + } + + public void onRequestLengthResolved(long requestLength) { + if (this.requestLength == C.LENGTH_UNSET && requestLength != C.LENGTH_UNSET) { + this.requestLength = requestLength; + listener.onProgress(requestLength, bytesCached, /* newBytesCached= */ 0); + } + } + + public void onBytesCached(long newBytesCached) { + bytesCached += newBytesCached; + listener.onProgress(requestLength, bytesCached, newBytesCached); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java new file mode 100644 index 0000000000..660a2a3cb3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContent.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import java.io.File; +import java.util.TreeSet; + +/** Defines the cached content for a single stream. */ +/* package */ final class CachedContent { + + private static final String TAG = "CachedContent"; + + /** The cache file id that uniquely identifies the original stream. */ + public final int id; + /** The cache key that uniquely identifies the original stream. */ + public final String key; + /** The cached spans of this content. */ + private final TreeSet<SimpleCacheSpan> cachedSpans; + /** Metadata values. */ + private DefaultContentMetadata metadata; + /** Whether the content is locked. */ + private boolean locked; + + /** + * Creates a CachedContent. + * + * @param id The cache file id. + * @param key The cache stream key. + */ + public CachedContent(int id, String key) { + this(id, key, DefaultContentMetadata.EMPTY); + } + + public CachedContent(int id, String key, DefaultContentMetadata metadata) { + this.id = id; + this.key = key; + this.metadata = metadata; + this.cachedSpans = new TreeSet<>(); + } + + /** Returns the metadata. */ + public DefaultContentMetadata getMetadata() { + return metadata; + } + + /** + * Applies {@code mutations} to the metadata. + * + * @return Whether {@code mutations} changed any metadata. + */ + public boolean applyMetadataMutations(ContentMetadataMutations mutations) { + DefaultContentMetadata oldMetadata = metadata; + metadata = metadata.copyWithMutationsApplied(mutations); + return !metadata.equals(oldMetadata); + } + + /** Returns whether the content is locked. */ + public boolean isLocked() { + return locked; + } + + /** Sets the locked state of the content. */ + public void setLocked(boolean locked) { + this.locked = locked; + } + + /** Adds the given {@link SimpleCacheSpan} which contains a part of the content. */ + public void addSpan(SimpleCacheSpan span) { + cachedSpans.add(span); + } + + /** Returns a set of all {@link SimpleCacheSpan}s. */ + public TreeSet<SimpleCacheSpan> getSpans() { + return cachedSpans; + } + + /** + * Returns the span containing the position. If there isn't one, it returns a hole span + * which defines the maximum extents of the hole in the cache. + */ + public SimpleCacheSpan getSpan(long position) { + SimpleCacheSpan lookupSpan = SimpleCacheSpan.createLookup(key, position); + SimpleCacheSpan floorSpan = cachedSpans.floor(lookupSpan); + if (floorSpan != null && floorSpan.position + floorSpan.length > position) { + return floorSpan; + } + SimpleCacheSpan ceilSpan = cachedSpans.ceiling(lookupSpan); + return ceilSpan == null ? SimpleCacheSpan.createOpenHole(key, position) + : SimpleCacheSpan.createClosedHole(key, position, ceilSpan.position - position); + } + + /** + * Returns the length of the cached data block starting from the {@code position} to the block end + * up to {@code length} bytes. If the {@code position} isn't cached then -(the length of the gap + * to the next cached data up to {@code length} bytes) is returned. + * + * @param position The starting position of the data. + * @param length The maximum length of the data to be returned. + * @return the length of the cached or not cached data block length. + */ + public long getCachedBytesLength(long position, long length) { + SimpleCacheSpan span = getSpan(position); + if (span.isHoleSpan()) { + // We don't have a span covering the start of the queried region. + return -Math.min(span.isOpenEnded() ? Long.MAX_VALUE : span.length, length); + } + long queryEndPosition = position + length; + long currentEndPosition = span.position + span.length; + if (currentEndPosition < queryEndPosition) { + for (SimpleCacheSpan next : cachedSpans.tailSet(span, false)) { + if (next.position > currentEndPosition) { + // There's a hole in the cache within the queried region. + break; + } + // We expect currentEndPosition to always equal (next.position + next.length), but + // perform a max check anyway to guard against the existence of overlapping spans. + currentEndPosition = Math.max(currentEndPosition, next.position + next.length); + if (currentEndPosition >= queryEndPosition) { + // We've found spans covering the queried region. + break; + } + } + } + return Math.min(currentEndPosition - position, length); + } + + /** + * Sets the given span's last touch timestamp. The passed span becomes invalid after this call. + * + * @param cacheSpan Span to be copied and updated. + * @param lastTouchTimestamp The new last touch timestamp. + * @param updateFile Whether the span file should be renamed to have its timestamp match the new + * last touch time. + * @return A span with the updated last touch timestamp. + */ + public SimpleCacheSpan setLastTouchTimestamp( + SimpleCacheSpan cacheSpan, long lastTouchTimestamp, boolean updateFile) { + Assertions.checkState(cachedSpans.remove(cacheSpan)); + File file = cacheSpan.file; + if (updateFile) { + File directory = file.getParentFile(); + long position = cacheSpan.position; + File newFile = SimpleCacheSpan.getCacheFile(directory, id, position, lastTouchTimestamp); + if (file.renameTo(newFile)) { + file = newFile; + } else { + Log.w(TAG, "Failed to rename " + file + " to " + newFile); + } + } + SimpleCacheSpan newCacheSpan = + cacheSpan.copyWithFileAndLastTouchTimestamp(file, lastTouchTimestamp); + cachedSpans.add(newCacheSpan); + return newCacheSpan; + } + + /** Returns whether there are any spans cached. */ + public boolean isEmpty() { + return cachedSpans.isEmpty(); + } + + /** Removes the given span from cache. */ + public boolean removeSpan(CacheSpan span) { + if (cachedSpans.remove(span)) { + span.file.delete(); + return true; + } + return false; + } + + @Override + public int hashCode() { + int result = id; + result = 31 * result + key.hashCode(); + result = 31 * result + metadata.hashCode(); + return result; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + CachedContent that = (CachedContent) o; + return id == that.id + && key.equals(that.key) + && cachedSpans.equals(that.cachedSpans) + && metadata.equals(that.metadata); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java new file mode 100644 index 0000000000..ac31e492a2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedContentIndex.java @@ -0,0 +1,956 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.annotation.SuppressLint; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.SQLException; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.VersionTable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.AtomicFile; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ReusableBufferedOutputStream; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.CipherOutputStream; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** Maintains the index of cached content. */ +/* package */ class CachedContentIndex { + + /* package */ static final String FILE_NAME_ATOMIC = "cached_content_index.exi"; + + private static final int INCREMENTAL_METADATA_READ_LENGTH = 10 * 1024 * 1024; + + private final HashMap<String, CachedContent> keyToContent; + /** + * Maps assigned ids to their corresponding keys. Also contains (id -> null) entries for ids that + * have been removed from the index since it was last stored. This prevents reuse of these ids, + * which is necessary to avoid clashes that could otherwise occur as a result of the sequence: + * + * <p>[1] (key1, id1) is removed from the in-memory index ... the index is not stored to disk ... + * [2] id1 is reused for a different key2 ... the index is not stored to disk ... [3] A file for + * key2 is partially written using a path corresponding to id1 ... the process is killed before + * the index is stored to disk ... [4] The index is read from disk, causing the partially written + * file to be incorrectly associated to key1 + * + * <p>By avoiding id reuse in step [2], a new id2 will be used instead. Step [4] will then delete + * the partially written file because the index does not contain an entry for id2. + * + * <p>When the index is next stored (id -> null) entries are removed, making the ids eligible for + * reuse. + */ + private final SparseArray<@NullableType String> idToKey; + /** + * Tracks ids for which (id -> null) entries are present in idToKey, so that they can be removed + * efficiently when the index is next stored. + */ + private final SparseBooleanArray removedIds; + /** Tracks ids that are new since the index was last stored. */ + private final SparseBooleanArray newIds; + + private Storage storage; + @Nullable private Storage previousStorage; + + /** Returns whether the file is an index file. */ + public static boolean isIndexFile(String fileName) { + // Atomic file backups add additional suffixes to the file name. + return fileName.startsWith(FILE_NAME_ATOMIC); + } + + /** + * Deletes index data for the specified cache. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param databaseProvider Provides the database in which the index is stored. + * @param uid The cache UID. + * @throws DatabaseIOException If an error occurs deleting the index data. + */ + @WorkerThread + public static void delete(DatabaseProvider databaseProvider, long uid) + throws DatabaseIOException { + DatabaseStorage.delete(databaseProvider, uid); + } + + /** + * Creates an instance supporting database storage only. + * + * @param databaseProvider Provides the database in which the index is stored. + */ + public CachedContentIndex(DatabaseProvider databaseProvider) { + this( + databaseProvider, + /* legacyStorageDir= */ null, + /* legacyStorageSecretKey= */ null, + /* legacyStorageEncrypt= */ false, + /* preferLegacyStorage= */ false); + } + + /** + * Creates an instance supporting either or both of database and legacy storage. + * + * @param databaseProvider Provides the database in which the index is stored, or {@code null} to + * use only legacy storage. + * @param legacyStorageDir The directory in which any legacy storage is stored, or {@code null} to + * use only database storage. + * @param legacyStorageSecretKey A 16 byte AES key for reading, and optionally writing, legacy + * storage. + * @param legacyStorageEncrypt Whether to encrypt when writing to legacy storage. Must be false if + * {@code legacyStorageSecretKey} is null. + * @param preferLegacyStorage Whether to use prefer legacy storage if both storage types are + * enabled. This option is only useful for downgrading from database storage back to legacy + * storage. + */ + public CachedContentIndex( + @Nullable DatabaseProvider databaseProvider, + @Nullable File legacyStorageDir, + @Nullable byte[] legacyStorageSecretKey, + boolean legacyStorageEncrypt, + boolean preferLegacyStorage) { + Assertions.checkState(databaseProvider != null || legacyStorageDir != null); + keyToContent = new HashMap<>(); + idToKey = new SparseArray<>(); + removedIds = new SparseBooleanArray(); + newIds = new SparseBooleanArray(); + Storage databaseStorage = + databaseProvider != null ? new DatabaseStorage(databaseProvider) : null; + Storage legacyStorage = + legacyStorageDir != null + ? new LegacyStorage( + new File(legacyStorageDir, FILE_NAME_ATOMIC), + legacyStorageSecretKey, + legacyStorageEncrypt) + : null; + if (databaseStorage == null || (legacyStorage != null && preferLegacyStorage)) { + storage = legacyStorage; + previousStorage = databaseStorage; + } else { + storage = databaseStorage; + previousStorage = legacyStorage; + } + } + + /** + * Loads the index data for the given cache UID. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param uid The UID of the cache whose index is to be loaded. + * @throws IOException If an error occurs initializing the index data. + */ + @WorkerThread + public void initialize(long uid) throws IOException { + storage.initialize(uid); + if (previousStorage != null) { + previousStorage.initialize(uid); + } + if (!storage.exists() && previousStorage != null && previousStorage.exists()) { + // Copy from previous storage into current storage. + previousStorage.load(keyToContent, idToKey); + storage.storeFully(keyToContent); + } else { + // Load from the current storage. + storage.load(keyToContent, idToKey); + } + if (previousStorage != null) { + previousStorage.delete(); + previousStorage = null; + } + } + + /** + * Stores the index data to index file if there is a change. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @throws IOException If an error occurs storing the index data. + */ + @WorkerThread + public void store() throws IOException { + storage.storeIncremental(keyToContent); + // Make ids that were removed since the index was last stored eligible for re-use. + int removedIdCount = removedIds.size(); + for (int i = 0; i < removedIdCount; i++) { + idToKey.remove(removedIds.keyAt(i)); + } + removedIds.clear(); + newIds.clear(); + } + + /** + * Adds the given key to the index if it isn't there already. + * + * @param key The cache key that uniquely identifies the original stream. + * @return A new or existing CachedContent instance with the given key. + */ + public CachedContent getOrAdd(String key) { + CachedContent cachedContent = keyToContent.get(key); + return cachedContent == null ? addNew(key) : cachedContent; + } + + /** Returns a CachedContent instance with the given key or null if there isn't one. */ + public CachedContent get(String key) { + return keyToContent.get(key); + } + + /** + * Returns a Collection of all CachedContent instances in the index. The collection is backed by + * the {@code keyToContent} map, so changes to the map are reflected in the collection, and + * vice-versa. If the map is modified while an iteration over the collection is in progress + * (except through the iterator's own remove operation), the results of the iteration are + * undefined. + */ + public Collection<CachedContent> getAll() { + return keyToContent.values(); + } + + /** Returns an existing or new id assigned to the given key. */ + public int assignIdForKey(String key) { + return getOrAdd(key).id; + } + + /** Returns the key which has the given id assigned. */ + public String getKeyForId(int id) { + return idToKey.get(id); + } + + /** Removes {@link CachedContent} with the given key from index if it's empty and not locked. */ + public void maybeRemove(String key) { + CachedContent cachedContent = keyToContent.get(key); + if (cachedContent != null && cachedContent.isEmpty() && !cachedContent.isLocked()) { + keyToContent.remove(key); + int id = cachedContent.id; + boolean neverStored = newIds.get(id); + storage.onRemove(cachedContent, neverStored); + if (neverStored) { + // The id can be reused immediately. + idToKey.remove(id); + newIds.delete(id); + } else { + // Keep an entry in idToKey to stop the id from being reused until the index is next stored, + // and add an entry to removedIds to track that it should be removed when this does happen. + idToKey.put(id, /* value= */ null); + removedIds.put(id, /* value= */ true); + } + } + } + + /** Removes empty and not locked {@link CachedContent} instances from index. */ + public void removeEmpty() { + String[] keys = new String[keyToContent.size()]; + keyToContent.keySet().toArray(keys); + for (String key : keys) { + maybeRemove(key); + } + } + + /** + * Returns a set of all content keys. The set is backed by the {@code keyToContent} map, so + * changes to the map are reflected in the set, and vice-versa. If the map is modified while an + * iteration over the set is in progress (except through the iterator's own remove operation), the + * results of the iteration are undefined. + */ + public Set<String> getKeys() { + return keyToContent.keySet(); + } + + /** + * Applies {@code mutations} to the {@link ContentMetadata} for the given key. A new {@link + * CachedContent} is added if there isn't one already with the given key. + */ + public void applyContentMetadataMutations(String key, ContentMetadataMutations mutations) { + CachedContent cachedContent = getOrAdd(key); + if (cachedContent.applyMetadataMutations(mutations)) { + storage.onUpdate(cachedContent); + } + } + + /** Returns a {@link ContentMetadata} for the given key. */ + public ContentMetadata getContentMetadata(String key) { + CachedContent cachedContent = get(key); + return cachedContent != null ? cachedContent.getMetadata() : DefaultContentMetadata.EMPTY; + } + + private CachedContent addNew(String key) { + int id = getNewId(idToKey); + CachedContent cachedContent = new CachedContent(id, key); + keyToContent.put(key, cachedContent); + idToKey.put(id, key); + newIds.put(id, true); + storage.onUpdate(cachedContent); + return cachedContent; + } + + @SuppressLint("GetInstance") // Suppress warning about specifying "BC" as an explicit provider. + private static Cipher getCipher() throws NoSuchPaddingException, NoSuchAlgorithmException { + // Workaround for https://issuetracker.google.com/issues/36976726 + if (Util.SDK_INT == 18) { + try { + return Cipher.getInstance("AES/CBC/PKCS5PADDING", "BC"); + } catch (Throwable ignored) { + // ignored + } + } + return Cipher.getInstance("AES/CBC/PKCS5PADDING"); + } + + /** + * Returns an id which isn't used in the given array. If the maximum id in the array is smaller + * than {@link java.lang.Integer#MAX_VALUE} it just returns the next bigger integer. Otherwise it + * returns the smallest unused non-negative integer. + */ + @VisibleForTesting + /* package */ static int getNewId(SparseArray<String> idToKey) { + int size = idToKey.size(); + int id = size == 0 ? 0 : (idToKey.keyAt(size - 1) + 1); + if (id < 0) { // In case if we pass max int value. + // TODO optimization: defragmentation or binary search? + for (id = 0; id < size; id++) { + if (id != idToKey.keyAt(id)) { + break; + } + } + } + return id; + } + + /** + * Deserializes a {@link DefaultContentMetadata} from the given input stream. + * + * @param input Input stream to read from. + * @return a {@link DefaultContentMetadata} instance. + * @throws IOException If an error occurs during reading from the input. + */ + private static DefaultContentMetadata readContentMetadata(DataInputStream input) + throws IOException { + int size = input.readInt(); + HashMap<String, byte[]> metadata = new HashMap<>(); + for (int i = 0; i < size; i++) { + String name = input.readUTF(); + int valueSize = input.readInt(); + if (valueSize < 0) { + throw new IOException("Invalid value size: " + valueSize); + } + // Grow the array incrementally to avoid OutOfMemoryError in the case that a corrupt (and very + // large) valueSize was read. In such cases the implementation below is expected to throw + // IOException from one of the readFully calls, due to the end of the input being reached. + int bytesRead = 0; + int nextBytesToRead = Math.min(valueSize, INCREMENTAL_METADATA_READ_LENGTH); + byte[] value = Util.EMPTY_BYTE_ARRAY; + while (bytesRead != valueSize) { + value = Arrays.copyOf(value, bytesRead + nextBytesToRead); + input.readFully(value, bytesRead, nextBytesToRead); + bytesRead += nextBytesToRead; + nextBytesToRead = Math.min(valueSize - bytesRead, INCREMENTAL_METADATA_READ_LENGTH); + } + metadata.put(name, value); + } + return new DefaultContentMetadata(metadata); + } + + /** + * Serializes itself to a {@link DataOutputStream}. + * + * @param output Output stream to store the values. + * @throws IOException If an error occurs writing to the output. + */ + private static void writeContentMetadata(DefaultContentMetadata metadata, DataOutputStream output) + throws IOException { + Set<Map.Entry<String, byte[]>> entrySet = metadata.entrySet(); + output.writeInt(entrySet.size()); + for (Map.Entry<String, byte[]> entry : entrySet) { + output.writeUTF(entry.getKey()); + byte[] value = entry.getValue(); + output.writeInt(value.length); + output.write(value); + } + } + + /** Interface for the persistent index. */ + private interface Storage { + + /** Initializes the storage for the given cache UID. */ + void initialize(long uid); + + /** + * Returns whether the persisted index exists. + * + * @throws IOException If an error occurs determining whether the persisted index exists. + */ + boolean exists() throws IOException; + + /** + * Deletes the persisted index. + * + * @throws IOException If an error occurs deleting the index. + */ + void delete() throws IOException; + + /** + * Loads the persisted index into {@code content} and {@code idToKey}, creating it if it doesn't + * already exist. + * + * <p>If the persisted index is in a permanently bad state (i.e. all further attempts to load it + * are also expected to fail) then it will be deleted and the call will return successfully. For + * transient failures, {@link IOException} will be thrown. + * + * @param content The key to content map to populate with persisted data. + * @param idToKey The id to key map to populate with persisted data. + * @throws IOException If an error occurs loading the index. + */ + void load(HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) + throws IOException; + + /** + * Writes the persisted index, creating it if it doesn't already exist and replacing any + * existing content if it does. + * + * @param content The key to content map to persist. + * @throws IOException If an error occurs persisting the index. + */ + void storeFully(HashMap<String, CachedContent> content) throws IOException; + + /** + * Ensures incremental changes to the index since the initial {@link #initialize(long)} or last + * {@link #storeFully(HashMap)} are persisted. The storage will have been notified of all such + * changes via {@link #onUpdate(CachedContent)} and {@link #onRemove(CachedContent, boolean)}. + * + * @param content The key to content map to persist. + * @throws IOException If an error occurs persisting the index. + */ + void storeIncremental(HashMap<String, CachedContent> content) throws IOException; + + /** + * Called when a {@link CachedContent} is added or updated. + * + * @param cachedContent The updated {@link CachedContent}. + */ + void onUpdate(CachedContent cachedContent); + + /** + * Called when a {@link CachedContent} is removed. + * + * @param cachedContent The removed {@link CachedContent}. + * @param neverStored True if the {@link CachedContent} was added more recently than when the + * index was last stored. + */ + void onRemove(CachedContent cachedContent, boolean neverStored); + } + + /** {@link Storage} implementation that uses an {@link AtomicFile}. */ + private static class LegacyStorage implements Storage { + + private static final int VERSION = 2; + private static final int VERSION_METADATA_INTRODUCED = 2; + private static final int FLAG_ENCRYPTED_INDEX = 1; + + private final boolean encrypt; + @Nullable private final Cipher cipher; + @Nullable private final SecretKeySpec secretKeySpec; + @Nullable private final Random random; + private final AtomicFile atomicFile; + + private boolean changed; + @Nullable private ReusableBufferedOutputStream bufferedOutputStream; + + public LegacyStorage(File file, @Nullable byte[] secretKey, boolean encrypt) { + Cipher cipher = null; + SecretKeySpec secretKeySpec = null; + if (secretKey != null) { + Assertions.checkArgument(secretKey.length == 16); + try { + cipher = getCipher(); + secretKeySpec = new SecretKeySpec(secretKey, "AES"); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { + throw new IllegalStateException(e); // Should never happen. + } + } else { + Assertions.checkArgument(!encrypt); + } + this.encrypt = encrypt; + this.cipher = cipher; + this.secretKeySpec = secretKeySpec; + random = encrypt ? new Random() : null; + atomicFile = new AtomicFile(file); + } + + @Override + public void initialize(long uid) { + // Do nothing. Legacy storage uses a separate file for each cache. + } + + @Override + public boolean exists() { + return atomicFile.exists(); + } + + @Override + public void delete() { + atomicFile.delete(); + } + + @Override + public void load( + HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) { + Assertions.checkState(!changed); + if (!readFile(content, idToKey)) { + content.clear(); + idToKey.clear(); + atomicFile.delete(); + } + } + + @Override + public void storeFully(HashMap<String, CachedContent> content) throws IOException { + writeFile(content); + changed = false; + } + + @Override + public void storeIncremental(HashMap<String, CachedContent> content) throws IOException { + if (!changed) { + return; + } + storeFully(content); + } + + @Override + public void onUpdate(CachedContent cachedContent) { + changed = true; + } + + @Override + public void onRemove(CachedContent cachedContent, boolean neverStored) { + changed = true; + } + + private boolean readFile( + HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) { + if (!atomicFile.exists()) { + return true; + } + + DataInputStream input = null; + try { + InputStream inputStream = new BufferedInputStream(atomicFile.openRead()); + input = new DataInputStream(inputStream); + int version = input.readInt(); + if (version < 0 || version > VERSION) { + return false; + } + + int flags = input.readInt(); + if ((flags & FLAG_ENCRYPTED_INDEX) != 0) { + if (cipher == null) { + return false; + } + byte[] initializationVector = new byte[16]; + input.readFully(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); + } + input = new DataInputStream(new CipherInputStream(inputStream, cipher)); + } else if (encrypt) { + changed = true; // Force index to be rewritten encrypted after read. + } + + int count = input.readInt(); + int hashCode = 0; + for (int i = 0; i < count; i++) { + CachedContent cachedContent = readCachedContent(version, input); + content.put(cachedContent.key, cachedContent); + idToKey.put(cachedContent.id, cachedContent.key); + hashCode += hashCachedContent(cachedContent, version); + } + int fileHashCode = input.readInt(); + boolean isEOF = input.read() == -1; + if (fileHashCode != hashCode || !isEOF) { + return false; + } + } catch (IOException e) { + return false; + } finally { + if (input != null) { + Util.closeQuietly(input); + } + } + return true; + } + + private void writeFile(HashMap<String, CachedContent> content) throws IOException { + DataOutputStream output = null; + try { + OutputStream outputStream = atomicFile.startWrite(); + if (bufferedOutputStream == null) { + bufferedOutputStream = new ReusableBufferedOutputStream(outputStream); + } else { + bufferedOutputStream.reset(outputStream); + } + output = new DataOutputStream(bufferedOutputStream); + output.writeInt(VERSION); + + int flags = encrypt ? FLAG_ENCRYPTED_INDEX : 0; + output.writeInt(flags); + + if (encrypt) { + byte[] initializationVector = new byte[16]; + random.nextBytes(initializationVector); + output.write(initializationVector); + IvParameterSpec ivParameterSpec = new IvParameterSpec(initializationVector); + try { + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec); + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException(e); // Should never happen. + } + output.flush(); + output = new DataOutputStream(new CipherOutputStream(bufferedOutputStream, cipher)); + } + + output.writeInt(content.size()); + int hashCode = 0; + for (CachedContent cachedContent : content.values()) { + writeCachedContent(cachedContent, output); + hashCode += hashCachedContent(cachedContent, VERSION); + } + output.writeInt(hashCode); + atomicFile.endWrite(output); + // Avoid calling close twice. Duplicate CipherOutputStream.close calls did + // not used to be no-ops: https://android-review.googlesource.com/#/c/272799/ + output = null; + } finally { + Util.closeQuietly(output); + } + } + + /** + * Calculates a hash code for a {@link CachedContent} which is compatible with a particular + * index version. + */ + private int hashCachedContent(CachedContent cachedContent, int version) { + int result = cachedContent.id; + result = 31 * result + cachedContent.key.hashCode(); + if (version < VERSION_METADATA_INTRODUCED) { + long length = ContentMetadata.getContentLength(cachedContent.getMetadata()); + result = 31 * result + (int) (length ^ (length >>> 32)); + } else { + result = 31 * result + cachedContent.getMetadata().hashCode(); + } + return result; + } + + /** + * Reads a {@link CachedContent} from a {@link DataInputStream}. + * + * @param version Version of the encoded data. + * @param input Input stream containing values needed to initialize CachedContent instance. + * @throws IOException If an error occurs during reading values. + */ + private CachedContent readCachedContent(int version, DataInputStream input) throws IOException { + int id = input.readInt(); + String key = input.readUTF(); + DefaultContentMetadata metadata; + if (version < VERSION_METADATA_INTRODUCED) { + long length = input.readLong(); + ContentMetadataMutations mutations = new ContentMetadataMutations(); + ContentMetadataMutations.setContentLength(mutations, length); + metadata = DefaultContentMetadata.EMPTY.copyWithMutationsApplied(mutations); + } else { + metadata = readContentMetadata(input); + } + return new CachedContent(id, key, metadata); + } + + /** + * Writes a {@link CachedContent} to a {@link DataOutputStream}. + * + * @param output Output stream to store the values. + * @throws IOException If an error occurs during writing values to output. + */ + private void writeCachedContent(CachedContent cachedContent, DataOutputStream output) + throws IOException { + output.writeInt(cachedContent.id); + output.writeUTF(cachedContent.key); + writeContentMetadata(cachedContent.getMetadata(), output); + } + } + + /** {@link Storage} implementation that uses an SQL database. */ + private static final class DatabaseStorage implements Storage { + + private static final String TABLE_PREFIX = DatabaseProvider.TABLE_PREFIX + "CacheIndex"; + private static final int TABLE_VERSION = 1; + + private static final String COLUMN_ID = "id"; + private static final String COLUMN_KEY = "key"; + private static final String COLUMN_METADATA = "metadata"; + + private static final int COLUMN_INDEX_ID = 0; + private static final int COLUMN_INDEX_KEY = 1; + private static final int COLUMN_INDEX_METADATA = 2; + + private static final String WHERE_ID_EQUALS = COLUMN_ID + " = ?"; + + private static final String[] COLUMNS = new String[] {COLUMN_ID, COLUMN_KEY, COLUMN_METADATA}; + private static final String TABLE_SCHEMA = + "(" + + COLUMN_ID + + " INTEGER PRIMARY KEY NOT NULL," + + COLUMN_KEY + + " TEXT NOT NULL," + + COLUMN_METADATA + + " BLOB NOT NULL)"; + + private final DatabaseProvider databaseProvider; + private final SparseArray<CachedContent> pendingUpdates; + + private String hexUid; + private String tableName; + + public static void delete(DatabaseProvider databaseProvider, long uid) + throws DatabaseIOException { + delete(databaseProvider, Long.toHexString(uid)); + } + + public DatabaseStorage(DatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + pendingUpdates = new SparseArray<>(); + } + + @Override + public void initialize(long uid) { + hexUid = Long.toHexString(uid); + tableName = getTableName(hexUid); + } + + @Override + public boolean exists() throws DatabaseIOException { + return VersionTable.getVersion( + databaseProvider.getReadableDatabase(), + VersionTable.FEATURE_CACHE_CONTENT_METADATA, + hexUid) + != VersionTable.VERSION_UNSET; + } + + @Override + public void delete() throws DatabaseIOException { + delete(databaseProvider, hexUid); + } + + @Override + public void load( + HashMap<String, CachedContent> content, SparseArray<@NullableType String> idToKey) + throws IOException { + Assertions.checkState(pendingUpdates.size() == 0); + try { + int version = + VersionTable.getVersion( + databaseProvider.getReadableDatabase(), + VersionTable.FEATURE_CACHE_CONTENT_METADATA, + hexUid); + if (version != TABLE_VERSION) { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + initializeTable(writableDatabase); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } + + try (Cursor cursor = getCursor()) { + while (cursor.moveToNext()) { + int id = cursor.getInt(COLUMN_INDEX_ID); + String key = cursor.getString(COLUMN_INDEX_KEY); + byte[] metadataBytes = cursor.getBlob(COLUMN_INDEX_METADATA); + + ByteArrayInputStream inputStream = new ByteArrayInputStream(metadataBytes); + DataInputStream input = new DataInputStream(inputStream); + DefaultContentMetadata metadata = readContentMetadata(input); + + CachedContent cachedContent = new CachedContent(id, key, metadata); + content.put(cachedContent.key, cachedContent); + idToKey.put(cachedContent.id, cachedContent.key); + } + } + } catch (SQLiteException e) { + content.clear(); + idToKey.clear(); + throw new DatabaseIOException(e); + } + } + + @Override + public void storeFully(HashMap<String, CachedContent> content) throws IOException { + try { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + initializeTable(writableDatabase); + for (CachedContent cachedContent : content.values()) { + addOrUpdateRow(writableDatabase, cachedContent); + } + writableDatabase.setTransactionSuccessful(); + pendingUpdates.clear(); + } finally { + writableDatabase.endTransaction(); + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void storeIncremental(HashMap<String, CachedContent> content) throws IOException { + if (pendingUpdates.size() == 0) { + return; + } + try { + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + for (int i = 0; i < pendingUpdates.size(); i++) { + CachedContent cachedContent = pendingUpdates.valueAt(i); + if (cachedContent == null) { + deleteRow(writableDatabase, pendingUpdates.keyAt(i)); + } else { + addOrUpdateRow(writableDatabase, cachedContent); + } + } + writableDatabase.setTransactionSuccessful(); + pendingUpdates.clear(); + } finally { + writableDatabase.endTransaction(); + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + @Override + public void onUpdate(CachedContent cachedContent) { + pendingUpdates.put(cachedContent.id, cachedContent); + } + + @Override + public void onRemove(CachedContent cachedContent, boolean neverStored) { + if (neverStored) { + pendingUpdates.delete(cachedContent.id); + } else { + pendingUpdates.put(cachedContent.id, null); + } + } + + private Cursor getCursor() { + return databaseProvider + .getReadableDatabase() + .query( + tableName, + COLUMNS, + /* selection= */ null, + /* selectionArgs= */ null, + /* groupBy= */ null, + /* having= */ null, + /* orderBy= */ null); + } + + private void initializeTable(SQLiteDatabase writableDatabase) throws DatabaseIOException { + VersionTable.setVersion( + writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid, TABLE_VERSION); + dropTable(writableDatabase, tableName); + writableDatabase.execSQL("CREATE TABLE " + tableName + " " + TABLE_SCHEMA); + } + + private void deleteRow(SQLiteDatabase writableDatabase, int key) { + writableDatabase.delete(tableName, WHERE_ID_EQUALS, new String[] {Integer.toString(key)}); + } + + private void addOrUpdateRow(SQLiteDatabase writableDatabase, CachedContent cachedContent) + throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + writeContentMetadata(cachedContent.getMetadata(), new DataOutputStream(outputStream)); + byte[] data = outputStream.toByteArray(); + + ContentValues values = new ContentValues(); + values.put(COLUMN_ID, cachedContent.id); + values.put(COLUMN_KEY, cachedContent.key); + values.put(COLUMN_METADATA, data); + writableDatabase.replaceOrThrow(tableName, /* nullColumnHack= */ null, values); + } + + private static void delete(DatabaseProvider databaseProvider, String hexUid) + throws DatabaseIOException { + try { + String tableName = getTableName(hexUid); + SQLiteDatabase writableDatabase = databaseProvider.getWritableDatabase(); + writableDatabase.beginTransactionNonExclusive(); + try { + VersionTable.removeVersion( + writableDatabase, VersionTable.FEATURE_CACHE_CONTENT_METADATA, hexUid); + dropTable(writableDatabase, tableName); + writableDatabase.setTransactionSuccessful(); + } finally { + writableDatabase.endTransaction(); + } + } catch (SQLException e) { + throw new DatabaseIOException(e); + } + } + + private static void dropTable(SQLiteDatabase writableDatabase, String tableName) { + writableDatabase.execSQL("DROP TABLE IF EXISTS " + tableName); + } + + private static String getTableName(String hexUid) { + return TABLE_PREFIX + hexUid; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java new file mode 100644 index 0000000000..9b08301ab8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/CachedRegionTracker.java @@ -0,0 +1,204 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.NonNull; +import org.mozilla.thirdparty.com.google.android.exoplayer2.extractor.ChunkIndex; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; +import java.util.Iterator; +import java.util.NavigableSet; +import java.util.TreeSet; + +/** + * Utility class for efficiently tracking regions of data that are stored in a {@link Cache} + * for a given cache key. + */ +public final class CachedRegionTracker implements Cache.Listener { + + private static final String TAG = "CachedRegionTracker"; + + public static final int NOT_CACHED = -1; + public static final int CACHED_TO_END = -2; + + private final Cache cache; + private final String cacheKey; + private final ChunkIndex chunkIndex; + + private final TreeSet<Region> regions; + private final Region lookupRegion; + + public CachedRegionTracker(Cache cache, String cacheKey, ChunkIndex chunkIndex) { + this.cache = cache; + this.cacheKey = cacheKey; + this.chunkIndex = chunkIndex; + this.regions = new TreeSet<>(); + this.lookupRegion = new Region(0, 0); + + synchronized (this) { + NavigableSet<CacheSpan> cacheSpans = cache.addListener(cacheKey, this); + // Merge the spans into regions. mergeSpan is more efficient when merging from high to low, + // which is why a descending iterator is used here. + Iterator<CacheSpan> spanIterator = cacheSpans.descendingIterator(); + while (spanIterator.hasNext()) { + CacheSpan span = spanIterator.next(); + mergeSpan(span); + } + } + } + + public void release() { + cache.removeListener(cacheKey, this); + } + + /** + * When provided with a byte offset, this method locates the cached region within which the + * offset falls, and returns the approximate end position in milliseconds of that region. If the + * byte offset does not fall within a cached region then {@link #NOT_CACHED} is returned. + * If the cached region extends to the end of the stream, {@link #CACHED_TO_END} is returned. + * + * @param byteOffset The byte offset in the underlying stream. + * @return The end position of the corresponding cache region, {@link #NOT_CACHED}, or + * {@link #CACHED_TO_END}. + */ + public synchronized int getRegionEndTimeMs(long byteOffset) { + lookupRegion.startOffset = byteOffset; + Region floorRegion = regions.floor(lookupRegion); + if (floorRegion == null || byteOffset > floorRegion.endOffset + || floorRegion.endOffsetIndex == -1) { + return NOT_CACHED; + } + int index = floorRegion.endOffsetIndex; + if (index == chunkIndex.length - 1 + && floorRegion.endOffset == (chunkIndex.offsets[index] + chunkIndex.sizes[index])) { + return CACHED_TO_END; + } + long segmentFractionUs = (chunkIndex.durationsUs[index] + * (floorRegion.endOffset - chunkIndex.offsets[index])) / chunkIndex.sizes[index]; + return (int) ((chunkIndex.timesUs[index] + segmentFractionUs) / 1000); + } + + @Override + public synchronized void onSpanAdded(Cache cache, CacheSpan span) { + mergeSpan(span); + } + + @Override + public synchronized void onSpanRemoved(Cache cache, CacheSpan span) { + Region removedRegion = new Region(span.position, span.position + span.length); + + // Look up a region this span falls into. + Region floorRegion = regions.floor(removedRegion); + if (floorRegion == null) { + Log.e(TAG, "Removed a span we were not aware of"); + return; + } + + // Remove it. + regions.remove(floorRegion); + + // Add new floor and ceiling regions, if necessary. + if (floorRegion.startOffset < removedRegion.startOffset) { + Region newFloorRegion = new Region(floorRegion.startOffset, removedRegion.startOffset); + + int index = Arrays.binarySearch(chunkIndex.offsets, newFloorRegion.endOffset); + newFloorRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newFloorRegion); + } + + if (floorRegion.endOffset > removedRegion.endOffset) { + Region newCeilingRegion = new Region(removedRegion.endOffset + 1, floorRegion.endOffset); + newCeilingRegion.endOffsetIndex = floorRegion.endOffsetIndex; + regions.add(newCeilingRegion); + } + } + + @Override + public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { + // Do nothing. + } + + private void mergeSpan(CacheSpan span) { + Region newRegion = new Region(span.position, span.position + span.length); + Region floorRegion = regions.floor(newRegion); + Region ceilingRegion = regions.ceiling(newRegion); + boolean floorConnects = regionsConnect(floorRegion, newRegion); + boolean ceilingConnects = regionsConnect(newRegion, ceilingRegion); + + if (ceilingConnects) { + if (floorConnects) { + // Extend floorRegion to cover both newRegion and ceilingRegion. + floorRegion.endOffset = ceilingRegion.endOffset; + floorRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + } else { + // Extend newRegion to cover ceilingRegion. Add it. + newRegion.endOffset = ceilingRegion.endOffset; + newRegion.endOffsetIndex = ceilingRegion.endOffsetIndex; + regions.add(newRegion); + } + regions.remove(ceilingRegion); + } else if (floorConnects) { + // Extend floorRegion to the right to cover newRegion. + floorRegion.endOffset = newRegion.endOffset; + int index = floorRegion.endOffsetIndex; + while (index < chunkIndex.length - 1 + && (chunkIndex.offsets[index + 1] <= floorRegion.endOffset)) { + index++; + } + floorRegion.endOffsetIndex = index; + } else { + // This is a new region. + int index = Arrays.binarySearch(chunkIndex.offsets, newRegion.endOffset); + newRegion.endOffsetIndex = index < 0 ? -index - 2 : index; + regions.add(newRegion); + } + } + + private boolean regionsConnect(Region lower, Region upper) { + return lower != null && upper != null && lower.endOffset == upper.startOffset; + } + + private static class Region implements Comparable<Region> { + + /** + * The first byte of the region (inclusive). + */ + public long startOffset; + /** + * End offset of the region (exclusive). + */ + public long endOffset; + /** + * The index in chunkIndex that contains the end offset. May be -1 if the end offset comes + * before the start of the first media chunk (i.e. if the end offset is within the stream + * header). + */ + public int endOffsetIndex; + + public Region(long position, long endOffset) { + this.startOffset = position; + this.endOffset = endOffset; + } + + @Override + public int compareTo(@NonNull Region another) { + return Util.compareLong(startOffset, another.startOffset); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java new file mode 100644 index 0000000000..aa34823043 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadata.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** + * Interface for an immutable snapshot of keyed metadata. + */ +public interface ContentMetadata { + + /** + * Prefix for custom metadata keys. Applications can use keys starting with this prefix without + * any risk of their keys colliding with ones defined by the ExoPlayer library. + */ + @SuppressWarnings("unused") + String KEY_CUSTOM_PREFIX = "custom_"; + /** Key for redirected uri (type: String). */ + String KEY_REDIRECTED_URI = "exo_redir"; + /** Key for content length in bytes (type: long). */ + String KEY_CONTENT_LENGTH = "exo_len"; + + /** + * Returns a metadata value. + * + * @param key Key of the metadata to be returned. + * @param defaultValue Value to return if the metadata doesn't exist. + * @return The metadata value. + */ + @Nullable + byte[] get(String key, @Nullable byte[] defaultValue); + + /** + * Returns a metadata value. + * + * @param key Key of the metadata to be returned. + * @param defaultValue Value to return if the metadata doesn't exist. + * @return The metadata value. + */ + @Nullable + String get(String key, @Nullable String defaultValue); + + /** + * Returns a metadata value. + * + * @param key Key of the metadata to be returned. + * @param defaultValue Value to return if the metadata doesn't exist. + * @return The metadata value. + */ + long get(String key, long defaultValue); + + /** Returns whether the metadata is available. */ + boolean contains(String key); + + /** + * Returns the value stored under {@link #KEY_CONTENT_LENGTH}, or {@link C#LENGTH_UNSET} if not + * set. + */ + static long getContentLength(ContentMetadata contentMetadata) { + return contentMetadata.get(KEY_CONTENT_LENGTH, C.LENGTH_UNSET); + } + + /** + * Returns the value stored under {@link #KEY_REDIRECTED_URI} as a {@link Uri}, or {code null} if + * not set. + */ + @Nullable + static Uri getRedirectedUri(ContentMetadata contentMetadata) { + String redirectedUri = contentMetadata.get(KEY_REDIRECTED_URI, (String) null); + return redirectedUri == null ? null : Uri.parse(redirectedUri); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java new file mode 100644 index 0000000000..c7a8d9f711 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/ContentMetadataMutations.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +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 java.util.Map.Entry; + +/** + * Defines multiple mutations on metadata value which are applied atomically. This class isn't + * thread safe. + */ +public class ContentMetadataMutations { + + /** + * Adds a mutation to set the {@link ContentMetadata#KEY_CONTENT_LENGTH} value, or to remove any + * existing value if {@link C#LENGTH_UNSET} is passed. + * + * @param mutations The mutations to modify. + * @param length The length value, or {@link C#LENGTH_UNSET} to remove any existing entry. + * @return The mutations instance, for convenience. + */ + public static ContentMetadataMutations setContentLength( + ContentMetadataMutations mutations, long length) { + return mutations.set(ContentMetadata.KEY_CONTENT_LENGTH, length); + } + + /** + * Adds a mutation to set the {@link ContentMetadata#KEY_REDIRECTED_URI} value, or to remove any + * existing entry if {@code null} is passed. + * + * @param mutations The mutations to modify. + * @param uri The {@link Uri} value, or {@code null} to remove any existing entry. + * @return The mutations instance, for convenience. + */ + public static ContentMetadataMutations setRedirectedUri( + ContentMetadataMutations mutations, @Nullable Uri uri) { + if (uri == null) { + return mutations.remove(ContentMetadata.KEY_REDIRECTED_URI); + } else { + return mutations.set(ContentMetadata.KEY_REDIRECTED_URI, uri.toString()); + } + } + + private final Map<String, Object> editedValues; + private final List<String> removedValues; + + /** Constructs a DefaultMetadataMutations. */ + public ContentMetadataMutations() { + editedValues = new HashMap<>(); + removedValues = new ArrayList<>(); + } + + /** + * Adds a mutation to set a metadata value. Passing {@code null} as {@code name} or {@code value} + * isn't allowed. + * + * @param name The name of the metadata value. + * @param value The value to be set. + * @return This instance, for convenience. + */ + public ContentMetadataMutations set(String name, String value) { + return checkAndSet(name, value); + } + + /** + * Adds a mutation to set a metadata value. Passing {@code null} as {@code name} isn't allowed. + * + * @param name The name of the metadata value. + * @param value The value to be set. + * @return This instance, for convenience. + */ + public ContentMetadataMutations set(String name, long value) { + return checkAndSet(name, value); + } + + /** + * Adds a mutation to set a metadata value. Passing {@code null} as {@code name} or {@code value} + * isn't allowed. + * + * @param name The name of the metadata value. + * @param value The value to be set. + * @return This instance, for convenience. + */ + public ContentMetadataMutations set(String name, byte[] value) { + return checkAndSet(name, Arrays.copyOf(value, value.length)); + } + + /** + * Adds a mutation to remove a metadata value. + * + * @param name The name of the metadata value. + * @return This instance, for convenience. + */ + public ContentMetadataMutations remove(String name) { + removedValues.add(name); + editedValues.remove(name); + return this; + } + + /** Returns a list of names of metadata values to be removed. */ + public List<String> getRemovedValues() { + return Collections.unmodifiableList(new ArrayList<>(removedValues)); + } + + /** Returns a map of metadata name, value pairs to be set. Values are copied. */ + public Map<String, Object> getEditedValues() { + HashMap<String, Object> hashMap = new HashMap<>(editedValues); + for (Entry<String, Object> entry : hashMap.entrySet()) { + Object value = entry.getValue(); + if (value instanceof byte[]) { + byte[] bytes = (byte[]) value; + entry.setValue(Arrays.copyOf(bytes, bytes.length)); + } + } + return Collections.unmodifiableMap(hashMap); + } + + private ContentMetadataMutations checkAndSet(String name, Object value) { + editedValues.put(Assertions.checkNotNull(name), Assertions.checkNotNull(value)); + removedValues.remove(name); + return this; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java new file mode 100644 index 0000000000..2602f834e7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/DefaultContentMetadata.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** Default implementation of {@link ContentMetadata}. Values are stored as byte arrays. */ +public final class DefaultContentMetadata implements ContentMetadata { + + /** An empty DefaultContentMetadata. */ + public static final DefaultContentMetadata EMPTY = + new DefaultContentMetadata(Collections.emptyMap()); + + private int hashCode; + + private final Map<String, byte[]> metadata; + + public DefaultContentMetadata() { + this(Collections.emptyMap()); + } + + /** @param metadata The metadata entries in their raw byte array form. */ + public DefaultContentMetadata(Map<String, byte[]> metadata) { + this.metadata = Collections.unmodifiableMap(metadata); + } + + /** + * Returns a copy {@link DefaultContentMetadata} with {@code mutations} applied. If {@code + * mutations} don't change anything, returns this instance. + */ + public DefaultContentMetadata copyWithMutationsApplied(ContentMetadataMutations mutations) { + Map<String, byte[]> mutatedMetadata = applyMutations(metadata, mutations); + if (isMetadataEqual(metadata, mutatedMetadata)) { + return this; + } + return new DefaultContentMetadata(mutatedMetadata); + } + + /** Returns the set of metadata entries in their raw byte array form. */ + public Set<Entry<String, byte[]>> entrySet() { + return metadata.entrySet(); + } + + @Override + @Nullable + public final byte[] get(String name, @Nullable byte[] defaultValue) { + if (metadata.containsKey(name)) { + byte[] bytes = metadata.get(name); + return Arrays.copyOf(bytes, bytes.length); + } else { + return defaultValue; + } + } + + @Override + @Nullable + public final String get(String name, @Nullable String defaultValue) { + if (metadata.containsKey(name)) { + byte[] bytes = metadata.get(name); + return new String(bytes, Charset.forName(C.UTF8_NAME)); + } else { + return defaultValue; + } + } + + @Override + public final long get(String name, long defaultValue) { + if (metadata.containsKey(name)) { + byte[] bytes = metadata.get(name); + return ByteBuffer.wrap(bytes).getLong(); + } else { + return defaultValue; + } + } + + @Override + public final boolean contains(String name) { + return metadata.containsKey(name); + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return isMetadataEqual(metadata, ((DefaultContentMetadata) o).metadata); + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 0; + for (Entry<String, byte[]> entry : metadata.entrySet()) { + result += entry.getKey().hashCode() ^ Arrays.hashCode(entry.getValue()); + } + hashCode = result; + } + return hashCode; + } + + private static boolean isMetadataEqual(Map<String, byte[]> first, Map<String, byte[]> second) { + if (first.size() != second.size()) { + return false; + } + for (Entry<String, byte[]> entry : first.entrySet()) { + byte[] value = entry.getValue(); + byte[] otherValue = second.get(entry.getKey()); + if (!Arrays.equals(value, otherValue)) { + return false; + } + } + return true; + } + + private static Map<String, byte[]> applyMutations( + Map<String, byte[]> otherMetadata, ContentMetadataMutations mutations) { + HashMap<String, byte[]> metadata = new HashMap<>(otherMetadata); + removeValues(metadata, mutations.getRemovedValues()); + addValues(metadata, mutations.getEditedValues()); + return metadata; + } + + private static void removeValues(HashMap<String, byte[]> metadata, List<String> names) { + for (int i = 0; i < names.size(); i++) { + metadata.remove(names.get(i)); + } + } + + private static void addValues(HashMap<String, byte[]> metadata, Map<String, Object> values) { + for (String name : values.keySet()) { + metadata.put(name, getBytes(values.get(name))); + } + } + + private static byte[] getBytes(Object value) { + if (value instanceof Long) { + return ByteBuffer.allocate(8).putLong((Long) value).array(); + } else if (value instanceof String) { + return ((String) value).getBytes(Charset.forName(C.UTF8_NAME)); + } else if (value instanceof byte[]) { + return (byte[]) value; + } else { + throw new IllegalArgumentException(); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java new file mode 100644 index 0000000000..56eff06b25 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/LeastRecentlyUsedCacheEvictor.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache.Cache.CacheException; +import java.util.TreeSet; + +/** Evicts least recently used cache files first. */ +public final class LeastRecentlyUsedCacheEvictor implements CacheEvictor { + + private final long maxBytes; + private final TreeSet<CacheSpan> leastRecentlyUsed; + + private long currentSize; + + public LeastRecentlyUsedCacheEvictor(long maxBytes) { + this.maxBytes = maxBytes; + this.leastRecentlyUsed = new TreeSet<>(LeastRecentlyUsedCacheEvictor::compare); + } + + @Override + public boolean requiresCacheSpanTouches() { + return true; + } + + @Override + public void onCacheInitialized() { + // Do nothing. + } + + @Override + public void onStartFile(Cache cache, String key, long position, long length) { + if (length != C.LENGTH_UNSET) { + evictCache(cache, length); + } + } + + @Override + public void onSpanAdded(Cache cache, CacheSpan span) { + leastRecentlyUsed.add(span); + currentSize += span.length; + evictCache(cache, 0); + } + + @Override + public void onSpanRemoved(Cache cache, CacheSpan span) { + leastRecentlyUsed.remove(span); + currentSize -= span.length; + } + + @Override + public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { + onSpanRemoved(cache, oldSpan); + onSpanAdded(cache, newSpan); + } + + private void evictCache(Cache cache, long requiredSpace) { + while (currentSize + requiredSpace > maxBytes && !leastRecentlyUsed.isEmpty()) { + try { + cache.removeSpan(leastRecentlyUsed.first()); + } catch (CacheException e) { + // do nothing. + } + } + } + + private static int compare(CacheSpan lhs, CacheSpan rhs) { + long lastTouchTimestampDelta = lhs.lastTouchTimestamp - rhs.lastTouchTimestamp; + if (lastTouchTimestampDelta == 0) { + // Use the standard compareTo method as a tie-break. + return lhs.compareTo(rhs); + } + return lhs.lastTouchTimestamp < rhs.lastTouchTimestamp ? -1 : 1; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java new file mode 100644 index 0000000000..75c1ad0a09 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/NoOpCacheEvictor.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + + +/** + * Evictor that doesn't ever evict cache files. + * + * Warning: Using this evictor might have unforeseeable consequences if cache + * size is not managed elsewhere. + */ +public final class NoOpCacheEvictor implements CacheEvictor { + + @Override + public boolean requiresCacheSpanTouches() { + return false; + } + + @Override + public void onCacheInitialized() { + // Do nothing. + } + + @Override + public void onStartFile(Cache cache, String key, long position, long maxLength) { + // Do nothing. + } + + @Override + public void onSpanAdded(Cache cache, CacheSpan span) { + // Do nothing. + } + + @Override + public void onSpanRemoved(Cache cache, CacheSpan span) { + // Do nothing. + } + + @Override + public void onSpanTouched(Cache cache, CacheSpan oldSpan, CacheSpan newSpan) { + // Do nothing. + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java new file mode 100644 index 0000000000..9e36c48d88 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCache.java @@ -0,0 +1,812 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import android.os.ConditionVariable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseIOException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.database.DatabaseProvider; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.NavigableSet; +import java.util.Random; +import java.util.Set; +import java.util.TreeSet; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** + * A {@link Cache} implementation that maintains an in-memory representation. + * + * <p>Only one instance of SimpleCache is allowed for a given directory at a given time. + * + * <p>To delete a SimpleCache, use {@link #delete(File, DatabaseProvider)} rather than deleting the + * directory and its contents directly. This is necessary to ensure that associated index data is + * also removed. + */ +public final class SimpleCache implements Cache { + + private static final String TAG = "SimpleCache"; + /** + * Cache files are distributed between a number of subdirectories. This helps to avoid poor + * performance in cases where the performance of the underlying file system (e.g. FAT32) scales + * badly with the number of files per directory. See + * https://github.com/google/ExoPlayer/issues/4253. + */ + private static final int SUBDIRECTORY_COUNT = 10; + + private static final String UID_FILE_SUFFIX = ".uid"; + + private static final HashSet<File> lockedCacheDirs = new HashSet<>(); + + private final File cacheDir; + private final CacheEvictor evictor; + private final CachedContentIndex contentIndex; + @Nullable private final CacheFileMetadataIndex fileIndex; + private final HashMap<String, ArrayList<Listener>> listeners; + private final Random random; + private final boolean touchCacheSpans; + + private long uid; + private long totalSpace; + private boolean released; + private @MonotonicNonNull CacheException initializationException; + + /** + * Returns whether {@code cacheFolder} is locked by a {@link SimpleCache} instance. To unlock the + * folder the {@link SimpleCache} instance should be released. + */ + public static synchronized boolean isCacheFolderLocked(File cacheFolder) { + return lockedCacheDirs.contains(cacheFolder.getAbsoluteFile()); + } + + /** + * Deletes all content belonging to a cache instance. + * + * <p>This method may be slow and shouldn't normally be called on the main thread. + * + * @param cacheDir The cache directory. + * @param databaseProvider The database in which index data is stored, or {@code null} if the + * cache used a legacy index. + */ + @WorkerThread + public static void delete(File cacheDir, @Nullable DatabaseProvider databaseProvider) { + if (!cacheDir.exists()) { + return; + } + + File[] files = cacheDir.listFiles(); + if (files == null) { + cacheDir.delete(); + return; + } + + if (databaseProvider != null) { + // Make a best effort to read the cache UID and delete associated index data before deleting + // cache directory itself. + long uid = loadUid(files); + if (uid != UID_UNSET) { + try { + CacheFileMetadataIndex.delete(databaseProvider, uid); + } catch (DatabaseIOException e) { + Log.w(TAG, "Failed to delete file metadata: " + uid); + } + try { + CachedContentIndex.delete(databaseProvider, uid); + } catch (DatabaseIOException e) { + Log.w(TAG, "Failed to delete file metadata: " + uid); + } + } + } + + Util.recursiveDelete(cacheDir); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence + * the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. For download use cases where cache eviction should not + * occur, use {@link NoOpCacheEvictor}. + * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance. + */ + @Deprecated + public SimpleCache(File cacheDir, CacheEvictor evictor) { + this(cacheDir, evictor, null, false); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence + * the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. For download use cases where cache eviction should not + * occur, use {@link NoOpCacheEvictor}. + * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC. + * The key must be 16 bytes long. + * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance. + */ + @Deprecated + @SuppressWarnings("deprecation") + public SimpleCache(File cacheDir, CacheEvictor evictor, @Nullable byte[] secretKey) { + this(cacheDir, evictor, secretKey, secretKey != null); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence + * the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. For download use cases where cache eviction should not + * occur, use {@link NoOpCacheEvictor}. + * @param secretKey If not null, cache keys will be stored encrypted on filesystem using AES/CBC. + * The key must be 16 bytes long. + * @param encrypt Whether the index will be encrypted when written. Must be false if {@code + * secretKey} is null. + * @deprecated Use a constructor that takes a {@link DatabaseProvider} for improved performance. + */ + @Deprecated + public SimpleCache( + File cacheDir, CacheEvictor evictor, @Nullable byte[] secretKey, boolean encrypt) { + this( + cacheDir, + evictor, + /* databaseProvider= */ null, + secretKey, + encrypt, + /* preferLegacyIndex= */ true); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the directory. Hence + * the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. For download use cases where cache eviction should not + * occur, use {@link NoOpCacheEvictor}. + * @param databaseProvider Provides the database in which the cache index is stored. + */ + public SimpleCache(File cacheDir, CacheEvictor evictor, DatabaseProvider databaseProvider) { + this( + cacheDir, + evictor, + databaseProvider, + /* legacyIndexSecretKey= */ null, + /* legacyIndexEncrypt= */ false, + /* preferLegacyIndex= */ false); + } + + /** + * Constructs the cache. The cache will delete any unrecognized files from the cache directory. + * Hence the directory cannot be used to store other files. + * + * @param cacheDir A dedicated cache directory. + * @param evictor The evictor to be used. For download use cases where cache eviction should not + * occur, use {@link NoOpCacheEvictor}. + * @param databaseProvider Provides the database in which the cache index is stored, or {@code + * null} to use a legacy index. Using a database index is highly recommended for performance + * reasons. + * @param legacyIndexSecretKey A 16 byte AES key for reading, and optionally writing, the legacy + * index. Not used by the database index, however should still be provided when using the + * database index in cases where upgrading from the legacy index may be necessary. + * @param legacyIndexEncrypt Whether to encrypt when writing to the legacy index. Must be {@code + * false} if {@code legacyIndexSecretKey} is {@code null}. Not used by the database index. + * @param preferLegacyIndex Whether to use the legacy index even if a {@code databaseProvider} is + * provided. Should be {@code false} in nearly all cases. Setting this to {@code true} is only + * useful for downgrading from the database index back to the legacy index. + */ + public SimpleCache( + File cacheDir, + CacheEvictor evictor, + @Nullable DatabaseProvider databaseProvider, + @Nullable byte[] legacyIndexSecretKey, + boolean legacyIndexEncrypt, + boolean preferLegacyIndex) { + this( + cacheDir, + evictor, + new CachedContentIndex( + databaseProvider, + cacheDir, + legacyIndexSecretKey, + legacyIndexEncrypt, + preferLegacyIndex), + databaseProvider != null && !preferLegacyIndex + ? new CacheFileMetadataIndex(databaseProvider) + : null); + } + + /* package */ SimpleCache( + File cacheDir, + CacheEvictor evictor, + CachedContentIndex contentIndex, + @Nullable CacheFileMetadataIndex fileIndex) { + if (!lockFolder(cacheDir)) { + throw new IllegalStateException("Another SimpleCache instance uses the folder: " + cacheDir); + } + + this.cacheDir = cacheDir; + this.evictor = evictor; + this.contentIndex = contentIndex; + this.fileIndex = fileIndex; + listeners = new HashMap<>(); + random = new Random(); + touchCacheSpans = evictor.requiresCacheSpanTouches(); + uid = UID_UNSET; + + // Start cache initialization. + final ConditionVariable conditionVariable = new ConditionVariable(); + new Thread("SimpleCache.initialize()") { + @Override + public void run() { + synchronized (SimpleCache.this) { + conditionVariable.open(); + initialize(); + SimpleCache.this.evictor.onCacheInitialized(); + } + } + }.start(); + conditionVariable.block(); + } + + /** + * Checks whether the cache was initialized successfully. + * + * @throws CacheException If an error occurred during initialization. + */ + public synchronized void checkInitialization() throws CacheException { + if (initializationException != null) { + throw initializationException; + } + } + + @Override + public synchronized long getUid() { + return uid; + } + + @Override + public synchronized void release() { + if (released) { + return; + } + listeners.clear(); + removeStaleSpans(); + try { + contentIndex.store(); + } catch (IOException e) { + Log.e(TAG, "Storing index file failed", e); + } finally { + unlockFolder(cacheDir); + released = true; + } + } + + @Override + public synchronized NavigableSet<CacheSpan> addListener(String key, Listener listener) { + Assertions.checkState(!released); + ArrayList<Listener> listenersForKey = listeners.get(key); + if (listenersForKey == null) { + listenersForKey = new ArrayList<>(); + listeners.put(key, listenersForKey); + } + listenersForKey.add(listener); + return getCachedSpans(key); + } + + @Override + public synchronized void removeListener(String key, Listener listener) { + if (released) { + return; + } + ArrayList<Listener> listenersForKey = listeners.get(key); + if (listenersForKey != null) { + listenersForKey.remove(listener); + if (listenersForKey.isEmpty()) { + listeners.remove(key); + } + } + } + + @NonNull + @Override + public synchronized NavigableSet<CacheSpan> getCachedSpans(String key) { + Assertions.checkState(!released); + CachedContent cachedContent = contentIndex.get(key); + return cachedContent == null || cachedContent.isEmpty() + ? new TreeSet<>() + : new TreeSet<CacheSpan>(cachedContent.getSpans()); + } + + @Override + public synchronized Set<String> getKeys() { + Assertions.checkState(!released); + return new HashSet<>(contentIndex.getKeys()); + } + + @Override + public synchronized long getCacheSpace() { + Assertions.checkState(!released); + return totalSpace; + } + + @Override + public synchronized CacheSpan startReadWrite(String key, long position) + throws InterruptedException, CacheException { + Assertions.checkState(!released); + checkInitialization(); + + while (true) { + CacheSpan span = startReadWriteNonBlocking(key, position); + if (span != null) { + return span; + } else { + // Lock not available. We'll be woken up when a span is added, or when a locked span is + // released. We'll be able to make progress when either: + // 1. A span is added for the requested key that covers the requested position, in which + // case a read can be started. + // 2. The lock for the requested key is released, in which case a write can be started. + wait(); + } + } + } + + @Override + @Nullable + public synchronized CacheSpan startReadWriteNonBlocking(String key, long position) + throws CacheException { + Assertions.checkState(!released); + checkInitialization(); + + SimpleCacheSpan span = getSpan(key, position); + + if (span.isCached) { + // Read case. + return touchSpan(key, span); + } + + CachedContent cachedContent = contentIndex.getOrAdd(key); + if (!cachedContent.isLocked()) { + // Write case. + cachedContent.setLocked(true); + return span; + } + + // Lock not available. + return null; + } + + @Override + public synchronized File startFile(String key, long position, long length) throws CacheException { + Assertions.checkState(!released); + checkInitialization(); + + CachedContent cachedContent = contentIndex.get(key); + Assertions.checkNotNull(cachedContent); + Assertions.checkState(cachedContent.isLocked()); + if (!cacheDir.exists()) { + // For some reason the cache directory doesn't exist. Make a best effort to create it. + cacheDir.mkdirs(); + removeStaleSpans(); + } + evictor.onStartFile(this, key, position, length); + // Randomly distribute files into subdirectories with a uniform distribution. + File fileDir = new File(cacheDir, Integer.toString(random.nextInt(SUBDIRECTORY_COUNT))); + if (!fileDir.exists()) { + fileDir.mkdir(); + } + long lastTouchTimestamp = System.currentTimeMillis(); + return SimpleCacheSpan.getCacheFile(fileDir, cachedContent.id, position, lastTouchTimestamp); + } + + @Override + public synchronized void commitFile(File file, long length) throws CacheException { + Assertions.checkState(!released); + if (!file.exists()) { + return; + } + if (length == 0) { + file.delete(); + return; + } + + SimpleCacheSpan span = + Assertions.checkNotNull(SimpleCacheSpan.createCacheEntry(file, length, contentIndex)); + CachedContent cachedContent = Assertions.checkNotNull(contentIndex.get(span.key)); + Assertions.checkState(cachedContent.isLocked()); + + // Check if the span conflicts with the set content length + long contentLength = ContentMetadata.getContentLength(cachedContent.getMetadata()); + if (contentLength != C.LENGTH_UNSET) { + Assertions.checkState((span.position + span.length) <= contentLength); + } + + if (fileIndex != null) { + String fileName = file.getName(); + try { + fileIndex.set(fileName, span.length, span.lastTouchTimestamp); + } catch (IOException e) { + throw new CacheException(e); + } + } + addSpan(span); + try { + contentIndex.store(); + } catch (IOException e) { + throw new CacheException(e); + } + notifyAll(); + } + + @Override + public synchronized void releaseHoleSpan(CacheSpan holeSpan) { + Assertions.checkState(!released); + CachedContent cachedContent = contentIndex.get(holeSpan.key); + Assertions.checkNotNull(cachedContent); + Assertions.checkState(cachedContent.isLocked()); + cachedContent.setLocked(false); + contentIndex.maybeRemove(cachedContent.key); + notifyAll(); + } + + @Override + public synchronized void removeSpan(CacheSpan span) { + Assertions.checkState(!released); + removeSpanInternal(span); + } + + @Override + public synchronized boolean isCached(String key, long position, long length) { + Assertions.checkState(!released); + CachedContent cachedContent = contentIndex.get(key); + return cachedContent != null && cachedContent.getCachedBytesLength(position, length) >= length; + } + + @Override + public synchronized long getCachedLength(String key, long position, long length) { + Assertions.checkState(!released); + CachedContent cachedContent = contentIndex.get(key); + return cachedContent != null ? cachedContent.getCachedBytesLength(position, length) : -length; + } + + @Override + public synchronized void applyContentMetadataMutations( + String key, ContentMetadataMutations mutations) throws CacheException { + Assertions.checkState(!released); + checkInitialization(); + + contentIndex.applyContentMetadataMutations(key, mutations); + try { + contentIndex.store(); + } catch (IOException e) { + throw new CacheException(e); + } + } + + @Override + public synchronized ContentMetadata getContentMetadata(String key) { + Assertions.checkState(!released); + return contentIndex.getContentMetadata(key); + } + + /** Ensures that the cache's in-memory representation has been initialized. */ + private void initialize() { + if (!cacheDir.exists()) { + if (!cacheDir.mkdirs()) { + String message = "Failed to create cache directory: " + cacheDir; + Log.e(TAG, message); + initializationException = new CacheException(message); + return; + } + } + + File[] files = cacheDir.listFiles(); + if (files == null) { + String message = "Failed to list cache directory files: " + cacheDir; + Log.e(TAG, message); + initializationException = new CacheException(message); + return; + } + + uid = loadUid(files); + if (uid == UID_UNSET) { + try { + uid = createUid(cacheDir); + } catch (IOException e) { + String message = "Failed to create cache UID: " + cacheDir; + Log.e(TAG, message, e); + initializationException = new CacheException(message, e); + return; + } + } + + try { + contentIndex.initialize(uid); + if (fileIndex != null) { + fileIndex.initialize(uid); + Map<String, CacheFileMetadata> fileMetadata = fileIndex.getAll(); + loadDirectory(cacheDir, /* isRoot= */ true, files, fileMetadata); + fileIndex.removeAll(fileMetadata.keySet()); + } else { + loadDirectory(cacheDir, /* isRoot= */ true, files, /* fileMetadata= */ null); + } + } catch (IOException e) { + String message = "Failed to initialize cache indices: " + cacheDir; + Log.e(TAG, message, e); + initializationException = new CacheException(message, e); + return; + } + + contentIndex.removeEmpty(); + try { + contentIndex.store(); + } catch (IOException e) { + Log.e(TAG, "Storing index file failed", e); + } + } + + /** + * Loads a cache directory. If the root directory is passed, also loads any subdirectories. + * + * @param directory The directory. + * @param isRoot Whether the directory is the root directory. + * @param files The files belonging to the directory. + * @param fileMetadata A mutable map containing cache file metadata, keyed by file name. The map + * is modified by removing entries for all loaded files. When the method call returns, the map + * will contain only metadata that was unused. May be null if no file metadata is available. + */ + private void loadDirectory( + File directory, + boolean isRoot, + @Nullable File[] files, + @Nullable Map<String, CacheFileMetadata> fileMetadata) { + if (files == null || files.length == 0) { + // Either (a) directory isn't really a directory (b) it's empty, or (c) listing files failed. + if (!isRoot) { + // For (a) and (b) deletion is the desired result. For (c) it will be a no-op if the + // directory is non-empty, so there's no harm in trying. + directory.delete(); + } + return; + } + for (File file : files) { + String fileName = file.getName(); + if (isRoot && fileName.indexOf('.') == -1) { + loadDirectory(file, /* isRoot= */ false, file.listFiles(), fileMetadata); + } else { + if (isRoot + && (CachedContentIndex.isIndexFile(fileName) || fileName.endsWith(UID_FILE_SUFFIX))) { + // Skip expected UID and index files in the root directory. + continue; + } + long length = C.LENGTH_UNSET; + long lastTouchTimestamp = C.TIME_UNSET; + CacheFileMetadata metadata = fileMetadata != null ? fileMetadata.remove(fileName) : null; + if (metadata != null) { + length = metadata.length; + lastTouchTimestamp = metadata.lastTouchTimestamp; + } + SimpleCacheSpan span = + SimpleCacheSpan.createCacheEntry(file, length, lastTouchTimestamp, contentIndex); + if (span != null) { + addSpan(span); + } else { + file.delete(); + } + } + } + } + + /** + * Touches a cache span, returning the updated result. If the evictor does not require cache spans + * to be touched, then this method does nothing and the span is returned without modification. + * + * @param key The key of the span being touched. + * @param span The span being touched. + * @return The updated span. + */ + private SimpleCacheSpan touchSpan(String key, SimpleCacheSpan span) { + if (!touchCacheSpans) { + return span; + } + String fileName = Assertions.checkNotNull(span.file).getName(); + long length = span.length; + long lastTouchTimestamp = System.currentTimeMillis(); + boolean updateFile = false; + if (fileIndex != null) { + try { + fileIndex.set(fileName, length, lastTouchTimestamp); + } catch (IOException e) { + Log.w(TAG, "Failed to update index with new touch timestamp."); + } + } else { + // Updating the file itself to incorporate the new last touch timestamp is much slower than + // updating the file index. Hence we only update the file if we don't have a file index. + updateFile = true; + } + SimpleCacheSpan newSpan = + contentIndex.get(key).setLastTouchTimestamp(span, lastTouchTimestamp, updateFile); + notifySpanTouched(span, newSpan); + return newSpan; + } + + /** + * Returns the cache span corresponding to the provided lookup span. + * + * <p>If the lookup position is contained by an existing entry in the cache, then the returned + * span defines the file in which the data is stored. If the lookup position is not contained by + * an existing entry, then the returned span defines the maximum extents of the hole in the cache. + * + * @param key The key of the span being requested. + * @param position The position of the span being requested. + * @return The corresponding cache {@link SimpleCacheSpan}. + */ + private SimpleCacheSpan getSpan(String key, long position) { + CachedContent cachedContent = contentIndex.get(key); + if (cachedContent == null) { + return SimpleCacheSpan.createOpenHole(key, position); + } + while (true) { + SimpleCacheSpan span = cachedContent.getSpan(position); + if (span.isCached && span.file.length() != span.length) { + // The file has been modified or deleted underneath us. It's likely that other files will + // have been modified too, so scan the whole in-memory representation. + removeStaleSpans(); + continue; + } + return span; + } + } + + /** + * Adds a cached span to the in-memory representation. + * + * @param span The span to be added. + */ + private void addSpan(SimpleCacheSpan span) { + contentIndex.getOrAdd(span.key).addSpan(span); + totalSpace += span.length; + notifySpanAdded(span); + } + + private void removeSpanInternal(CacheSpan span) { + CachedContent cachedContent = contentIndex.get(span.key); + if (cachedContent == null || !cachedContent.removeSpan(span)) { + return; + } + totalSpace -= span.length; + if (fileIndex != null) { + String fileName = span.file.getName(); + try { + fileIndex.remove(fileName); + } catch (IOException e) { + // This will leave a stale entry in the file index. It will be removed next time the cache + // is initialized. + Log.w(TAG, "Failed to remove file index entry for: " + fileName); + } + } + contentIndex.maybeRemove(cachedContent.key); + notifySpanRemoved(span); + } + + /** + * Scans all of the cached spans in the in-memory representation, removing any for which the + * underlying file lengths no longer match. + */ + private void removeStaleSpans() { + ArrayList<CacheSpan> spansToBeRemoved = new ArrayList<>(); + for (CachedContent cachedContent : contentIndex.getAll()) { + for (CacheSpan span : cachedContent.getSpans()) { + if (span.file.length() != span.length) { + spansToBeRemoved.add(span); + } + } + } + for (int i = 0; i < spansToBeRemoved.size(); i++) { + removeSpanInternal(spansToBeRemoved.get(i)); + } + } + + private void notifySpanRemoved(CacheSpan span) { + ArrayList<Listener> keyListeners = listeners.get(span.key); + if (keyListeners != null) { + for (int i = keyListeners.size() - 1; i >= 0; i--) { + keyListeners.get(i).onSpanRemoved(this, span); + } + } + evictor.onSpanRemoved(this, span); + } + + private void notifySpanAdded(SimpleCacheSpan span) { + ArrayList<Listener> keyListeners = listeners.get(span.key); + if (keyListeners != null) { + for (int i = keyListeners.size() - 1; i >= 0; i--) { + keyListeners.get(i).onSpanAdded(this, span); + } + } + evictor.onSpanAdded(this, span); + } + + private void notifySpanTouched(SimpleCacheSpan oldSpan, CacheSpan newSpan) { + ArrayList<Listener> keyListeners = listeners.get(oldSpan.key); + if (keyListeners != null) { + for (int i = keyListeners.size() - 1; i >= 0; i--) { + keyListeners.get(i).onSpanTouched(this, oldSpan, newSpan); + } + } + evictor.onSpanTouched(this, oldSpan, newSpan); + } + + /** + * Loads the cache UID from the files belonging to the root directory. + * + * @param files The files belonging to the root directory. + * @return The loaded UID, or {@link #UID_UNSET} if a UID has not yet been created. + */ + private static long loadUid(File[] files) { + for (File file : files) { + String fileName = file.getName(); + if (fileName.endsWith(UID_FILE_SUFFIX)) { + try { + return parseUid(fileName); + } catch (NumberFormatException e) { + // This should never happen, but if it does delete the malformed UID file and continue. + Log.e(TAG, "Malformed UID file: " + file); + file.delete(); + } + } + } + return UID_UNSET; + } + + @SuppressWarnings("TrulyRandom") + private static long createUid(File directory) throws IOException { + // Generate a non-negative UID. + long uid = new SecureRandom().nextLong(); + uid = uid == Long.MIN_VALUE ? 0 : Math.abs(uid); + // Persist it as a file. + String hexUid = Long.toString(uid, /* radix= */ 16); + File hexUidFile = new File(directory, hexUid + UID_FILE_SUFFIX); + if (!hexUidFile.createNewFile()) { + // False means that the file already exists, so this should never happen. + throw new IOException("Failed to create UID file: " + hexUidFile); + } + return uid; + } + + private static long parseUid(String fileName) { + return Long.parseLong(fileName.substring(0, fileName.indexOf('.')), /* radix= */ 16); + } + + private static synchronized boolean lockFolder(File cacheDir) { + return lockedCacheDirs.add(cacheDir.getAbsoluteFile()); + } + + private static synchronized void unlockFolder(File cacheDir) { + lockedCacheDirs.remove(cacheDir.getAbsoluteFile()); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java new file mode 100644 index 0000000000..6e7bec301f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/cache/SimpleCacheSpan.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.cache; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.io.File; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** This class stores span metadata in filename. */ +/* package */ final class SimpleCacheSpan extends CacheSpan { + + /* package */ static final String COMMON_SUFFIX = ".exo"; + + private static final String SUFFIX = ".v3" + COMMON_SUFFIX; + private static final Pattern CACHE_FILE_PATTERN_V1 = Pattern.compile( + "^(.+)\\.(\\d+)\\.(\\d+)\\.v1\\.exo$", Pattern.DOTALL); + private static final Pattern CACHE_FILE_PATTERN_V2 = Pattern.compile( + "^(.+)\\.(\\d+)\\.(\\d+)\\.v2\\.exo$", Pattern.DOTALL); + private static final Pattern CACHE_FILE_PATTERN_V3 = Pattern.compile( + "^(\\d+)\\.(\\d+)\\.(\\d+)\\.v3\\.exo$", Pattern.DOTALL); + + /** + * Returns a new {@link File} instance from {@code cacheDir}, {@code id}, {@code position}, {@code + * timestamp}. + * + * @param cacheDir The parent abstract pathname. + * @param id The cache file id. + * @param position The position of the stored data in the original stream. + * @param timestamp The file timestamp. + * @return The cache file. + */ + public static File getCacheFile(File cacheDir, int id, long position, long timestamp) { + return new File(cacheDir, id + "." + position + "." + timestamp + SUFFIX); + } + + /** + * Creates a lookup span. + * + * @param key The cache key. + * @param position The position of the {@link CacheSpan} in the original stream. + * @return The span. + */ + public static SimpleCacheSpan createLookup(String key, long position) { + return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null); + } + + /** + * Creates an open hole span. + * + * @param key The cache key. + * @param position The position of the {@link CacheSpan} in the original stream. + * @return The span. + */ + public static SimpleCacheSpan createOpenHole(String key, long position) { + return new SimpleCacheSpan(key, position, C.LENGTH_UNSET, C.TIME_UNSET, null); + } + + /** + * Creates a closed hole span. + * + * @param key The cache key. + * @param position The position of the {@link CacheSpan} in the original stream. + * @param length The length of the {@link CacheSpan}. + * @return The span. + */ + public static SimpleCacheSpan createClosedHole(String key, long position, long length) { + return new SimpleCacheSpan(key, position, length, C.TIME_UNSET, null); + } + + /** + * Creates a cache span from an underlying cache file. Upgrades the file if necessary. + * + * @param file The cache file. + * @param length The length of the cache file in bytes, or {@link C#LENGTH_UNSET} to query the + * underlying file system. Querying the underlying file system can be expensive, so callers + * that already know the length of the file should pass it explicitly. + * @return The span, or null if the file name is not correctly formatted, or if the id is not + * present in the content index, or if the length is 0. + */ + @Nullable + public static SimpleCacheSpan createCacheEntry(File file, long length, CachedContentIndex index) { + return createCacheEntry(file, length, /* lastTouchTimestamp= */ C.TIME_UNSET, index); + } + + /** + * Creates a cache span from an underlying cache file. Upgrades the file if necessary. + * + * @param file The cache file. + * @param length The length of the cache file in bytes, or {@link C#LENGTH_UNSET} to query the + * underlying file system. Querying the underlying file system can be expensive, so callers + * that already know the length of the file should pass it explicitly. + * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} to use the file + * timestamp. + * @return The span, or null if the file name is not correctly formatted, or if the id is not + * present in the content index, or if the length is 0. + */ + @Nullable + public static SimpleCacheSpan createCacheEntry( + File file, long length, long lastTouchTimestamp, CachedContentIndex index) { + String name = file.getName(); + if (!name.endsWith(SUFFIX)) { + @Nullable File upgradedFile = upgradeFile(file, index); + if (upgradedFile == null) { + return null; + } + file = upgradedFile; + name = file.getName(); + } + + Matcher matcher = CACHE_FILE_PATTERN_V3.matcher(name); + if (!matcher.matches()) { + return null; + } + + int id = Integer.parseInt(matcher.group(1)); + String key = index.getKeyForId(id); + if (key == null) { + return null; + } + + if (length == C.LENGTH_UNSET) { + length = file.length(); + } + if (length == 0) { + return null; + } + + long position = Long.parseLong(matcher.group(2)); + if (lastTouchTimestamp == C.TIME_UNSET) { + lastTouchTimestamp = Long.parseLong(matcher.group(3)); + } + return new SimpleCacheSpan(key, position, length, lastTouchTimestamp, file); + } + + /** + * Upgrades the cache file if it is created by an earlier version of {@link SimpleCache}. + * + * @param file The cache file. + * @param index Cached content index. + * @return Upgraded cache file or {@code null} if the file name is not correctly formatted or the + * file can not be renamed. + */ + @Nullable + private static File upgradeFile(File file, CachedContentIndex index) { + String key; + String filename = file.getName(); + Matcher matcher = CACHE_FILE_PATTERN_V2.matcher(filename); + if (matcher.matches()) { + key = Util.unescapeFileName(matcher.group(1)); + if (key == null) { + return null; + } + } else { + matcher = CACHE_FILE_PATTERN_V1.matcher(filename); + if (!matcher.matches()) { + return null; + } + key = matcher.group(1); // Keys were not escaped in version 1. + } + + File newCacheFile = + getCacheFile( + Assertions.checkStateNotNull(file.getParentFile()), + index.assignIdForKey(key), + Long.parseLong(matcher.group(2)), + Long.parseLong(matcher.group(3))); + if (!file.renameTo(newCacheFile)) { + return null; + } + return newCacheFile; + } + + /** + * @param key The cache key. + * @param position The position of the {@link CacheSpan} in the original stream. + * @param length The length of the {@link CacheSpan}, or {@link C#LENGTH_UNSET} if this is an + * open-ended hole. + * @param lastTouchTimestamp The last touch timestamp, or {@link C#TIME_UNSET} if {@link + * #isCached} is false. + * @param file The file corresponding to this {@link CacheSpan}, or null if it's a hole. + */ + private SimpleCacheSpan( + String key, long position, long length, long lastTouchTimestamp, @Nullable File file) { + super(key, position, length, lastTouchTimestamp, file); + } + + /** + * Returns a copy of this CacheSpan with a new file and last touch timestamp. + * + * @param file The new file. + * @param lastTouchTimestamp The new last touch time. + * @return A copy with the new file and last touch timestamp. + * @throws IllegalStateException If called on a non-cached span (i.e. {@link #isCached} is false). + */ + public SimpleCacheSpan copyWithFileAndLastTouchTimestamp(File file, long lastTouchTimestamp) { + Assertions.checkState(isCached); + return new SimpleCacheSpan(key, position, length, lastTouchTimestamp, file); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java new file mode 100644 index 0000000000..4c6be98157 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSink.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.crypto; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSink; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import java.io.IOException; +import javax.crypto.Cipher; + +/** + * A wrapping {@link DataSink} that encrypts the data being consumed. + */ +public final class AesCipherDataSink implements DataSink { + + private final DataSink wrappedDataSink; + private final byte[] secretKey; + @Nullable private final byte[] scratch; + + @Nullable private AesFlushingCipher cipher; + + /** + * Create an instance whose {@code write} methods have the side effect of overwriting the input + * {@code data}. Use this constructor for maximum efficiency in the case that there is no + * requirement for the input data arrays to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink) { + this(secretKey, wrappedDataSink, null); + } + + /** + * Create an instance whose {@code write} methods are free of side effects. Use this constructor + * when the input data arrays are required to remain unchanged. + * + * @param secretKey The key data. + * @param wrappedDataSink The wrapped {@link DataSink}. + * @param scratch Scratch space. Data is encrypted into this array before being written to the + * wrapped {@link DataSink}. It should be of appropriate size for the expected writes. If a + * write is larger than the size of this array the write will still succeed, but multiple + * cipher calls will be required to complete the operation. If {@code null} then encryption + * will overwrite the input {@code data}. + */ + public AesCipherDataSink(byte[] secretKey, DataSink wrappedDataSink, @Nullable byte[] scratch) { + this.wrappedDataSink = wrappedDataSink; + this.secretKey = secretKey; + this.scratch = scratch; + } + + @Override + public void open(DataSpec dataSpec) throws IOException { + wrappedDataSink.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.ENCRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + } + + @Override + public void write(byte[] data, int offset, int length) throws IOException { + if (scratch == null) { + // In-place mode. Writes over the input data. + castNonNull(cipher).updateInPlace(data, offset, length); + wrappedDataSink.write(data, offset, length); + } else { + // Use scratch space. The original data remains intact. + int bytesProcessed = 0; + while (bytesProcessed < length) { + int bytesToProcess = Math.min(length - bytesProcessed, scratch.length); + castNonNull(cipher) + .update(data, offset + bytesProcessed, bytesToProcess, scratch, /* outOffset= */ 0); + wrappedDataSink.write(scratch, /* offset= */ 0, bytesToProcess); + bytesProcessed += bytesToProcess; + } + } + } + + @Override + public void close() throws IOException { + cipher = null; + wrappedDataSink.close(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java new file mode 100644 index 0000000000..0b0687b57e --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesCipherDataSource.java @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.crypto; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.net.Uri; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSpec; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.TransferListener; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import javax.crypto.Cipher; + +/** + * A {@link DataSource} that decrypts the data read from an upstream source. + */ +public final class AesCipherDataSource implements DataSource { + + private final DataSource upstream; + private final byte[] secretKey; + + @Nullable private AesFlushingCipher cipher; + + public AesCipherDataSource(byte[] secretKey, DataSource upstream) { + this.upstream = upstream; + this.secretKey = secretKey; + } + + @Override + public void addTransferListener(TransferListener transferListener) { + upstream.addTransferListener(transferListener); + } + + @Override + public long open(DataSpec dataSpec) throws IOException { + long dataLength = upstream.open(dataSpec); + long nonce = CryptoUtil.getFNV64Hash(dataSpec.key); + cipher = new AesFlushingCipher(Cipher.DECRYPT_MODE, secretKey, nonce, + dataSpec.absoluteStreamPosition); + return dataLength; + } + + @Override + public int read(byte[] data, int offset, int readLength) throws IOException { + if (readLength == 0) { + return 0; + } + int read = upstream.read(data, offset, readLength); + if (read == C.RESULT_END_OF_INPUT) { + return C.RESULT_END_OF_INPUT; + } + castNonNull(cipher).updateInPlace(data, offset, read); + return read; + } + + @Override + @Nullable + public Uri getUri() { + return upstream.getUri(); + } + + @Override + public Map<String, List<String>> getResponseHeaders() { + return upstream.getResponseHeaders(); + } + + @Override + public void close() throws IOException { + cipher = null; + upstream.close(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java new file mode 100644 index 0000000000..985a6dcf24 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/AesFlushingCipher.java @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.crypto; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import javax.crypto.Cipher; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.ShortBufferException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +/** + * A flushing variant of a AES/CTR/NoPadding {@link Cipher}. + * + * Unlike a regular {@link Cipher}, the update methods of this class are guaranteed to process all + * of the bytes input (and hence output the same number of bytes). + */ +public final class AesFlushingCipher { + + private final Cipher cipher; + private final int blockSize; + private final byte[] zerosBlock; + private final byte[] flushedBlock; + + private int pendingXorBytes; + + public AesFlushingCipher(int mode, byte[] secretKey, long nonce, long offset) { + try { + cipher = Cipher.getInstance("AES/CTR/NoPadding"); + blockSize = cipher.getBlockSize(); + zerosBlock = new byte[blockSize]; + flushedBlock = new byte[blockSize]; + long counter = offset / blockSize; + int startPadding = (int) (offset % blockSize); + cipher.init( + mode, + new SecretKeySpec(secretKey, Util.splitAtFirst(cipher.getAlgorithm(), "/")[0]), + new IvParameterSpec(getInitializationVector(nonce, counter))); + if (startPadding != 0) { + updateInPlace(new byte[startPadding], 0, startPadding); + } + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException + | InvalidAlgorithmParameterException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + public void updateInPlace(byte[] data, int offset, int length) { + update(data, offset, length, data, offset); + } + + public void update(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + // If we previously flushed the cipher by inputting zeros up to a block boundary, then we need + // to manually transform the data that actually ended the block. See the comment below for more + // details. + while (pendingXorBytes > 0) { + out[outOffset] = (byte) (in[inOffset] ^ flushedBlock[blockSize - pendingXorBytes]); + outOffset++; + inOffset++; + pendingXorBytes--; + length--; + if (length == 0) { + return; + } + } + + // Do the bulk of the update. + int written = nonFlushingUpdate(in, inOffset, length, out, outOffset); + if (length == written) { + return; + } + + // We need to finish the block to flush out the remaining bytes. We do so by inputting zeros, + // so that the corresponding bytes output by the cipher are those that would have been XORed + // against the real end-of-block data to transform it. We store these bytes so that we can + // perform the transformation manually in the case of a subsequent call to this method with + // the real data. + int bytesToFlush = length - written; + Assertions.checkState(bytesToFlush < blockSize); + outOffset += written; + pendingXorBytes = blockSize - bytesToFlush; + written = nonFlushingUpdate(zerosBlock, 0, pendingXorBytes, flushedBlock, 0); + Assertions.checkState(written == blockSize); + // The first part of xorBytes contains the flushed data, which we copy out. The remainder + // contains the bytes that will be needed for manual transformation in a subsequent call. + for (int i = 0; i < bytesToFlush; i++) { + out[outOffset++] = flushedBlock[i]; + } + } + + private int nonFlushingUpdate(byte[] in, int inOffset, int length, byte[] out, int outOffset) { + try { + return cipher.update(in, inOffset, length, out, outOffset); + } catch (ShortBufferException e) { + // Should never happen. + throw new RuntimeException(e); + } + } + + private byte[] getInitializationVector(long nonce, long counter) { + return ByteBuffer.allocate(16).putLong(nonce).putLong(counter).array(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java new file mode 100644 index 0000000000..a4904b9285 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/upstream/crypto/CryptoUtil.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.crypto; + +import androidx.annotation.Nullable; + +/** + * Utility functions for the crypto package. + */ +/* package */ final class CryptoUtil { + + private CryptoUtil() {} + + /** + * Returns the hash value of the input as a long using the 64 bit FNV-1a hash function. The hash + * values produced by this function are less likely to collide than those produced by {@link + * #hashCode()}. + */ + public static long getFNV64Hash(@Nullable String input) { + if (input == null) { + return 0; + } + + long hash = 0; + for (int i = 0; i < input.length(); i++) { + hash ^= input.charAt(i); + // This is equivalent to hash *= 0x100000001b3 (the FNV magic prime number). + hash += (hash << 1) + (hash << 4) + (hash << 5) + (hash << 7) + (hash << 8) + (hash << 40); + } + return hash; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Assertions.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Assertions.java new file mode 100644 index 0000000000..361b895695 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Assertions.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.Looper; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; + +/** + * Provides methods for asserting the truth of expressions and properties. + */ +public final class Assertions { + + private Assertions() {} + + /** + * Throws {@link IllegalArgumentException} if {@code expression} evaluates to false. + * + * @param expression The expression to evaluate. + * @throws IllegalArgumentException If {@code expression} is false. + */ + public static void checkArgument(boolean expression) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && !expression) { + throw new IllegalArgumentException(); + } + } + + /** + * Throws {@link IllegalArgumentException} if {@code expression} evaluates to false. + * + * @param expression The expression to evaluate. + * @param errorMessage The exception message if an exception is thrown. The message is converted + * to a {@link String} using {@link String#valueOf(Object)}. + * @throws IllegalArgumentException If {@code expression} is false. + */ + public static void checkArgument(boolean expression, Object errorMessage) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && !expression) { + throw new IllegalArgumentException(String.valueOf(errorMessage)); + } + } + + /** + * Throws {@link IndexOutOfBoundsException} if {@code index} falls outside the specified bounds. + * + * @param index The index to test. + * @param start The start of the allowed range (inclusive). + * @param limit The end of the allowed range (exclusive). + * @return The {@code index} that was validated. + * @throws IndexOutOfBoundsException If {@code index} falls outside the specified bounds. + */ + public static int checkIndex(int index, int start, int limit) { + if (index < start || index >= limit) { + throw new IndexOutOfBoundsException(); + } + return index; + } + + /** + * Throws {@link IllegalStateException} if {@code expression} evaluates to false. + * + * @param expression The expression to evaluate. + * @throws IllegalStateException If {@code expression} is false. + */ + public static void checkState(boolean expression) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && !expression) { + throw new IllegalStateException(); + } + } + + /** + * Throws {@link IllegalStateException} if {@code expression} evaluates to false. + * + * @param expression The expression to evaluate. + * @param errorMessage The exception message if an exception is thrown. The message is converted + * to a {@link String} using {@link String#valueOf(Object)}. + * @throws IllegalStateException If {@code expression} is false. + */ + public static void checkState(boolean expression, Object errorMessage) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && !expression) { + throw new IllegalStateException(String.valueOf(errorMessage)); + } + } + + /** + * Throws {@link IllegalStateException} if {@code reference} is null. + * + * @param <T> The type of the reference. + * @param reference The reference. + * @return The non-null reference that was validated. + * @throws IllegalStateException If {@code reference} is null. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull({"#1"}) + public static <T> T checkStateNotNull(@Nullable T reference) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && reference == null) { + throw new IllegalStateException(); + } + return reference; + } + + /** + * Throws {@link IllegalStateException} if {@code reference} is null. + * + * @param <T> The type of the reference. + * @param reference The reference. + * @param errorMessage The exception message to use if the check fails. The message is converted + * to a string using {@link String#valueOf(Object)}. + * @return The non-null reference that was validated. + * @throws IllegalStateException If {@code reference} is null. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull({"#1"}) + public static <T> T checkStateNotNull(@Nullable T reference, Object errorMessage) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && reference == null) { + throw new IllegalStateException(String.valueOf(errorMessage)); + } + return reference; + } + + /** + * Throws {@link NullPointerException} if {@code reference} is null. + * + * @param <T> The type of the reference. + * @param reference The reference. + * @return The non-null reference that was validated. + * @throws NullPointerException If {@code reference} is null. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull({"#1"}) + public static <T> T checkNotNull(@Nullable T reference) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && reference == null) { + throw new NullPointerException(); + } + return reference; + } + + /** + * Throws {@link NullPointerException} if {@code reference} is null. + * + * @param <T> The type of the reference. + * @param reference The reference. + * @param errorMessage The exception message to use if the check fails. The message is converted + * to a string using {@link String#valueOf(Object)}. + * @return The non-null reference that was validated. + * @throws NullPointerException If {@code reference} is null. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull({"#1"}) + public static <T> T checkNotNull(@Nullable T reference, Object errorMessage) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && reference == null) { + throw new NullPointerException(String.valueOf(errorMessage)); + } + return reference; + } + + /** + * Throws {@link IllegalArgumentException} if {@code string} is null or zero length. + * + * @param string The string to check. + * @return The non-null, non-empty string that was validated. + * @throws IllegalArgumentException If {@code string} is null or 0-length. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull({"#1"}) + public static String checkNotEmpty(@Nullable String string) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && TextUtils.isEmpty(string)) { + throw new IllegalArgumentException(); + } + return string; + } + + /** + * Throws {@link IllegalArgumentException} if {@code string} is null or zero length. + * + * @param string The string to check. + * @param errorMessage The exception message to use if the check fails. The message is converted + * to a string using {@link String#valueOf(Object)}. + * @return The non-null, non-empty string that was validated. + * @throws IllegalArgumentException If {@code string} is null or 0-length. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull({"#1"}) + public static String checkNotEmpty(@Nullable String string, Object errorMessage) { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && TextUtils.isEmpty(string)) { + throw new IllegalArgumentException(String.valueOf(errorMessage)); + } + return string; + } + + /** + * Throws {@link IllegalStateException} if the calling thread is not the application's main + * thread. + * + * @throws IllegalStateException If the calling thread is not the application's main thread. + */ + public static void checkMainThread() { + if (ExoPlayerLibraryInfo.ASSERTIONS_ENABLED && Looper.myLooper() != Looper.getMainLooper()) { + throw new IllegalStateException("Not in applications main thread"); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/AtomicFile.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/AtomicFile.java new file mode 100644 index 0000000000..d868a7d22a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/AtomicFile.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * A helper class for performing atomic operations on a file by creating a backup file until a write + * has successfully completed. + * + * <p>Atomic file guarantees file integrity by ensuring that a file has been completely written and + * synced to disk before removing its backup. As long as the backup file exists, the original file + * is considered to be invalid (left over from a previous attempt to write the file). + * + * <p>Atomic file does not confer any file locking semantics. Do not use this class when the file + * may be accessed or modified concurrently by multiple threads or processes. The caller is + * responsible for ensuring appropriate mutual exclusion invariants whenever it accesses the file. + */ +public final class AtomicFile { + + private static final String TAG = "AtomicFile"; + + private final File baseName; + private final File backupName; + + /** + * Create a new AtomicFile for a file located at the given File path. The secondary backup file + * will be the same file path with ".bak" appended. + */ + public AtomicFile(File baseName) { + this.baseName = baseName; + backupName = new File(baseName.getPath() + ".bak"); + } + + /** Returns whether the file or its backup exists. */ + public boolean exists() { + return baseName.exists() || backupName.exists(); + } + + /** Delete the atomic file. This deletes both the base and backup files. */ + public void delete() { + baseName.delete(); + backupName.delete(); + } + + /** + * Start a new write operation on the file. This returns an {@link OutputStream} to which you can + * write the new file data. If the whole data is written successfully you <em>must</em> call + * {@link #endWrite(OutputStream)}. On failure you should call {@link OutputStream#close()} + * only to free up resources used by it. + * + * <p>Example usage: + * + * <pre> + * DataOutputStream dataOutput = null; + * try { + * OutputStream outputStream = atomicFile.startWrite(); + * dataOutput = new DataOutputStream(outputStream); // Wrapper stream + * dataOutput.write(data1); + * dataOutput.write(data2); + * atomicFile.endWrite(dataOutput); // Pass wrapper stream + * } finally{ + * if (dataOutput != null) { + * dataOutput.close(); + * } + * } + * </pre> + * + * <p>Note that if another thread is currently performing a write, this will simply replace + * whatever that thread is writing with the new file being written by this thread, and when the + * other thread finishes the write the new write operation will no longer be safe (or will be + * lost). You must do your own threading protection for access to AtomicFile. + */ + public OutputStream startWrite() throws IOException { + // Rename the current file so it may be used as a backup during the next read + if (baseName.exists()) { + if (!backupName.exists()) { + if (!baseName.renameTo(backupName)) { + Log.w(TAG, "Couldn't rename file " + baseName + " to backup file " + backupName); + } + } else { + baseName.delete(); + } + } + OutputStream str; + try { + str = new AtomicFileOutputStream(baseName); + } catch (FileNotFoundException e) { + File parent = baseName.getParentFile(); + if (parent == null || !parent.mkdirs()) { + throw new IOException("Couldn't create " + baseName, e); + } + // Try again now that we've created the parent directory. + try { + str = new AtomicFileOutputStream(baseName); + } catch (FileNotFoundException e2) { + throw new IOException("Couldn't create " + baseName, e2); + } + } + return str; + } + + /** + * Call when you have successfully finished writing to the stream returned by {@link + * #startWrite()}. This will close, sync, and commit the new data. The next attempt to read the + * atomic file will return the new file stream. + * + * @param str Outer-most wrapper OutputStream used to write to the stream returned by {@link + * #startWrite()}. + * @see #startWrite() + */ + public void endWrite(OutputStream str) throws IOException { + str.close(); + // If close() throws exception, the next line is skipped. + backupName.delete(); + } + + /** + * Open the atomic file for reading. If there previously was an incomplete write, this will roll + * back to the last good data before opening for read. + * + * <p>Note that if another thread is currently performing a write, this will incorrectly consider + * it to be in the state of a bad write and roll back, causing the new data currently being + * written to be dropped. You must do your own threading protection for access to AtomicFile. + */ + public InputStream openRead() throws FileNotFoundException { + restoreBackup(); + return new FileInputStream(baseName); + } + + private void restoreBackup() { + if (backupName.exists()) { + baseName.delete(); + backupName.renameTo(baseName); + } + } + + private static final class AtomicFileOutputStream extends OutputStream { + + private final FileOutputStream fileOutputStream; + private boolean closed = false; + + public AtomicFileOutputStream(File file) throws FileNotFoundException { + fileOutputStream = new FileOutputStream(file); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + flush(); + try { + fileOutputStream.getFD().sync(); + } catch (IOException e) { + Log.w(TAG, "Failed to sync file descriptor:", e); + } + fileOutputStream.close(); + } + + @Override + public void flush() throws IOException { + fileOutputStream.flush(); + } + + @Override + public void write(int b) throws IOException { + fileOutputStream.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + fileOutputStream.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + fileOutputStream.write(b, off, len); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Clock.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Clock.java new file mode 100644 index 0000000000..4247e1db7b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Clock.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.Handler; +import android.os.Looper; +import androidx.annotation.Nullable; + +/** + * An interface through which system clocks can be read and {@link HandlerWrapper}s created. The + * {@link #DEFAULT} implementation must be used for all non-test cases. + */ +public interface Clock { + + /** + * Default {@link Clock} to use for all non-test cases. + */ + Clock DEFAULT = new SystemClock(); + + /** @see android.os.SystemClock#elapsedRealtime() */ + long elapsedRealtime(); + + /** @see android.os.SystemClock#uptimeMillis() */ + long uptimeMillis(); + + /** @see android.os.SystemClock#sleep(long) */ + void sleep(long sleepTimeMs); + + /** + * Creates a {@link HandlerWrapper} using a specified looper and a specified callback for handling + * messages. + * + * @see Handler#Handler(Looper, Handler.Callback) + */ + HandlerWrapper createHandler(Looper looper, @Nullable Handler.Callback callback); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/CodecSpecificDataUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/CodecSpecificDataUtil.java new file mode 100644 index 0000000000..9c821c47c8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/CodecSpecificDataUtil.java @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.util.Pair; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import java.util.ArrayList; +import java.util.List; + +/** + * Provides static utility methods for manipulating various types of codec specific data. + */ +public final class CodecSpecificDataUtil { + + private static final byte[] NAL_START_CODE = new byte[] {0, 0, 0, 1}; + + private static final int AUDIO_SPECIFIC_CONFIG_FREQUENCY_INDEX_ARBITRARY = 0xF; + + private static final int[] AUDIO_SPECIFIC_CONFIG_SAMPLING_RATE_TABLE = new int[] { + 96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350 + }; + + private static final int AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID = -1; + /** + * In the channel configurations below, <A> indicates a single channel element; (A, B) indicates a + * channel pair element; and [A] indicates a low-frequency effects element. + * The speaker mapping short forms used are: + * - FC: front center + * - BC: back center + * - FL/FR: front left/right + * - FCL/FCR: front center left/right + * - FTL/FTR: front top left/right + * - SL/SR: back surround left/right + * - BL/BR: back left/right + * - LFE: low frequency effects + */ + private static final int[] AUDIO_SPECIFIC_CONFIG_CHANNEL_COUNT_TABLE = + new int[] { + 0, + 1, /* mono: <FC> */ + 2, /* stereo: (FL, FR) */ + 3, /* 3.0: <FC>, (FL, FR) */ + 4, /* 4.0: <FC>, (FL, FR), <BC> */ + 5, /* 5.0 back: <FC>, (FL, FR), (SL, SR) */ + 6, /* 5.1 back: <FC>, (FL, FR), (SL, SR), <BC>, [LFE] */ + 8, /* 7.1 wide back: <FC>, (FCL, FCR), (FL, FR), (SL, SR), [LFE] */ + AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID, + AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID, + AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID, + 7, /* 6.1: <FC>, (FL, FR), (SL, SR), <RC>, [LFE] */ + 8, /* 7.1: <FC>, (FL, FR), (SL, SR), (BL, BR), [LFE] */ + AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID, + 8, /* 7.1 top: <FC>, (FL, FR), (SL, SR), [LFE], (FTL, FTR) */ + AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID + }; + + // Advanced Audio Coding Low-Complexity profile. + private static final int AUDIO_OBJECT_TYPE_AAC_LC = 2; + // Spectral Band Replication. + private static final int AUDIO_OBJECT_TYPE_SBR = 5; + // Error Resilient Bit-Sliced Arithmetic Coding. + private static final int AUDIO_OBJECT_TYPE_ER_BSAC = 22; + // Parametric Stereo. + private static final int AUDIO_OBJECT_TYPE_PS = 29; + // Escape code for extended audio object types. + private static final int AUDIO_OBJECT_TYPE_ESCAPE = 31; + + private CodecSpecificDataUtil() {} + + /** + * Parses an AAC AudioSpecificConfig, as defined in ISO 14496-3 1.6.2.1 + * + * @param audioSpecificConfig A byte array containing the AudioSpecificConfig to parse. + * @return A pair consisting of the sample rate in Hz and the channel count. + * @throws ParserException If the AudioSpecificConfig cannot be parsed as it's not supported. + */ + public static Pair<Integer, Integer> parseAacAudioSpecificConfig(byte[] audioSpecificConfig) + throws ParserException { + return parseAacAudioSpecificConfig(new ParsableBitArray(audioSpecificConfig), false); + } + + /** + * Parses an AAC AudioSpecificConfig, as defined in ISO 14496-3 1.6.2.1 + * + * @param bitArray A {@link ParsableBitArray} containing the AudioSpecificConfig to parse. The + * position is advanced to the end of the AudioSpecificConfig. + * @param forceReadToEnd Whether the entire AudioSpecificConfig should be read. Required for + * knowing the length of the configuration payload. + * @return A pair consisting of the sample rate in Hz and the channel count. + * @throws ParserException If the AudioSpecificConfig cannot be parsed as it's not supported. + */ + public static Pair<Integer, Integer> parseAacAudioSpecificConfig( + ParsableBitArray bitArray, boolean forceReadToEnd) throws ParserException { + int audioObjectType = getAacAudioObjectType(bitArray); + int sampleRate = getAacSamplingFrequency(bitArray); + int channelConfiguration = bitArray.readBits(4); + if (audioObjectType == AUDIO_OBJECT_TYPE_SBR || audioObjectType == AUDIO_OBJECT_TYPE_PS) { + // For an AAC bitstream using spectral band replication (SBR) or parametric stereo (PS) with + // explicit signaling, we return the extension sampling frequency as the sample rate of the + // content; this is identical to the sample rate of the decoded output but may differ from + // the sample rate set above. + // Use the extensionSamplingFrequencyIndex. + sampleRate = getAacSamplingFrequency(bitArray); + audioObjectType = getAacAudioObjectType(bitArray); + if (audioObjectType == AUDIO_OBJECT_TYPE_ER_BSAC) { + // Use the extensionChannelConfiguration. + channelConfiguration = bitArray.readBits(4); + } + } + + if (forceReadToEnd) { + switch (audioObjectType) { + case 1: + case 2: + case 3: + case 4: + case 6: + case 7: + case 17: + case 19: + case 20: + case 21: + case 22: + case 23: + parseGaSpecificConfig(bitArray, audioObjectType, channelConfiguration); + break; + default: + throw new ParserException("Unsupported audio object type: " + audioObjectType); + } + switch (audioObjectType) { + case 17: + case 19: + case 20: + case 21: + case 22: + case 23: + int epConfig = bitArray.readBits(2); + if (epConfig == 2 || epConfig == 3) { + throw new ParserException("Unsupported epConfig: " + epConfig); + } + break; + } + } + // For supported containers, bits_to_decode() is always 0. + int channelCount = AUDIO_SPECIFIC_CONFIG_CHANNEL_COUNT_TABLE[channelConfiguration]; + Assertions.checkArgument(channelCount != AUDIO_SPECIFIC_CONFIG_CHANNEL_CONFIGURATION_INVALID); + return Pair.create(sampleRate, channelCount); + } + + /** + * Builds a simple HE-AAC LC AudioSpecificConfig, as defined in ISO 14496-3 1.6.2.1 + * + * @param sampleRate The sample rate in Hz. + * @param channelCount The channel count. + * @return The AudioSpecificConfig. + */ + public static byte[] buildAacLcAudioSpecificConfig(int sampleRate, int channelCount) { + int sampleRateIndex = C.INDEX_UNSET; + for (int i = 0; i < AUDIO_SPECIFIC_CONFIG_SAMPLING_RATE_TABLE.length; ++i) { + if (sampleRate == AUDIO_SPECIFIC_CONFIG_SAMPLING_RATE_TABLE[i]) { + sampleRateIndex = i; + } + } + int channelConfig = C.INDEX_UNSET; + for (int i = 0; i < AUDIO_SPECIFIC_CONFIG_CHANNEL_COUNT_TABLE.length; ++i) { + if (channelCount == AUDIO_SPECIFIC_CONFIG_CHANNEL_COUNT_TABLE[i]) { + channelConfig = i; + } + } + if (sampleRate == C.INDEX_UNSET || channelConfig == C.INDEX_UNSET) { + throw new IllegalArgumentException( + "Invalid sample rate or number of channels: " + sampleRate + ", " + channelCount); + } + return buildAacAudioSpecificConfig(AUDIO_OBJECT_TYPE_AAC_LC, sampleRateIndex, channelConfig); + } + + /** + * Builds a simple AudioSpecificConfig, as defined in ISO 14496-3 1.6.2.1 + * + * @param audioObjectType The audio object type. + * @param sampleRateIndex The sample rate index. + * @param channelConfig The channel configuration. + * @return The AudioSpecificConfig. + */ + public static byte[] buildAacAudioSpecificConfig(int audioObjectType, int sampleRateIndex, + int channelConfig) { + byte[] specificConfig = new byte[2]; + specificConfig[0] = (byte) (((audioObjectType << 3) & 0xF8) | ((sampleRateIndex >> 1) & 0x07)); + specificConfig[1] = (byte) (((sampleRateIndex << 7) & 0x80) | ((channelConfig << 3) & 0x78)); + return specificConfig; + } + + /** + * Parses an ALAC AudioSpecificConfig (i.e. an <a + * href="https://github.com/macosforge/alac/blob/master/ALACMagicCookieDescription.txt">ALACSpecificConfig</a>). + * + * @param audioSpecificConfig A byte array containing the AudioSpecificConfig to parse. + * @return A pair consisting of the sample rate in Hz and the channel count. + */ + public static Pair<Integer, Integer> parseAlacAudioSpecificConfig(byte[] audioSpecificConfig) { + ParsableByteArray byteArray = new ParsableByteArray(audioSpecificConfig); + byteArray.setPosition(9); + int channelCount = byteArray.readUnsignedByte(); + byteArray.setPosition(20); + int sampleRate = byteArray.readUnsignedIntToInt(); + return Pair.create(sampleRate, channelCount); + } + + /** + * Builds an RFC 6381 AVC codec string using the provided parameters. + * + * @param profileIdc The encoding profile. + * @param constraintsFlagsAndReservedZero2Bits The constraint flags followed by the reserved zero + * 2 bits, all contained in the least significant byte of the integer. + * @param levelIdc The encoding level. + * @return An RFC 6381 AVC codec string built using the provided parameters. + */ + public static String buildAvcCodecString( + int profileIdc, int constraintsFlagsAndReservedZero2Bits, int levelIdc) { + return String.format( + "avc1.%02X%02X%02X", profileIdc, constraintsFlagsAndReservedZero2Bits, levelIdc); + } + + /** + * Constructs a NAL unit consisting of the NAL start code followed by the specified data. + * + * @param data An array containing the data that should follow the NAL start code. + * @param offset The start offset into {@code data}. + * @param length The number of bytes to copy from {@code data} + * @return The constructed NAL unit. + */ + public static byte[] buildNalUnit(byte[] data, int offset, int length) { + byte[] nalUnit = new byte[length + NAL_START_CODE.length]; + System.arraycopy(NAL_START_CODE, 0, nalUnit, 0, NAL_START_CODE.length); + System.arraycopy(data, offset, nalUnit, NAL_START_CODE.length, length); + return nalUnit; + } + + /** + * Splits an array of NAL units. + * + * <p>If the input consists of NAL start code delimited units, then the returned array consists of + * the split NAL units, each of which is still prefixed with the NAL start code. For any other + * input, null is returned. + * + * @param data An array of data. + * @return The individual NAL units, or null if the input did not consist of NAL start code + * delimited units. + */ + public static @Nullable byte[][] splitNalUnits(byte[] data) { + if (!isNalStartCode(data, 0)) { + // data does not consist of NAL start code delimited units. + return null; + } + List<Integer> starts = new ArrayList<>(); + int nalUnitIndex = 0; + do { + starts.add(nalUnitIndex); + nalUnitIndex = findNalStartCode(data, nalUnitIndex + NAL_START_CODE.length); + } while (nalUnitIndex != C.INDEX_UNSET); + byte[][] split = new byte[starts.size()][]; + for (int i = 0; i < starts.size(); i++) { + int startIndex = starts.get(i); + int endIndex = i < starts.size() - 1 ? starts.get(i + 1) : data.length; + byte[] nal = new byte[endIndex - startIndex]; + System.arraycopy(data, startIndex, nal, 0, nal.length); + split[i] = nal; + } + return split; + } + + /** + * Finds the next occurrence of the NAL start code from a given index. + * + * @param data The data in which to search. + * @param index The first index to test. + * @return The index of the first byte of the found start code, or {@link C#INDEX_UNSET}. + */ + private static int findNalStartCode(byte[] data, int index) { + int endIndex = data.length - NAL_START_CODE.length; + for (int i = index; i <= endIndex; i++) { + if (isNalStartCode(data, i)) { + return i; + } + } + return C.INDEX_UNSET; + } + + /** + * Tests whether there exists a NAL start code at a given index. + * + * @param data The data. + * @param index The index to test. + * @return Whether there exists a start code that begins at {@code index}. + */ + private static boolean isNalStartCode(byte[] data, int index) { + if (data.length - index <= NAL_START_CODE.length) { + return false; + } + for (int j = 0; j < NAL_START_CODE.length; j++) { + if (data[index + j] != NAL_START_CODE[j]) { + return false; + } + } + return true; + } + + /** + * Returns the AAC audio object type as specified in 14496-3 (2005) Table 1.14. + * + * @param bitArray The bit array containing the audio specific configuration. + * @return The audio object type. + */ + private static int getAacAudioObjectType(ParsableBitArray bitArray) { + int audioObjectType = bitArray.readBits(5); + if (audioObjectType == AUDIO_OBJECT_TYPE_ESCAPE) { + audioObjectType = 32 + bitArray.readBits(6); + } + return audioObjectType; + } + + /** + * Returns the AAC sampling frequency (or extension sampling frequency) as specified in 14496-3 + * (2005) Table 1.13. + * + * @param bitArray The bit array containing the audio specific configuration. + * @return The sampling frequency. + */ + private static int getAacSamplingFrequency(ParsableBitArray bitArray) { + int samplingFrequency; + int frequencyIndex = bitArray.readBits(4); + if (frequencyIndex == AUDIO_SPECIFIC_CONFIG_FREQUENCY_INDEX_ARBITRARY) { + samplingFrequency = bitArray.readBits(24); + } else { + Assertions.checkArgument(frequencyIndex < 13); + samplingFrequency = AUDIO_SPECIFIC_CONFIG_SAMPLING_RATE_TABLE[frequencyIndex]; + } + return samplingFrequency; + } + + private static void parseGaSpecificConfig(ParsableBitArray bitArray, int audioObjectType, + int channelConfiguration) { + bitArray.skipBits(1); // frameLengthFlag. + boolean dependsOnCoreDecoder = bitArray.readBit(); + if (dependsOnCoreDecoder) { + bitArray.skipBits(14); // coreCoderDelay. + } + boolean extensionFlag = bitArray.readBit(); + if (channelConfiguration == 0) { + throw new UnsupportedOperationException(); // TODO: Implement programConfigElement(); + } + if (audioObjectType == 6 || audioObjectType == 20) { + bitArray.skipBits(3); // layerNr. + } + if (extensionFlag) { + if (audioObjectType == 22) { + bitArray.skipBits(16); // numOfSubFrame (5), layer_length(11). + } + if (audioObjectType == 17 || audioObjectType == 19 || audioObjectType == 20 + || audioObjectType == 23) { + // aacSectionDataResilienceFlag, aacScalefactorDataResilienceFlag, + // aacSpectralDataResilienceFlag. + bitArray.skipBits(3); + } + bitArray.skipBits(1); // extensionFlag3. + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ColorParser.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ColorParser.java new file mode 100644 index 0000000000..31b81fe16f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ColorParser.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.text.TextUtils; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parser for color expressions found in styling formats, e.g. TTML and CSS. + * + * @see <a href="https://w3c.github.io/webvtt/#styling">WebVTT CSS Styling</a> + * @see <a href="https://www.w3.org/TR/ttml2/">Timed Text Markup Language 2 (TTML2) - 10.3.5</a> + */ +public final class ColorParser { + + private static final String RGB = "rgb"; + private static final String RGBA = "rgba"; + + private static final Pattern RGB_PATTERN = Pattern.compile( + "^rgb\\((\\d{1,3}),(\\d{1,3}),(\\d{1,3})\\)$"); + + private static final Pattern RGBA_PATTERN_INT_ALPHA = Pattern.compile( + "^rgba\\((\\d{1,3}),(\\d{1,3}),(\\d{1,3}),(\\d{1,3})\\)$"); + + private static final Pattern RGBA_PATTERN_FLOAT_ALPHA = Pattern.compile( + "^rgba\\((\\d{1,3}),(\\d{1,3}),(\\d{1,3}),(\\d*\\.?\\d*?)\\)$"); + + private static final Map<String, Integer> COLOR_MAP; + + /** + * Parses a TTML color expression. + * + * @param colorExpression The color expression. + * @return The parsed ARGB color. + */ + public static int parseTtmlColor(String colorExpression) { + return parseColorInternal(colorExpression, false); + } + + /** + * Parses a CSS color expression. + * + * @param colorExpression The color expression. + * @return The parsed ARGB color. + */ + public static int parseCssColor(String colorExpression) { + return parseColorInternal(colorExpression, true); + } + + private static int parseColorInternal(String colorExpression, boolean alphaHasFloatFormat) { + Assertions.checkArgument(!TextUtils.isEmpty(colorExpression)); + colorExpression = colorExpression.replace(" ", ""); + if (colorExpression.charAt(0) == '#') { + // Parse using Long to avoid failure when colorExpression is greater than #7FFFFFFF. + int color = (int) Long.parseLong(colorExpression.substring(1), 16); + if (colorExpression.length() == 7) { + // Set the alpha value + color |= 0xFF000000; + } else if (colorExpression.length() == 9) { + // We have #RRGGBBAA, but we need #AARRGGBB + color = ((color & 0xFF) << 24) | (color >>> 8); + } else { + throw new IllegalArgumentException(); + } + return color; + } else if (colorExpression.startsWith(RGBA)) { + Matcher matcher = (alphaHasFloatFormat ? RGBA_PATTERN_FLOAT_ALPHA : RGBA_PATTERN_INT_ALPHA) + .matcher(colorExpression); + if (matcher.matches()) { + return argb( + alphaHasFloatFormat ? (int) (255 * Float.parseFloat(matcher.group(4))) + : Integer.parseInt(matcher.group(4), 10), + Integer.parseInt(matcher.group(1), 10), + Integer.parseInt(matcher.group(2), 10), + Integer.parseInt(matcher.group(3), 10) + ); + } + } else if (colorExpression.startsWith(RGB)) { + Matcher matcher = RGB_PATTERN.matcher(colorExpression); + if (matcher.matches()) { + return rgb( + Integer.parseInt(matcher.group(1), 10), + Integer.parseInt(matcher.group(2), 10), + Integer.parseInt(matcher.group(3), 10) + ); + } + } else { + // we use our own color map + Integer color = COLOR_MAP.get(Util.toLowerInvariant(colorExpression)); + if (color != null) { + return color; + } + } + throw new IllegalArgumentException(); + } + + private static int argb(int alpha, int red, int green, int blue) { + return (alpha << 24) | (red << 16) | (green << 8) | blue; + } + + private static int rgb(int red, int green, int blue) { + return argb(0xFF, red, green, blue); + } + + static { + COLOR_MAP = new HashMap<>(); + COLOR_MAP.put("aliceblue", 0xFFF0F8FF); + COLOR_MAP.put("antiquewhite", 0xFFFAEBD7); + COLOR_MAP.put("aqua", 0xFF00FFFF); + COLOR_MAP.put("aquamarine", 0xFF7FFFD4); + COLOR_MAP.put("azure", 0xFFF0FFFF); + COLOR_MAP.put("beige", 0xFFF5F5DC); + COLOR_MAP.put("bisque", 0xFFFFE4C4); + COLOR_MAP.put("black", 0xFF000000); + COLOR_MAP.put("blanchedalmond", 0xFFFFEBCD); + COLOR_MAP.put("blue", 0xFF0000FF); + COLOR_MAP.put("blueviolet", 0xFF8A2BE2); + COLOR_MAP.put("brown", 0xFFA52A2A); + COLOR_MAP.put("burlywood", 0xFFDEB887); + COLOR_MAP.put("cadetblue", 0xFF5F9EA0); + COLOR_MAP.put("chartreuse", 0xFF7FFF00); + COLOR_MAP.put("chocolate", 0xFFD2691E); + COLOR_MAP.put("coral", 0xFFFF7F50); + COLOR_MAP.put("cornflowerblue", 0xFF6495ED); + COLOR_MAP.put("cornsilk", 0xFFFFF8DC); + COLOR_MAP.put("crimson", 0xFFDC143C); + COLOR_MAP.put("cyan", 0xFF00FFFF); + COLOR_MAP.put("darkblue", 0xFF00008B); + COLOR_MAP.put("darkcyan", 0xFF008B8B); + COLOR_MAP.put("darkgoldenrod", 0xFFB8860B); + COLOR_MAP.put("darkgray", 0xFFA9A9A9); + COLOR_MAP.put("darkgreen", 0xFF006400); + COLOR_MAP.put("darkgrey", 0xFFA9A9A9); + COLOR_MAP.put("darkkhaki", 0xFFBDB76B); + COLOR_MAP.put("darkmagenta", 0xFF8B008B); + COLOR_MAP.put("darkolivegreen", 0xFF556B2F); + COLOR_MAP.put("darkorange", 0xFFFF8C00); + COLOR_MAP.put("darkorchid", 0xFF9932CC); + COLOR_MAP.put("darkred", 0xFF8B0000); + COLOR_MAP.put("darksalmon", 0xFFE9967A); + COLOR_MAP.put("darkseagreen", 0xFF8FBC8F); + COLOR_MAP.put("darkslateblue", 0xFF483D8B); + COLOR_MAP.put("darkslategray", 0xFF2F4F4F); + COLOR_MAP.put("darkslategrey", 0xFF2F4F4F); + COLOR_MAP.put("darkturquoise", 0xFF00CED1); + COLOR_MAP.put("darkviolet", 0xFF9400D3); + COLOR_MAP.put("deeppink", 0xFFFF1493); + COLOR_MAP.put("deepskyblue", 0xFF00BFFF); + COLOR_MAP.put("dimgray", 0xFF696969); + COLOR_MAP.put("dimgrey", 0xFF696969); + COLOR_MAP.put("dodgerblue", 0xFF1E90FF); + COLOR_MAP.put("firebrick", 0xFFB22222); + COLOR_MAP.put("floralwhite", 0xFFFFFAF0); + COLOR_MAP.put("forestgreen", 0xFF228B22); + COLOR_MAP.put("fuchsia", 0xFFFF00FF); + COLOR_MAP.put("gainsboro", 0xFFDCDCDC); + COLOR_MAP.put("ghostwhite", 0xFFF8F8FF); + COLOR_MAP.put("gold", 0xFFFFD700); + COLOR_MAP.put("goldenrod", 0xFFDAA520); + COLOR_MAP.put("gray", 0xFF808080); + COLOR_MAP.put("green", 0xFF008000); + COLOR_MAP.put("greenyellow", 0xFFADFF2F); + COLOR_MAP.put("grey", 0xFF808080); + COLOR_MAP.put("honeydew", 0xFFF0FFF0); + COLOR_MAP.put("hotpink", 0xFFFF69B4); + COLOR_MAP.put("indianred", 0xFFCD5C5C); + COLOR_MAP.put("indigo", 0xFF4B0082); + COLOR_MAP.put("ivory", 0xFFFFFFF0); + COLOR_MAP.put("khaki", 0xFFF0E68C); + COLOR_MAP.put("lavender", 0xFFE6E6FA); + COLOR_MAP.put("lavenderblush", 0xFFFFF0F5); + COLOR_MAP.put("lawngreen", 0xFF7CFC00); + COLOR_MAP.put("lemonchiffon", 0xFFFFFACD); + COLOR_MAP.put("lightblue", 0xFFADD8E6); + COLOR_MAP.put("lightcoral", 0xFFF08080); + COLOR_MAP.put("lightcyan", 0xFFE0FFFF); + COLOR_MAP.put("lightgoldenrodyellow", 0xFFFAFAD2); + COLOR_MAP.put("lightgray", 0xFFD3D3D3); + COLOR_MAP.put("lightgreen", 0xFF90EE90); + COLOR_MAP.put("lightgrey", 0xFFD3D3D3); + COLOR_MAP.put("lightpink", 0xFFFFB6C1); + COLOR_MAP.put("lightsalmon", 0xFFFFA07A); + COLOR_MAP.put("lightseagreen", 0xFF20B2AA); + COLOR_MAP.put("lightskyblue", 0xFF87CEFA); + COLOR_MAP.put("lightslategray", 0xFF778899); + COLOR_MAP.put("lightslategrey", 0xFF778899); + COLOR_MAP.put("lightsteelblue", 0xFFB0C4DE); + COLOR_MAP.put("lightyellow", 0xFFFFFFE0); + COLOR_MAP.put("lime", 0xFF00FF00); + COLOR_MAP.put("limegreen", 0xFF32CD32); + COLOR_MAP.put("linen", 0xFFFAF0E6); + COLOR_MAP.put("magenta", 0xFFFF00FF); + COLOR_MAP.put("maroon", 0xFF800000); + COLOR_MAP.put("mediumaquamarine", 0xFF66CDAA); + COLOR_MAP.put("mediumblue", 0xFF0000CD); + COLOR_MAP.put("mediumorchid", 0xFFBA55D3); + COLOR_MAP.put("mediumpurple", 0xFF9370DB); + COLOR_MAP.put("mediumseagreen", 0xFF3CB371); + COLOR_MAP.put("mediumslateblue", 0xFF7B68EE); + COLOR_MAP.put("mediumspringgreen", 0xFF00FA9A); + COLOR_MAP.put("mediumturquoise", 0xFF48D1CC); + COLOR_MAP.put("mediumvioletred", 0xFFC71585); + COLOR_MAP.put("midnightblue", 0xFF191970); + COLOR_MAP.put("mintcream", 0xFFF5FFFA); + COLOR_MAP.put("mistyrose", 0xFFFFE4E1); + COLOR_MAP.put("moccasin", 0xFFFFE4B5); + COLOR_MAP.put("navajowhite", 0xFFFFDEAD); + COLOR_MAP.put("navy", 0xFF000080); + COLOR_MAP.put("oldlace", 0xFFFDF5E6); + COLOR_MAP.put("olive", 0xFF808000); + COLOR_MAP.put("olivedrab", 0xFF6B8E23); + COLOR_MAP.put("orange", 0xFFFFA500); + COLOR_MAP.put("orangered", 0xFFFF4500); + COLOR_MAP.put("orchid", 0xFFDA70D6); + COLOR_MAP.put("palegoldenrod", 0xFFEEE8AA); + COLOR_MAP.put("palegreen", 0xFF98FB98); + COLOR_MAP.put("paleturquoise", 0xFFAFEEEE); + COLOR_MAP.put("palevioletred", 0xFFDB7093); + COLOR_MAP.put("papayawhip", 0xFFFFEFD5); + COLOR_MAP.put("peachpuff", 0xFFFFDAB9); + COLOR_MAP.put("peru", 0xFFCD853F); + COLOR_MAP.put("pink", 0xFFFFC0CB); + COLOR_MAP.put("plum", 0xFFDDA0DD); + COLOR_MAP.put("powderblue", 0xFFB0E0E6); + COLOR_MAP.put("purple", 0xFF800080); + COLOR_MAP.put("rebeccapurple", 0xFF663399); + COLOR_MAP.put("red", 0xFFFF0000); + COLOR_MAP.put("rosybrown", 0xFFBC8F8F); + COLOR_MAP.put("royalblue", 0xFF4169E1); + COLOR_MAP.put("saddlebrown", 0xFF8B4513); + COLOR_MAP.put("salmon", 0xFFFA8072); + COLOR_MAP.put("sandybrown", 0xFFF4A460); + COLOR_MAP.put("seagreen", 0xFF2E8B57); + COLOR_MAP.put("seashell", 0xFFFFF5EE); + COLOR_MAP.put("sienna", 0xFFA0522D); + COLOR_MAP.put("silver", 0xFFC0C0C0); + COLOR_MAP.put("skyblue", 0xFF87CEEB); + COLOR_MAP.put("slateblue", 0xFF6A5ACD); + COLOR_MAP.put("slategray", 0xFF708090); + COLOR_MAP.put("slategrey", 0xFF708090); + COLOR_MAP.put("snow", 0xFFFFFAFA); + COLOR_MAP.put("springgreen", 0xFF00FF7F); + COLOR_MAP.put("steelblue", 0xFF4682B4); + COLOR_MAP.put("tan", 0xFFD2B48C); + COLOR_MAP.put("teal", 0xFF008080); + COLOR_MAP.put("thistle", 0xFFD8BFD8); + COLOR_MAP.put("tomato", 0xFFFF6347); + COLOR_MAP.put("transparent", 0x00000000); + COLOR_MAP.put("turquoise", 0xFF40E0D0); + COLOR_MAP.put("violet", 0xFFEE82EE); + COLOR_MAP.put("wheat", 0xFFF5DEB3); + COLOR_MAP.put("white", 0xFFFFFFFF); + COLOR_MAP.put("whitesmoke", 0xFFF5F5F5); + COLOR_MAP.put("yellow", 0xFFFFFF00); + COLOR_MAP.put("yellowgreen", 0xFF9ACD32); + } + + private ColorParser() { + // Prevent instantiation. + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ConditionVariable.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ConditionVariable.java new file mode 100644 index 0000000000..3866edced1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ConditionVariable.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +/** + * An interruptible condition variable whose {@link #open()} and {@link #close()} methods return + * whether they resulted in a change of state. + */ +public final class ConditionVariable { + + private boolean isOpen; + + /** + * Opens the condition and releases all threads that are blocked. + * + * @return True if the condition variable was opened. False if it was already open. + */ + public synchronized boolean open() { + if (isOpen) { + return false; + } + isOpen = true; + notifyAll(); + return true; + } + + /** + * Closes the condition. + * + * @return True if the condition variable was closed. False if it was already closed. + */ + public synchronized boolean close() { + boolean wasOpen = isOpen; + isOpen = false; + return wasOpen; + } + + /** + * Blocks until the condition is opened. + * + * @throws InterruptedException If the thread is interrupted. + */ + public synchronized void block() throws InterruptedException { + while (!isOpen) { + wait(); + } + } + + /** + * Blocks until the condition is opened or until {@code timeout} milliseconds have passed. + * + * @param timeout The maximum time to wait in milliseconds. + * @return True if the condition was opened, false if the call returns because of the timeout. + * @throws InterruptedException If the thread is interrupted. + */ + public synchronized boolean block(long timeout) throws InterruptedException { + long now = android.os.SystemClock.elapsedRealtime(); + long end = now + timeout; + while (!isOpen && now < end) { + wait(end - now); + now = android.os.SystemClock.elapsedRealtime(); + } + return isOpen; + } + + /** Returns whether the condition is opened. */ + public synchronized boolean isOpen() { + return isOpen; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EGLSurfaceTexture.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EGLSurfaceTexture.java new file mode 100644 index 0000000000..1f48f718b7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EGLSurfaceTexture.java @@ -0,0 +1,312 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.annotation.TargetApi; +import android.graphics.SurfaceTexture; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLSurface; +import android.opengl.GLES20; +import android.os.Handler; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Generates a {@link SurfaceTexture} using EGL/GLES functions. */ +@TargetApi(17) +public final class EGLSurfaceTexture implements SurfaceTexture.OnFrameAvailableListener, Runnable { + + /** Listener to be called when the texture image on {@link SurfaceTexture} has been updated. */ + public interface TextureImageListener { + /** Called when the {@link SurfaceTexture} receives a new frame from its image producer. */ + void onFrameAvailable(); + } + + /** + * Secure mode to be used by the EGL surface and context. One of {@link #SECURE_MODE_NONE}, {@link + * #SECURE_MODE_SURFACELESS_CONTEXT} or {@link #SECURE_MODE_PROTECTED_PBUFFER}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({SECURE_MODE_NONE, SECURE_MODE_SURFACELESS_CONTEXT, SECURE_MODE_PROTECTED_PBUFFER}) + public @interface SecureMode {} + + /** No secure EGL surface and context required. */ + public static final int SECURE_MODE_NONE = 0; + /** Creating a surfaceless, secured EGL context. */ + public static final int SECURE_MODE_SURFACELESS_CONTEXT = 1; + /** Creating a secure surface backed by a pixel buffer. */ + public static final int SECURE_MODE_PROTECTED_PBUFFER = 2; + + private static final int EGL_SURFACE_WIDTH = 1; + private static final int EGL_SURFACE_HEIGHT = 1; + + private static final int[] EGL_CONFIG_ATTRIBUTES = + new int[] { + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_DEPTH_SIZE, 0, + EGL14.EGL_CONFIG_CAVEAT, EGL14.EGL_NONE, + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT, + EGL14.EGL_NONE + }; + + private static final int EGL_PROTECTED_CONTENT_EXT = 0x32C0; + + /** A runtime exception to be thrown if some EGL operations failed. */ + public static final class GlException extends RuntimeException { + private GlException(String msg) { + super(msg); + } + } + + private final Handler handler; + private final int[] textureIdHolder; + @Nullable private final TextureImageListener callback; + + @Nullable private EGLDisplay display; + @Nullable private EGLContext context; + @Nullable private EGLSurface surface; + @Nullable private SurfaceTexture texture; + + /** + * @param handler The {@link Handler} that will be used to call {@link + * SurfaceTexture#updateTexImage()} to update images on the {@link SurfaceTexture}. Note that + * {@link #init(int)} has to be called on the same looper thread as the {@link Handler}'s + * looper. + */ + public EGLSurfaceTexture(Handler handler) { + this(handler, /* callback= */ null); + } + + /** + * @param handler The {@link Handler} that will be used to call {@link + * SurfaceTexture#updateTexImage()} to update images on the {@link SurfaceTexture}. Note that + * {@link #init(int)} has to be called on the same looper thread as the looper of the {@link + * Handler}. + * @param callback The {@link TextureImageListener} to be called when the texture image on {@link + * SurfaceTexture} has been updated. This callback will be called on the same handler thread + * as the {@code handler}. + */ + public EGLSurfaceTexture(Handler handler, @Nullable TextureImageListener callback) { + this.handler = handler; + this.callback = callback; + textureIdHolder = new int[1]; + } + + /** + * Initializes required EGL parameters and creates the {@link SurfaceTexture}. + * + * @param secureMode The {@link SecureMode} to be used for EGL surface. + */ + public void init(@SecureMode int secureMode) { + display = getDefaultDisplay(); + EGLConfig config = chooseEGLConfig(display); + context = createEGLContext(display, config, secureMode); + surface = createEGLSurface(display, config, context, secureMode); + generateTextureIds(textureIdHolder); + texture = new SurfaceTexture(textureIdHolder[0]); + texture.setOnFrameAvailableListener(this); + } + + /** Releases all allocated resources. */ + @SuppressWarnings({"nullness:argument.type.incompatible"}) + public void release() { + handler.removeCallbacks(this); + try { + if (texture != null) { + texture.release(); + GLES20.glDeleteTextures(1, textureIdHolder, 0); + } + } finally { + if (display != null && !display.equals(EGL14.EGL_NO_DISPLAY)) { + EGL14.eglMakeCurrent( + display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT); + } + if (surface != null && !surface.equals(EGL14.EGL_NO_SURFACE)) { + EGL14.eglDestroySurface(display, surface); + } + if (context != null) { + EGL14.eglDestroyContext(display, context); + } + // EGL14.eglReleaseThread could crash before Android K (see [internal: b/11327779]). + if (Util.SDK_INT >= 19) { + EGL14.eglReleaseThread(); + } + if (display != null && !display.equals(EGL14.EGL_NO_DISPLAY)) { + // Android is unusual in that it uses a reference-counted EGLDisplay. So for + // every eglInitialize() we need an eglTerminate(). + EGL14.eglTerminate(display); + } + display = null; + context = null; + surface = null; + texture = null; + } + } + + /** + * Returns the wrapped {@link SurfaceTexture}. This can only be called after {@link #init(int)}. + */ + public SurfaceTexture getSurfaceTexture() { + return Assertions.checkNotNull(texture); + } + + // SurfaceTexture.OnFrameAvailableListener + + @Override + public void onFrameAvailable(SurfaceTexture surfaceTexture) { + handler.post(this); + } + + // Runnable + + @Override + public void run() { + // Run on the provided handler thread when a new image frame is available. + dispatchOnFrameAvailable(); + if (texture != null) { + try { + texture.updateTexImage(); + } catch (RuntimeException e) { + // Ignore + } + } + } + + private void dispatchOnFrameAvailable() { + if (callback != null) { + callback.onFrameAvailable(); + } + } + + private static EGLDisplay getDefaultDisplay() { + EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + if (display == null) { + throw new GlException("eglGetDisplay failed"); + } + + int[] version = new int[2]; + boolean eglInitialized = + EGL14.eglInitialize(display, version, /* majorOffset= */ 0, version, /* minorOffset= */ 1); + if (!eglInitialized) { + throw new GlException("eglInitialize failed"); + } + return display; + } + + private static EGLConfig chooseEGLConfig(EGLDisplay display) { + EGLConfig[] configs = new EGLConfig[1]; + int[] numConfigs = new int[1]; + boolean success = + EGL14.eglChooseConfig( + display, + EGL_CONFIG_ATTRIBUTES, + /* attrib_listOffset= */ 0, + configs, + /* configsOffset= */ 0, + /* config_size= */ 1, + numConfigs, + /* num_configOffset= */ 0); + if (!success || numConfigs[0] <= 0 || configs[0] == null) { + throw new GlException( + Util.formatInvariant( + /* format= */ "eglChooseConfig failed: success=%b, numConfigs[0]=%d, configs[0]=%s", + success, numConfigs[0], configs[0])); + } + + return configs[0]; + } + + private static EGLContext createEGLContext( + EGLDisplay display, EGLConfig config, @SecureMode int secureMode) { + int[] glAttributes; + if (secureMode == SECURE_MODE_NONE) { + glAttributes = new int[] {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE}; + } else { + glAttributes = + new int[] { + EGL14.EGL_CONTEXT_CLIENT_VERSION, + 2, + EGL_PROTECTED_CONTENT_EXT, + EGL14.EGL_TRUE, + EGL14.EGL_NONE + }; + } + EGLContext context = + EGL14.eglCreateContext( + display, config, android.opengl.EGL14.EGL_NO_CONTEXT, glAttributes, 0); + if (context == null) { + throw new GlException("eglCreateContext failed"); + } + return context; + } + + private static EGLSurface createEGLSurface( + EGLDisplay display, EGLConfig config, EGLContext context, @SecureMode int secureMode) { + EGLSurface surface; + if (secureMode == SECURE_MODE_SURFACELESS_CONTEXT) { + surface = EGL14.EGL_NO_SURFACE; + } else { + int[] pbufferAttributes; + if (secureMode == SECURE_MODE_PROTECTED_PBUFFER) { + pbufferAttributes = + new int[] { + EGL14.EGL_WIDTH, + EGL_SURFACE_WIDTH, + EGL14.EGL_HEIGHT, + EGL_SURFACE_HEIGHT, + EGL_PROTECTED_CONTENT_EXT, + EGL14.EGL_TRUE, + EGL14.EGL_NONE + }; + } else { + pbufferAttributes = + new int[] { + EGL14.EGL_WIDTH, + EGL_SURFACE_WIDTH, + EGL14.EGL_HEIGHT, + EGL_SURFACE_HEIGHT, + EGL14.EGL_NONE + }; + } + surface = EGL14.eglCreatePbufferSurface(display, config, pbufferAttributes, /* offset= */ 0); + if (surface == null) { + throw new GlException("eglCreatePbufferSurface failed"); + } + } + + boolean eglMadeCurrent = + EGL14.eglMakeCurrent(display, /* draw= */ surface, /* read= */ surface, context); + if (!eglMadeCurrent) { + throw new GlException("eglMakeCurrent failed"); + } + return surface; + } + + private static void generateTextureIds(int[] textureIdHolder) { + GLES20.glGenTextures(/* n= */ 1, textureIdHolder, /* offset= */ 0); + GlUtil.checkGlError(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ErrorMessageProvider.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ErrorMessageProvider.java new file mode 100644 index 0000000000..0eca418cd8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ErrorMessageProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.util.Pair; + +/** Converts throwables into error codes and user readable error messages. */ +public interface ErrorMessageProvider<T extends Throwable> { + + /** + * Returns a pair consisting of an error code and a user readable error message for the given + * throwable. + * + * @param throwable The throwable for which an error code and message should be generated. + * @return A pair consisting of an error code and a user readable error message. + */ + Pair<Integer, String> getErrorMessage(T throwable); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EventDispatcher.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EventDispatcher.java new file mode 100644 index 0000000000..6e9a3798bf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EventDispatcher.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.Handler; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Event dispatcher which allows listener registration. + * + * @param <T> The type of listener. + */ +public final class EventDispatcher<T> { + + /** Functional interface to send an event. */ + public interface Event<T> { + + /** + * Sends the event to a listener. + * + * @param listener The listener to send the event to. + */ + void sendTo(T listener); + } + + /** The list of listeners and handlers. */ + private final CopyOnWriteArrayList<HandlerAndListener<T>> listeners; + + /** Creates an event dispatcher. */ + public EventDispatcher() { + listeners = new CopyOnWriteArrayList<>(); + } + + /** Adds a listener to the event dispatcher. */ + public void addListener(Handler handler, T eventListener) { + Assertions.checkArgument(handler != null && eventListener != null); + removeListener(eventListener); + listeners.add(new HandlerAndListener<>(handler, eventListener)); + } + + /** Removes a listener from the event dispatcher. */ + public void removeListener(T eventListener) { + for (HandlerAndListener<T> handlerAndListener : listeners) { + if (handlerAndListener.listener == eventListener) { + handlerAndListener.release(); + listeners.remove(handlerAndListener); + } + } + } + + /** + * Dispatches an event to all registered listeners. + * + * @param event The {@link Event}. + */ + public void dispatch(Event<T> event) { + for (HandlerAndListener<T> handlerAndListener : listeners) { + handlerAndListener.dispatch(event); + } + } + + private static final class HandlerAndListener<T> { + + private final Handler handler; + private final T listener; + + private boolean released; + + public HandlerAndListener(Handler handler, T eventListener) { + this.handler = handler; + this.listener = eventListener; + } + + public void release() { + released = true; + } + + public void dispatch(Event<T> event) { + handler.post( + () -> { + if (!released) { + event.sendTo(listener); + } + }); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EventLogger.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EventLogger.java new file mode 100644 index 0000000000..0c2a6abcf1 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/EventLogger.java @@ -0,0 +1,651 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.SystemClock; +import android.text.TextUtils; +import android.view.Surface; +import androidx.annotation.Nullable; +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.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player.PlaybackSuppressionReason; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities.AdaptiveSupport; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.analytics.AnalyticsListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioAttributes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.LoadEventInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener.MediaLoadData; +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.trackselection.MappingTrackSelector; +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 java.io.IOException; +import java.text.NumberFormat; +import java.util.Locale; + +/** Logs events from {@link Player} and other core components using {@link Log}. */ +@SuppressWarnings("UngroupedOverloads") +public class EventLogger implements AnalyticsListener { + + private static final String DEFAULT_TAG = "EventLogger"; + private static final int MAX_TIMELINE_ITEM_LINES = 3; + private static final NumberFormat TIME_FORMAT; + static { + TIME_FORMAT = NumberFormat.getInstance(Locale.US); + TIME_FORMAT.setMinimumFractionDigits(2); + TIME_FORMAT.setMaximumFractionDigits(2); + TIME_FORMAT.setGroupingUsed(false); + } + + @Nullable private final MappingTrackSelector trackSelector; + private final String tag; + private final Timeline.Window window; + private final Timeline.Period period; + private final long startTimeMs; + + /** + * Creates event logger. + * + * @param trackSelector The mapping track selector used by the player. May be null if detailed + * logging of track mapping is not required. + */ + public EventLogger(@Nullable MappingTrackSelector trackSelector) { + this(trackSelector, DEFAULT_TAG); + } + + /** + * Creates event logger. + * + * @param trackSelector The mapping track selector used by the player. May be null if detailed + * logging of track mapping is not required. + * @param tag The tag used for logging. + */ + public EventLogger(@Nullable MappingTrackSelector trackSelector, String tag) { + this.trackSelector = trackSelector; + this.tag = tag; + window = new Timeline.Window(); + period = new Timeline.Period(); + startTimeMs = SystemClock.elapsedRealtime(); + } + + // AnalyticsListener + + @Override + public void onLoadingChanged(EventTime eventTime, boolean isLoading) { + logd(eventTime, "loading", Boolean.toString(isLoading)); + } + + @Override + public void onPlayerStateChanged( + EventTime eventTime, boolean playWhenReady, @Player.State int state) { + logd(eventTime, "state", playWhenReady + ", " + getStateString(state)); + } + + @Override + public void onPlaybackSuppressionReasonChanged( + EventTime eventTime, @PlaybackSuppressionReason int playbackSuppressionReason) { + logd( + eventTime, + "playbackSuppressionReason", + getPlaybackSuppressionReasonString(playbackSuppressionReason)); + } + + @Override + public void onIsPlayingChanged(EventTime eventTime, boolean isPlaying) { + logd(eventTime, "isPlaying", Boolean.toString(isPlaying)); + } + + @Override + public void onRepeatModeChanged(EventTime eventTime, @Player.RepeatMode int repeatMode) { + logd(eventTime, "repeatMode", getRepeatModeString(repeatMode)); + } + + @Override + public void onShuffleModeChanged(EventTime eventTime, boolean shuffleModeEnabled) { + logd(eventTime, "shuffleModeEnabled", Boolean.toString(shuffleModeEnabled)); + } + + @Override + public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) { + logd(eventTime, "positionDiscontinuity", getDiscontinuityReasonString(reason)); + } + + @Override + public void onSeekStarted(EventTime eventTime) { + logd(eventTime, "seekStarted"); + } + + @Override + public void onPlaybackParametersChanged( + EventTime eventTime, PlaybackParameters playbackParameters) { + logd( + eventTime, + "playbackParameters", + Util.formatInvariant( + "speed=%.2f, pitch=%.2f, skipSilence=%s", + playbackParameters.speed, playbackParameters.pitch, playbackParameters.skipSilence)); + } + + @Override + public void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason int reason) { + int periodCount = eventTime.timeline.getPeriodCount(); + int windowCount = eventTime.timeline.getWindowCount(); + logd( + "timeline [" + + getEventTimeString(eventTime) + + ", periodCount=" + + periodCount + + ", windowCount=" + + windowCount + + ", reason=" + + getTimelineChangeReasonString(reason)); + for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { + eventTime.timeline.getPeriod(i, period); + logd(" " + "period [" + getTimeString(period.getDurationMs()) + "]"); + } + if (periodCount > MAX_TIMELINE_ITEM_LINES) { + logd(" ..."); + } + for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { + eventTime.timeline.getWindow(i, window); + logd( + " " + + "window [" + + getTimeString(window.getDurationMs()) + + ", " + + window.isSeekable + + ", " + + window.isDynamic + + "]"); + } + if (windowCount > MAX_TIMELINE_ITEM_LINES) { + logd(" ..."); + } + logd("]"); + } + + @Override + public void onPlayerError(EventTime eventTime, ExoPlaybackException e) { + loge(eventTime, "playerFailed", e); + } + + @Override + public void onTracksChanged( + EventTime eventTime, TrackGroupArray ignored, TrackSelectionArray trackSelections) { + MappedTrackInfo mappedTrackInfo = + trackSelector != null ? trackSelector.getCurrentMappedTrackInfo() : null; + if (mappedTrackInfo == null) { + logd(eventTime, "tracks", "[]"); + return; + } + logd("tracks [" + getEventTimeString(eventTime)); + // Log tracks associated to renderers. + int rendererCount = mappedTrackInfo.getRendererCount(); + for (int rendererIndex = 0; rendererIndex < rendererCount; rendererIndex++) { + TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + TrackSelection trackSelection = trackSelections.get(rendererIndex); + if (rendererTrackGroups.length > 0) { + logd(" Renderer:" + rendererIndex + " ["); + for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) { + TrackGroup trackGroup = rendererTrackGroups.get(groupIndex); + String adaptiveSupport = + getAdaptiveSupportString( + trackGroup.length, + mappedTrackInfo.getAdaptiveSupport( + rendererIndex, groupIndex, /* includeCapabilitiesExceededTracks= */ false)); + logd(" Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " ["); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + String status = getTrackStatusString(trackSelection, trackGroup, trackIndex); + String formatSupport = + RendererCapabilities.getFormatSupportString( + mappedTrackInfo.getTrackSupport(rendererIndex, groupIndex, trackIndex)); + logd( + " " + + status + + " Track:" + + trackIndex + + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + + formatSupport); + } + logd(" ]"); + } + // Log metadata for at most one of the tracks selected for the renderer. + if (trackSelection != null) { + for (int selectionIndex = 0; selectionIndex < trackSelection.length(); selectionIndex++) { + Metadata metadata = trackSelection.getFormat(selectionIndex).metadata; + if (metadata != null) { + logd(" Metadata ["); + printMetadata(metadata, " "); + logd(" ]"); + break; + } + } + } + logd(" ]"); + } + } + // Log tracks not associated with a renderer. + TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnmappedTrackGroups(); + if (unassociatedTrackGroups.length > 0) { + logd(" Renderer:None ["); + for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) { + logd(" Group:" + groupIndex + " ["); + TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + String status = getTrackStatusString(false); + String formatSupport = + RendererCapabilities.getFormatSupportString( + RendererCapabilities.FORMAT_UNSUPPORTED_TYPE); + logd( + " " + + status + + " Track:" + + trackIndex + + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + + formatSupport); + } + logd(" ]"); + } + logd(" ]"); + } + logd("]"); + } + + @Override + public void onSeekProcessed(EventTime eventTime) { + logd(eventTime, "seekProcessed"); + } + + @Override + public void onMetadata(EventTime eventTime, Metadata metadata) { + logd("metadata [" + getEventTimeString(eventTime)); + printMetadata(metadata, " "); + logd("]"); + } + + @Override + public void onDecoderEnabled(EventTime eventTime, int trackType, DecoderCounters counters) { + logd(eventTime, "decoderEnabled", Util.getTrackTypeString(trackType)); + } + + @Override + public void onAudioSessionId(EventTime eventTime, int audioSessionId) { + logd(eventTime, "audioSessionId", Integer.toString(audioSessionId)); + } + + @Override + public void onAudioAttributesChanged(EventTime eventTime, AudioAttributes audioAttributes) { + logd( + eventTime, + "audioAttributes", + audioAttributes.contentType + + "," + + audioAttributes.flags + + "," + + audioAttributes.usage + + "," + + audioAttributes.allowedCapturePolicy); + } + + @Override + public void onVolumeChanged(EventTime eventTime, float volume) { + logd(eventTime, "volume", Float.toString(volume)); + } + + @Override + public void onDecoderInitialized( + EventTime eventTime, int trackType, String decoderName, long initializationDurationMs) { + logd(eventTime, "decoderInitialized", Util.getTrackTypeString(trackType) + ", " + decoderName); + } + + @Override + public void onDecoderInputFormatChanged(EventTime eventTime, int trackType, Format format) { + logd( + eventTime, + "decoderInputFormat", + Util.getTrackTypeString(trackType) + ", " + Format.toLogString(format)); + } + + @Override + public void onDecoderDisabled(EventTime eventTime, int trackType, DecoderCounters counters) { + logd(eventTime, "decoderDisabled", Util.getTrackTypeString(trackType)); + } + + @Override + public void onAudioUnderrun( + EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + loge( + eventTime, + "audioTrackUnderrun", + bufferSize + ", " + bufferSizeMs + ", " + elapsedSinceLastFeedMs + "]", + null); + } + + @Override + public void onDroppedVideoFrames(EventTime eventTime, int count, long elapsedMs) { + logd(eventTime, "droppedFrames", Integer.toString(count)); + } + + @Override + public void onVideoSizeChanged( + EventTime eventTime, + int width, + int height, + int unappliedRotationDegrees, + float pixelWidthHeightRatio) { + logd(eventTime, "videoSize", width + ", " + height); + } + + @Override + public void onRenderedFirstFrame(EventTime eventTime, @Nullable Surface surface) { + logd(eventTime, "renderedFirstFrame", String.valueOf(surface)); + } + + @Override + public void onMediaPeriodCreated(EventTime eventTime) { + logd(eventTime, "mediaPeriodCreated"); + } + + @Override + public void onMediaPeriodReleased(EventTime eventTime) { + logd(eventTime, "mediaPeriodReleased"); + } + + @Override + public void onLoadStarted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + // Do nothing. + } + + @Override + public void onLoadError( + EventTime eventTime, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData, + IOException error, + boolean wasCanceled) { + printInternalError(eventTime, "loadError", error); + } + + @Override + public void onLoadCanceled( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + // Do nothing. + } + + @Override + public void onLoadCompleted( + EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + // Do nothing. + } + + @Override + public void onReadingStarted(EventTime eventTime) { + logd(eventTime, "mediaPeriodReadingStarted"); + } + + @Override + public void onBandwidthEstimate( + EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) { + // Do nothing. + } + + @Override + public void onSurfaceSizeChanged(EventTime eventTime, int width, int height) { + logd(eventTime, "surfaceSize", width + ", " + height); + } + + @Override + public void onUpstreamDiscarded(EventTime eventTime, MediaLoadData mediaLoadData) { + logd(eventTime, "upstreamDiscarded", Format.toLogString(mediaLoadData.trackFormat)); + } + + @Override + public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) { + logd(eventTime, "downstreamFormat", Format.toLogString(mediaLoadData.trackFormat)); + } + + @Override + public void onDrmSessionAcquired(EventTime eventTime) { + logd(eventTime, "drmSessionAcquired"); + } + + @Override + public void onDrmSessionManagerError(EventTime eventTime, Exception e) { + printInternalError(eventTime, "drmSessionManagerError", e); + } + + @Override + public void onDrmKeysRestored(EventTime eventTime) { + logd(eventTime, "drmKeysRestored"); + } + + @Override + public void onDrmKeysRemoved(EventTime eventTime) { + logd(eventTime, "drmKeysRemoved"); + } + + @Override + public void onDrmKeysLoaded(EventTime eventTime) { + logd(eventTime, "drmKeysLoaded"); + } + + @Override + public void onDrmSessionReleased(EventTime eventTime) { + logd(eventTime, "drmSessionReleased"); + } + + /** + * Logs a debug message. + * + * @param msg The message to log. + */ + protected void logd(String msg) { + Log.d(tag, msg); + } + + /** + * Logs an error message. + * + * @param msg The message to log. + */ + protected void loge(String msg) { + Log.e(tag, msg); + } + + // Internal methods + + private void logd(EventTime eventTime, String eventName) { + logd(getEventString(eventTime, eventName, /* eventDescription= */ null, /* throwable= */ null)); + } + + private void logd(EventTime eventTime, String eventName, String eventDescription) { + logd(getEventString(eventTime, eventName, eventDescription, /* throwable= */ null)); + } + + private void loge(EventTime eventTime, String eventName, @Nullable Throwable throwable) { + loge(getEventString(eventTime, eventName, /* eventDescription= */ null, throwable)); + } + + private void loge( + EventTime eventTime, + String eventName, + String eventDescription, + @Nullable Throwable throwable) { + loge(getEventString(eventTime, eventName, eventDescription, throwable)); + } + + private void printInternalError(EventTime eventTime, String type, Exception e) { + loge(eventTime, "internalError", type, e); + } + + private void printMetadata(Metadata metadata, String prefix) { + for (int i = 0; i < metadata.length(); i++) { + logd(prefix + metadata.get(i)); + } + } + + private String getEventString( + EventTime eventTime, + String eventName, + @Nullable String eventDescription, + @Nullable Throwable throwable) { + String eventString = eventName + " [" + getEventTimeString(eventTime); + if (eventDescription != null) { + eventString += ", " + eventDescription; + } + @Nullable String throwableString = Log.getThrowableString(throwable); + if (!TextUtils.isEmpty(throwableString)) { + eventString += "\n " + throwableString.replace("\n", "\n ") + '\n'; + } + eventString += "]"; + return eventString; + } + + private String getEventTimeString(EventTime eventTime) { + String windowPeriodString = "window=" + eventTime.windowIndex; + if (eventTime.mediaPeriodId != null) { + windowPeriodString += + ", period=" + eventTime.timeline.getIndexOfPeriod(eventTime.mediaPeriodId.periodUid); + if (eventTime.mediaPeriodId.isAd()) { + windowPeriodString += ", adGroup=" + eventTime.mediaPeriodId.adGroupIndex; + windowPeriodString += ", ad=" + eventTime.mediaPeriodId.adIndexInAdGroup; + } + } + return "eventTime=" + + getTimeString(eventTime.realtimeMs - startTimeMs) + + ", mediaPos=" + + getTimeString(eventTime.currentPlaybackPositionMs) + + ", " + + windowPeriodString; + } + + private static String getTimeString(long timeMs) { + return timeMs == C.TIME_UNSET ? "?" : TIME_FORMAT.format((timeMs) / 1000f); + } + + private static String getStateString(int state) { + switch (state) { + case Player.STATE_BUFFERING: + return "BUFFERING"; + case Player.STATE_ENDED: + return "ENDED"; + case Player.STATE_IDLE: + return "IDLE"; + case Player.STATE_READY: + return "READY"; + default: + return "?"; + } + } + + private static String getAdaptiveSupportString( + int trackCount, @AdaptiveSupport 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: + throw new IllegalStateException(); + } + } + + // Suppressing reference equality warning because the track group stored in the track selection + // must point to the exact track group object to be considered part of it. + @SuppressWarnings("ReferenceEquality") + private static String getTrackStatusString( + @Nullable TrackSelection selection, TrackGroup group, int trackIndex) { + return getTrackStatusString(selection != null && selection.getTrackGroup() == group + && selection.indexOf(trackIndex) != C.INDEX_UNSET); + } + + private static String getTrackStatusString(boolean enabled) { + return enabled ? "[X]" : "[ ]"; + } + + private static String getRepeatModeString(@Player.RepeatMode int repeatMode) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + return "OFF"; + case Player.REPEAT_MODE_ONE: + return "ONE"; + case Player.REPEAT_MODE_ALL: + return "ALL"; + default: + return "?"; + } + } + + private static String getDiscontinuityReasonString(@Player.DiscontinuityReason int reason) { + switch (reason) { + case Player.DISCONTINUITY_REASON_PERIOD_TRANSITION: + return "PERIOD_TRANSITION"; + case Player.DISCONTINUITY_REASON_SEEK: + return "SEEK"; + case Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT: + return "SEEK_ADJUSTMENT"; + case Player.DISCONTINUITY_REASON_AD_INSERTION: + return "AD_INSERTION"; + case Player.DISCONTINUITY_REASON_INTERNAL: + return "INTERNAL"; + default: + return "?"; + } + } + + private static String getTimelineChangeReasonString(@Player.TimelineChangeReason int reason) { + switch (reason) { + case Player.TIMELINE_CHANGE_REASON_PREPARED: + return "PREPARED"; + case Player.TIMELINE_CHANGE_REASON_RESET: + return "RESET"; + case Player.TIMELINE_CHANGE_REASON_DYNAMIC: + return "DYNAMIC"; + default: + return "?"; + } + } + + private static String getPlaybackSuppressionReasonString( + @PlaybackSuppressionReason int playbackSuppressionReason) { + switch (playbackSuppressionReason) { + case Player.PLAYBACK_SUPPRESSION_REASON_NONE: + return "NONE"; + case Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS: + return "TRANSIENT_AUDIO_FOCUS_LOSS"; + default: + return "?"; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/FlacConstants.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/FlacConstants.java new file mode 100644 index 0000000000..faa917fab8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/FlacConstants.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +/** Defines constants used by the FLAC extractor. */ +public final class FlacConstants { + + /** Size of the FLAC stream marker in bytes. */ + public static final int STREAM_MARKER_SIZE = 4; + /** Size of the header of a FLAC metadata block in bytes. */ + public static final int METADATA_BLOCK_HEADER_SIZE = 4; + /** Size of the FLAC stream info block (header included) in bytes. */ + public static final int STREAM_INFO_BLOCK_SIZE = 38; + /** Minimum size of a FLAC frame header in bytes. */ + public static final int MIN_FRAME_HEADER_SIZE = 6; + /** Maximum size of a FLAC frame header in bytes. */ + public static final int MAX_FRAME_HEADER_SIZE = 16; + + /** Stream info metadata block type. */ + public static final int METADATA_TYPE_STREAM_INFO = 0; + /** Seek table metadata block type. */ + public static final int METADATA_TYPE_SEEK_TABLE = 3; + /** Vorbis comment metadata block type. */ + public static final int METADATA_TYPE_VORBIS_COMMENT = 4; + /** Picture metadata block type. */ + public static final int METADATA_TYPE_PICTURE = 6; + + private FlacConstants() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/FlacStreamMetadata.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/FlacStreamMetadata.java new file mode 100644 index 0000000000..893481d8da --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/FlacStreamMetadata.java @@ -0,0 +1,384 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.Metadata; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.flac.PictureFrame; +import org.mozilla.thirdparty.com.google.android.exoplayer2.metadata.flac.VorbisComment; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Holder for FLAC metadata. + * + * @see <a href="https://xiph.org/flac/format.html#metadata_block_streaminfo">FLAC format + * METADATA_BLOCK_STREAMINFO</a> + * @see <a href="https://xiph.org/flac/format.html#metadata_block_seektable">FLAC format + * METADATA_BLOCK_SEEKTABLE</a> + * @see <a href="https://xiph.org/flac/format.html#metadata_block_vorbis_comment">FLAC format + * METADATA_BLOCK_VORBIS_COMMENT</a> + * @see <a href="https://xiph.org/flac/format.html#metadata_block_picture">FLAC format + * METADATA_BLOCK_PICTURE</a> + */ +public final class FlacStreamMetadata { + + /** A FLAC seek table. */ + public static class SeekTable { + /** Seek points sample numbers. */ + public final long[] pointSampleNumbers; + /** Seek points byte offsets from the first frame. */ + public final long[] pointOffsets; + + public SeekTable(long[] pointSampleNumbers, long[] pointOffsets) { + this.pointSampleNumbers = pointSampleNumbers; + this.pointOffsets = pointOffsets; + } + } + + private static final String TAG = "FlacStreamMetadata"; + + /** Indicates that a value is not in the corresponding lookup table. */ + public static final int NOT_IN_LOOKUP_TABLE = -1; + /** Separator between the field name of a Vorbis comment and the corresponding value. */ + private static final String SEPARATOR = "="; + + /** Minimum number of samples per block. */ + public final int minBlockSizeSamples; + /** Maximum number of samples per block. */ + public final int maxBlockSizeSamples; + /** Minimum frame size in bytes, or 0 if the value is unknown. */ + public final int minFrameSize; + /** Maximum frame size in bytes, or 0 if the value is unknown. */ + public final int maxFrameSize; + /** Sample rate in Hertz. */ + public final int sampleRate; + /** + * Lookup key corresponding to the stream sample rate, or {@link #NOT_IN_LOOKUP_TABLE} if it is + * not in the lookup table. + * + * <p>This key is used to indicate the sample rate in the frame header for the most common values. + * + * <p>The sample rate lookup table is described in https://xiph.org/flac/format.html#frame_header. + */ + public final int sampleRateLookupKey; + /** Number of audio channels. */ + public final int channels; + /** Number of bits per sample. */ + public final int bitsPerSample; + /** + * Lookup key corresponding to the number of bits per sample of the stream, or {@link + * #NOT_IN_LOOKUP_TABLE} if it is not in the lookup table. + * + * <p>This key is used to indicate the number of bits per sample in the frame header for the most + * common values. + * + * <p>The sample size lookup table is described in https://xiph.org/flac/format.html#frame_header. + */ + public final int bitsPerSampleLookupKey; + /** Total number of samples, or 0 if the value is unknown. */ + public final long totalSamples; + /** Seek table, or {@code null} if it is not provided. */ + @Nullable public final SeekTable seekTable; + /** Content metadata, or {@code null} if it is not provided. */ + @Nullable private final Metadata metadata; + + /** + * Parses binary FLAC stream info metadata. + * + * @param data An array containing binary FLAC stream info block. + * @param offset The offset of the stream info block in {@code data}, excluding the header (i.e. + * the offset points to the first byte of the minimum block size). + */ + public FlacStreamMetadata(byte[] data, int offset) { + ParsableBitArray scratch = new ParsableBitArray(data); + scratch.setPosition(offset * 8); + minBlockSizeSamples = scratch.readBits(16); + maxBlockSizeSamples = scratch.readBits(16); + minFrameSize = scratch.readBits(24); + maxFrameSize = scratch.readBits(24); + sampleRate = scratch.readBits(20); + sampleRateLookupKey = getSampleRateLookupKey(sampleRate); + channels = scratch.readBits(3) + 1; + bitsPerSample = scratch.readBits(5) + 1; + bitsPerSampleLookupKey = getBitsPerSampleLookupKey(bitsPerSample); + totalSamples = scratch.readBitsToLong(36); + seekTable = null; + metadata = null; + } + + // Used in native code. + public FlacStreamMetadata( + int minBlockSizeSamples, + int maxBlockSizeSamples, + int minFrameSize, + int maxFrameSize, + int sampleRate, + int channels, + int bitsPerSample, + long totalSamples, + ArrayList<String> vorbisComments, + ArrayList<PictureFrame> pictureFrames) { + this( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + /* seekTable= */ null, + buildMetadata(vorbisComments, pictureFrames)); + } + + private FlacStreamMetadata( + int minBlockSizeSamples, + int maxBlockSizeSamples, + int minFrameSize, + int maxFrameSize, + int sampleRate, + int channels, + int bitsPerSample, + long totalSamples, + @Nullable SeekTable seekTable, + @Nullable Metadata metadata) { + this.minBlockSizeSamples = minBlockSizeSamples; + this.maxBlockSizeSamples = maxBlockSizeSamples; + this.minFrameSize = minFrameSize; + this.maxFrameSize = maxFrameSize; + this.sampleRate = sampleRate; + this.sampleRateLookupKey = getSampleRateLookupKey(sampleRate); + this.channels = channels; + this.bitsPerSample = bitsPerSample; + this.bitsPerSampleLookupKey = getBitsPerSampleLookupKey(bitsPerSample); + this.totalSamples = totalSamples; + this.seekTable = seekTable; + this.metadata = metadata; + } + + /** Returns the maximum size for a decoded frame from the FLAC stream. */ + public int getMaxDecodedFrameSize() { + return maxBlockSizeSamples * channels * (bitsPerSample / 8); + } + + /** Returns the bit-rate of the FLAC stream. */ + public int getBitRate() { + return bitsPerSample * sampleRate * channels; + } + + /** + * Returns the duration of the FLAC stream in microseconds, or {@link C#TIME_UNSET} if the total + * number of samples if unknown. + */ + public long getDurationUs() { + return totalSamples == 0 ? C.TIME_UNSET : totalSamples * C.MICROS_PER_SECOND / sampleRate; + } + + /** + * Returns the sample number of the sample at a given time. + * + * @param timeUs Time position in microseconds in the FLAC stream. + * @return The sample number corresponding to the time position. + */ + public long getSampleNumber(long timeUs) { + long sampleNumber = (timeUs * sampleRate) / C.MICROS_PER_SECOND; + return Util.constrainValue(sampleNumber, /* min= */ 0, totalSamples - 1); + } + + /** Returns the approximate number of bytes per frame for the current FLAC stream. */ + public long getApproxBytesPerFrame() { + long approxBytesPerFrame; + if (maxFrameSize > 0) { + approxBytesPerFrame = ((long) maxFrameSize + minFrameSize) / 2 + 1; + } else { + // Uses the stream's block-size if it's a known fixed block-size stream, otherwise uses the + // default value for FLAC block-size, which is 4096. + long blockSizeSamples = + (minBlockSizeSamples == maxBlockSizeSamples && minBlockSizeSamples > 0) + ? minBlockSizeSamples + : 4096; + approxBytesPerFrame = (blockSizeSamples * channels * bitsPerSample) / 8 + 64; + } + return approxBytesPerFrame; + } + + /** + * Returns a {@link Format} extracted from the FLAC stream metadata. + * + * <p>{@code streamMarkerAndInfoBlock} is updated to set the bit corresponding to the stream info + * last metadata block flag to true. + * + * @param streamMarkerAndInfoBlock An array containing the FLAC stream marker followed by the + * stream info block. + * @param id3Metadata The ID3 metadata of the stream, or {@code null} if there is no such data. + * @return The extracted {@link Format}. + */ + public Format getFormat(byte[] streamMarkerAndInfoBlock, @Nullable Metadata id3Metadata) { + // Set the last metadata block flag, ignore the other blocks. + streamMarkerAndInfoBlock[4] = (byte) 0x80; + int maxInputSize = maxFrameSize > 0 ? maxFrameSize : Format.NO_VALUE; + @Nullable Metadata metadataWithId3 = getMetadataCopyWithAppendedEntriesFrom(id3Metadata); + + return Format.createAudioSampleFormat( + /* id= */ null, + MimeTypes.AUDIO_FLAC, + /* codecs= */ null, + getBitRate(), + maxInputSize, + channels, + sampleRate, + /* pcmEncoding= */ Format.NO_VALUE, + /* encoderDelay= */ 0, + /* encoderPadding= */ 0, + /* initializationData= */ Collections.singletonList(streamMarkerAndInfoBlock), + /* drmInitData= */ null, + /* selectionFlags= */ 0, + /* language= */ null, + metadataWithId3); + } + + /** Returns a copy of the content metadata with entries from {@code other} appended. */ + @Nullable + public Metadata getMetadataCopyWithAppendedEntriesFrom(@Nullable Metadata other) { + return metadata == null ? other : metadata.copyWithAppendedEntriesFrom(other); + } + + /** Returns a copy of {@code this} with the seek table replaced by the one given. */ + public FlacStreamMetadata copyWithSeekTable(@Nullable SeekTable seekTable) { + return new FlacStreamMetadata( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + seekTable, + metadata); + } + + /** Returns a copy of {@code this} with the given Vorbis comments added to the metadata. */ + public FlacStreamMetadata copyWithVorbisComments(List<String> vorbisComments) { + @Nullable + Metadata appendedMetadata = + getMetadataCopyWithAppendedEntriesFrom( + buildMetadata(vorbisComments, Collections.emptyList())); + return new FlacStreamMetadata( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + seekTable, + appendedMetadata); + } + + /** Returns a copy of {@code this} with the given picture frames added to the metadata. */ + public FlacStreamMetadata copyWithPictureFrames(List<PictureFrame> pictureFrames) { + @Nullable + Metadata appendedMetadata = + getMetadataCopyWithAppendedEntriesFrom( + buildMetadata(Collections.emptyList(), pictureFrames)); + return new FlacStreamMetadata( + minBlockSizeSamples, + maxBlockSizeSamples, + minFrameSize, + maxFrameSize, + sampleRate, + channels, + bitsPerSample, + totalSamples, + seekTable, + appendedMetadata); + } + + private static int getSampleRateLookupKey(int sampleRate) { + switch (sampleRate) { + case 88200: + return 1; + case 176400: + return 2; + case 192000: + return 3; + case 8000: + return 4; + case 16000: + return 5; + case 22050: + return 6; + case 24000: + return 7; + case 32000: + return 8; + case 44100: + return 9; + case 48000: + return 10; + case 96000: + return 11; + default: + return NOT_IN_LOOKUP_TABLE; + } + } + + private static int getBitsPerSampleLookupKey(int bitsPerSample) { + switch (bitsPerSample) { + case 8: + return 1; + case 12: + return 2; + case 16: + return 4; + case 20: + return 5; + case 24: + return 6; + default: + return NOT_IN_LOOKUP_TABLE; + } + } + + @Nullable + private static Metadata buildMetadata( + List<String> vorbisComments, List<PictureFrame> pictureFrames) { + if (vorbisComments.isEmpty() && pictureFrames.isEmpty()) { + return null; + } + + ArrayList<Metadata.Entry> metadataEntries = new ArrayList<>(); + for (int i = 0; i < vorbisComments.size(); i++) { + String vorbisComment = vorbisComments.get(i); + String[] keyAndValue = Util.splitAtFirst(vorbisComment, SEPARATOR); + if (keyAndValue.length != 2) { + Log.w(TAG, "Failed to parse Vorbis comment: " + vorbisComment); + } else { + VorbisComment entry = new VorbisComment(keyAndValue[0], keyAndValue[1]); + metadataEntries.add(entry); + } + } + metadataEntries.addAll(pictureFrames); + + return metadataEntries.isEmpty() ? null : new Metadata(metadataEntries); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/GlUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/GlUtil.java new file mode 100644 index 0000000000..a34cee48f9 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/GlUtil.java @@ -0,0 +1,404 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import static android.opengl.GLU.gluErrorString; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.pm.PackageManager; +import android.opengl.EGL14; +import android.opengl.EGLDisplay; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import java.nio.Buffer; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import javax.microedition.khronos.egl.EGL10; + +/** GL utilities. */ +public final class GlUtil { + + /** + * GL attribute, which can be attached to a buffer with {@link Attribute#setBuffer(float[], int)}. + */ + public static final class Attribute { + + /** The name of the attribute in the GLSL sources. */ + public final String name; + + private final int index; + private final int location; + + @Nullable private Buffer buffer; + private int size; + + /** + * Creates a new GL attribute. + * + * @param program The identifier of a compiled and linked GLSL shader program. + * @param index The index of the attribute. After this instance has been constructed, the name + * of the attribute is available via the {@link #name} field. + */ + public Attribute(int program, int index) { + int[] len = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, len, 0); + + int[] type = new int[1]; + int[] size = new int[1]; + byte[] nameBytes = new byte[len[0]]; + int[] ignore = new int[1]; + + GLES20.glGetActiveAttrib(program, index, len[0], ignore, 0, size, 0, type, 0, nameBytes, 0); + name = new String(nameBytes, 0, strlen(nameBytes)); + location = GLES20.glGetAttribLocation(program, name); + this.index = index; + } + + /** + * Configures {@link #bind()} to attach vertices in {@code buffer} (each of size {@code size} + * elements) to this {@link Attribute}. + * + * @param buffer Buffer to bind to this attribute. + * @param size Number of elements per vertex. + */ + public void setBuffer(float[] buffer, int size) { + this.buffer = createBuffer(buffer); + this.size = size; + } + + /** + * Sets the vertex attribute to whatever was attached via {@link #setBuffer(float[], int)}. + * + * <p>Should be called before each drawing call. + */ + public void bind() { + Buffer buffer = Assertions.checkNotNull(this.buffer, "call setBuffer before bind"); + GLES20.glBindBuffer(GLES20.GL_ARRAY_BUFFER, 0); + GLES20.glVertexAttribPointer( + location, + size, // count + GLES20.GL_FLOAT, // type + false, // normalize + 0, // stride + buffer); + GLES20.glEnableVertexAttribArray(index); + checkGlError(); + } + } + + /** + * GL uniform, which can be attached to a sampler using {@link Uniform#setSamplerTexId(int, int)}. + */ + public static final class Uniform { + + /** The name of the uniform in the GLSL sources. */ + public final String name; + + private final int location; + private final int type; + private final float[] value; + + private int texId; + private int unit; + + /** + * Creates a new GL uniform. + * + * @param program The identifier of a compiled and linked GLSL shader program. + * @param index The index of the uniform. After this instance has been constructed, the name of + * the uniform is available via the {@link #name} field. + */ + public Uniform(int program, int index) { + int[] len = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_ACTIVE_UNIFORM_MAX_LENGTH, len, 0); + + int[] type = new int[1]; + int[] size = new int[1]; + byte[] name = new byte[len[0]]; + int[] ignore = new int[1]; + + GLES20.glGetActiveUniform(program, index, len[0], ignore, 0, size, 0, type, 0, name, 0); + this.name = new String(name, 0, strlen(name)); + location = GLES20.glGetUniformLocation(program, this.name); + this.type = type[0]; + + value = new float[1]; + } + + /** + * Configures {@link #bind()} to use the specified {@code texId} for this sampler uniform. + * + * @param texId The GL texture identifier from which to sample. + * @param unit The GL texture unit index. + */ + public void setSamplerTexId(int texId, int unit) { + this.texId = texId; + this.unit = unit; + } + + /** Configures {@link #bind()} to use the specified float {@code value} for this uniform. */ + public void setFloat(float value) { + this.value[0] = value; + } + + /** + * Sets the uniform to whatever value was passed via {@link #setSamplerTexId(int, int)} or + * {@link #setFloat(float)}. + * + * <p>Should be called before each drawing call. + */ + public void bind() { + if (type == GLES20.GL_FLOAT) { + GLES20.glUniform1fv(location, 1, value, 0); + checkGlError(); + return; + } + + if (texId == 0) { + throw new IllegalStateException("call setSamplerTexId before bind"); + } + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + unit); + if (type == GLES11Ext.GL_SAMPLER_EXTERNAL_OES) { + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId); + } else if (type == GLES20.GL_SAMPLER_2D) { + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId); + } else { + throw new IllegalStateException("unexpected uniform type: " + type); + } + GLES20.glUniform1i(location, unit); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + checkGlError(); + } + } + + private static final String TAG = "GlUtil"; + + private static final String EXTENSION_PROTECTED_CONTENT = "EGL_EXT_protected_content"; + private static final String EXTENSION_SURFACELESS_CONTEXT = "EGL_KHR_surfaceless_context"; + + /** Class only contains static methods. */ + private GlUtil() {} + + /** + * Returns whether creating a GL context with {@value EXTENSION_PROTECTED_CONTENT} is possible. If + * {@code true}, the device supports a protected output path for DRM content when using GL. + */ + @TargetApi(24) + public static boolean isProtectedContentExtensionSupported(Context context) { + if (Util.SDK_INT < 24) { + return false; + } + if (Util.SDK_INT < 26 && ("samsung".equals(Util.MANUFACTURER) || "XT1650".equals(Util.MODEL))) { + // Samsung devices running Nougat are known to be broken. See + // https://github.com/google/ExoPlayer/issues/3373 and [Internal: b/37197802]. + // Moto Z XT1650 is also affected. See + // https://github.com/google/ExoPlayer/issues/3215. + return false; + } + if (Util.SDK_INT < 26 + && !context + .getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_VR_MODE_HIGH_PERFORMANCE)) { + // Pre API level 26 devices were not well tested unless they supported VR mode. + return false; + } + + EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + @Nullable String eglExtensions = EGL14.eglQueryString(display, EGL10.EGL_EXTENSIONS); + return eglExtensions != null && eglExtensions.contains(EXTENSION_PROTECTED_CONTENT); + } + + /** + * Returns whether creating a GL context with {@value EXTENSION_SURFACELESS_CONTEXT} is possible. + */ + @TargetApi(17) + public static boolean isSurfacelessContextExtensionSupported() { + if (Util.SDK_INT < 17) { + return false; + } + EGLDisplay display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + @Nullable String eglExtensions = EGL14.eglQueryString(display, EGL10.EGL_EXTENSIONS); + return eglExtensions != null && eglExtensions.contains(EXTENSION_SURFACELESS_CONTEXT); + } + + /** + * If there is an OpenGl error, logs the error and if {@link + * ExoPlayerLibraryInfo#GL_ASSERTIONS_ENABLED} is true throws a {@link RuntimeException}. + */ + public static void checkGlError() { + int lastError = GLES20.GL_NO_ERROR; + int error; + while ((error = GLES20.glGetError()) != GLES20.GL_NO_ERROR) { + Log.e(TAG, "glError " + gluErrorString(error)); + lastError = error; + } + if (ExoPlayerLibraryInfo.GL_ASSERTIONS_ENABLED && lastError != GLES20.GL_NO_ERROR) { + throw new RuntimeException("glError " + gluErrorString(lastError)); + } + } + + /** + * Builds a GL shader program from vertex and fragment shader code. + * + * @param vertexCode GLES20 vertex shader program as arrays of strings. Strings are joined by + * adding a new line character in between each of them. + * @param fragmentCode GLES20 fragment shader program as arrays of strings. Strings are joined by + * adding a new line character in between each of them. + * @return GLES20 program id. + */ + public static int compileProgram(String[] vertexCode, String[] fragmentCode) { + return compileProgram(TextUtils.join("\n", vertexCode), TextUtils.join("\n", fragmentCode)); + } + + /** + * Builds a GL shader program from vertex and fragment shader code. + * + * @param vertexCode GLES20 vertex shader program. + * @param fragmentCode GLES20 fragment shader program. + * @return GLES20 program id. + */ + public static int compileProgram(String vertexCode, String fragmentCode) { + int program = GLES20.glCreateProgram(); + checkGlError(); + + // Add the vertex and fragment shaders. + addShader(GLES20.GL_VERTEX_SHADER, vertexCode, program); + addShader(GLES20.GL_FRAGMENT_SHADER, fragmentCode, program); + + // Link and check for errors. + GLES20.glLinkProgram(program); + int[] linkStatus = new int[] {GLES20.GL_FALSE}; + GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linkStatus, 0); + if (linkStatus[0] != GLES20.GL_TRUE) { + throwGlError("Unable to link shader program: \n" + GLES20.glGetProgramInfoLog(program)); + } + checkGlError(); + + return program; + } + + /** Returns the {@link Attribute}s in the specified {@code program}. */ + public static Attribute[] getAttributes(int program) { + int[] attributeCount = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_ACTIVE_ATTRIBUTES, attributeCount, 0); + if (attributeCount[0] != 2) { + throw new IllegalStateException("expected two attributes"); + } + + Attribute[] attributes = new Attribute[attributeCount[0]]; + for (int i = 0; i < attributeCount[0]; i++) { + attributes[i] = new Attribute(program, i); + } + return attributes; + } + + /** Returns the {@link Uniform}s in the specified {@code program}. */ + public static Uniform[] getUniforms(int program) { + int[] uniformCount = new int[1]; + GLES20.glGetProgramiv(program, GLES20.GL_ACTIVE_UNIFORMS, uniformCount, 0); + + Uniform[] uniforms = new Uniform[uniformCount[0]]; + for (int i = 0; i < uniformCount[0]; i++) { + uniforms[i] = new Uniform(program, i); + } + + return uniforms; + } + + /** + * Allocates a FloatBuffer with the given data. + * + * @param data Used to initialize the new buffer. + */ + public static FloatBuffer createBuffer(float[] data) { + return (FloatBuffer) createBuffer(data.length).put(data).flip(); + } + + /** + * Allocates a FloatBuffer. + * + * @param capacity The new buffer's capacity, in floats. + */ + public static FloatBuffer createBuffer(int capacity) { + ByteBuffer byteBuffer = ByteBuffer.allocateDirect(capacity * C.BYTES_PER_FLOAT); + return byteBuffer.order(ByteOrder.nativeOrder()).asFloatBuffer(); + } + + /** + * Creates a GL_TEXTURE_EXTERNAL_OES with default configuration of GL_LINEAR filtering and + * GL_CLAMP_TO_EDGE wrapping. + */ + public static int createExternalTexture() { + int[] texId = new int[1]; + GLES20.glGenTextures(1, IntBuffer.wrap(texId)); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, texId[0]); + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri( + GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + checkGlError(); + return texId[0]; + } + + private static void addShader(int type, String source, int program) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + + int[] result = new int[] {GLES20.GL_FALSE}; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, result, 0); + if (result[0] != GLES20.GL_TRUE) { + throwGlError(GLES20.glGetShaderInfoLog(shader) + ", source: " + source); + } + + GLES20.glAttachShader(program, shader); + GLES20.glDeleteShader(shader); + checkGlError(); + } + + private static void throwGlError(String errorMsg) { + Log.e(TAG, errorMsg); + if (ExoPlayerLibraryInfo.GL_ASSERTIONS_ENABLED) { + throw new RuntimeException(errorMsg); + } + } + + /** Returns the length of the null-terminated string in {@code strVal}. */ + private static int strlen(byte[] strVal) { + for (int i = 0; i < strVal.length; ++i) { + if (strVal[i] == '\0') { + return i; + } + } + return strVal.length; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/HandlerWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/HandlerWrapper.java new file mode 100644 index 0000000000..2e412fa10f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/HandlerWrapper.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import androidx.annotation.Nullable; + +/** + * An interface to call through to a {@link Handler}. Instances must be created by calling {@link + * Clock#createHandler(Looper, Handler.Callback)} on {@link Clock#DEFAULT} for all non-test cases. + */ +public interface HandlerWrapper { + + /** @see Handler#getLooper() */ + Looper getLooper(); + + /** @see Handler#obtainMessage(int) */ + Message obtainMessage(int what); + + /** @see Handler#obtainMessage(int, Object) */ + Message obtainMessage(int what, @Nullable Object obj); + + /** @see Handler#obtainMessage(int, int, int) */ + Message obtainMessage(int what, int arg1, int arg2); + + /** @see Handler#obtainMessage(int, int, int, Object) */ + Message obtainMessage(int what, int arg1, int arg2, @Nullable Object obj); + + /** @see Handler#sendEmptyMessage(int) */ + boolean sendEmptyMessage(int what); + + /** @see Handler#sendEmptyMessageAtTime(int, long) */ + boolean sendEmptyMessageAtTime(int what, long uptimeMs); + + /** @see Handler#removeMessages(int) */ + void removeMessages(int what); + + /** @see Handler#removeCallbacksAndMessages(Object) */ + void removeCallbacksAndMessages(@Nullable Object token); + + /** @see Handler#post(Runnable) */ + boolean post(Runnable runnable); + + /** @see Handler#postDelayed(Runnable, long) */ + boolean postDelayed(Runnable runnable, long delayMs); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/LibraryLoader.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/LibraryLoader.java new file mode 100644 index 0000000000..31e582aac5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/LibraryLoader.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.util.Arrays; + +/** + * Configurable loader for native libraries. + */ +public final class LibraryLoader { + + private static final String TAG = "LibraryLoader"; + + private String[] nativeLibraries; + private boolean loadAttempted; + private boolean isAvailable; + + /** + * @param libraries The names of the libraries to load. + */ + public LibraryLoader(String... libraries) { + nativeLibraries = libraries; + } + + /** + * Overrides the names of the libraries to load. Must be called before any call to + * {@link #isAvailable()}. + */ + public synchronized void setLibraries(String... libraries) { + Assertions.checkState(!loadAttempted, "Cannot set libraries after loading"); + nativeLibraries = libraries; + } + + /** + * Returns whether the underlying libraries are available, loading them if necessary. + */ + public synchronized boolean isAvailable() { + if (loadAttempted) { + return isAvailable; + } + loadAttempted = true; + try { + for (String lib : nativeLibraries) { + System.loadLibrary(lib); + } + isAvailable = true; + } catch (UnsatisfiedLinkError exception) { + // Log a warning as an attempt to check for the library indicates that the app depends on an + // extension and generally would expect its native libraries to be available. + Log.w(TAG, "Failed to load " + Arrays.toString(nativeLibraries)); + } + return isAvailable; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Log.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Log.java new file mode 100644 index 0000000000..b6e4a25935 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Log.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.text.TextUtils; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.net.UnknownHostException; + +/** Wrapper around {@link android.util.Log} which allows to set the log level. */ +public final class Log { + + /** + * Log level for ExoPlayer logcat logging. One of {@link #LOG_LEVEL_ALL}, {@link #LOG_LEVEL_INFO}, + * {@link #LOG_LEVEL_WARNING}, {@link #LOG_LEVEL_ERROR} or {@link #LOG_LEVEL_OFF}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({LOG_LEVEL_ALL, LOG_LEVEL_INFO, LOG_LEVEL_WARNING, LOG_LEVEL_ERROR, LOG_LEVEL_OFF}) + @interface LogLevel {} + /** Log level to log all messages. */ + public static final int LOG_LEVEL_ALL = 0; + /** Log level to only log informative, warning and error messages. */ + public static final int LOG_LEVEL_INFO = 1; + /** Log level to only log warning and error messages. */ + public static final int LOG_LEVEL_WARNING = 2; + /** Log level to only log error messages. */ + public static final int LOG_LEVEL_ERROR = 3; + /** Log level to disable all logging. */ + public static final int LOG_LEVEL_OFF = Integer.MAX_VALUE; + + private static int logLevel = LOG_LEVEL_ALL; + private static boolean logStackTraces = true; + + private Log() {} + + /** Returns current {@link LogLevel} for ExoPlayer logcat logging. */ + public static @LogLevel int getLogLevel() { + return logLevel; + } + + /** Returns whether stack traces of {@link Throwable}s will be logged to logcat. */ + public boolean getLogStackTraces() { + return logStackTraces; + } + + /** + * Sets the {@link LogLevel} for ExoPlayer logcat logging. + * + * @param logLevel The new {@link LogLevel}. + */ + public static void setLogLevel(@LogLevel int logLevel) { + Log.logLevel = logLevel; + } + + /** + * Sets whether stack traces of {@link Throwable}s will be logged to logcat. Stack trace logging + * is enabled by default. + * + * @param logStackTraces Whether stack traces will be logged. + */ + public static void setLogStackTraces(boolean logStackTraces) { + Log.logStackTraces = logStackTraces; + } + + /** @see android.util.Log#d(String, String) */ + public static void d(String tag, String message) { + if (logLevel == LOG_LEVEL_ALL) { + android.util.Log.d(tag, message); + } + } + + /** @see android.util.Log#d(String, String, Throwable) */ + public static void d(String tag, String message, @Nullable Throwable throwable) { + d(tag, appendThrowableString(message, throwable)); + } + + /** @see android.util.Log#i(String, String) */ + public static void i(String tag, String message) { + if (logLevel <= LOG_LEVEL_INFO) { + android.util.Log.i(tag, message); + } + } + + /** @see android.util.Log#i(String, String, Throwable) */ + public static void i(String tag, String message, @Nullable Throwable throwable) { + i(tag, appendThrowableString(message, throwable)); + } + + /** @see android.util.Log#w(String, String) */ + public static void w(String tag, String message) { + if (logLevel <= LOG_LEVEL_WARNING) { + android.util.Log.w(tag, message); + } + } + + /** @see android.util.Log#w(String, String, Throwable) */ + public static void w(String tag, String message, @Nullable Throwable throwable) { + w(tag, appendThrowableString(message, throwable)); + } + + /** @see android.util.Log#e(String, String) */ + public static void e(String tag, String message) { + if (logLevel <= LOG_LEVEL_ERROR) { + android.util.Log.e(tag, message); + } + } + + /** @see android.util.Log#e(String, String, Throwable) */ + public static void e(String tag, String message, @Nullable Throwable throwable) { + e(tag, appendThrowableString(message, throwable)); + } + + /** + * Returns a string representation of a {@link Throwable} suitable for logging, taking into + * account whether {@link #setLogStackTraces(boolean)} stack trace logging} is enabled. + * + * <p>Stack trace logging may be unconditionally suppressed for some expected failure modes (e.g., + * {@link Throwable Throwables} that are expected if the device doesn't have network connectivity) + * to avoid log spam. + * + * @param throwable The {@link Throwable}. + * @return The string representation of the {@link Throwable}. + */ + @Nullable + public static String getThrowableString(@Nullable Throwable throwable) { + if (throwable == null) { + return null; + } else if (isCausedByUnknownHostException(throwable)) { + // UnknownHostException implies the device doesn't have network connectivity. + // UnknownHostException.getMessage() may return a string that's more verbose than desired for + // logging an expected failure mode. Conversely, android.util.Log.getStackTraceString has + // special handling to return the empty string, which can result in logging that doesn't + // indicate the failure mode at all. Hence we special case this exception to always return a + // concise but useful message. + return "UnknownHostException (no network)"; + } else if (!logStackTraces) { + return throwable.getMessage(); + } else { + return android.util.Log.getStackTraceString(throwable).trim().replace("\t", " "); + } + } + + private static String appendThrowableString(String message, @Nullable Throwable throwable) { + @Nullable String throwableString = getThrowableString(throwable); + if (!TextUtils.isEmpty(throwableString)) { + message += "\n " + throwableString.replace("\n", "\n ") + '\n'; + } + return message; + } + + private static boolean isCausedByUnknownHostException(@Nullable Throwable throwable) { + while (throwable != null) { + if (throwable instanceof UnknownHostException) { + return true; + } + throwable = throwable.getCause(); + } + return false; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/LongArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/LongArray.java new file mode 100644 index 0000000000..ef6f938ca8 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/LongArray.java @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.util.Arrays; + +/** + * An append-only, auto-growing {@code long[]}. + */ +public final class LongArray { + + private static final int DEFAULT_INITIAL_CAPACITY = 32; + + private int size; + private long[] values; + + public LongArray() { + this(DEFAULT_INITIAL_CAPACITY); + } + + /** + * @param initialCapacity The initial capacity of the array. + */ + public LongArray(int initialCapacity) { + values = new long[initialCapacity]; + } + + /** + * Appends a value. + * + * @param value The value to append. + */ + public void add(long value) { + if (size == values.length) { + values = Arrays.copyOf(values, size * 2); + } + values[size++] = value; + } + + /** + * Returns the value at a specified index. + * + * @param index The index. + * @return The corresponding value. + * @throws IndexOutOfBoundsException If the index is less than zero, or greater than or equal to + * {@link #size()}. + */ + public long get(int index) { + if (index < 0 || index >= size) { + throw new IndexOutOfBoundsException("Invalid index " + index + ", size is " + size); + } + return values[index]; + } + + /** + * Returns the current size of the array. + */ + public int size() { + return size; + } + + /** + * Copies the current values into a newly allocated primitive array. + * + * @return The primitive array containing the copied values. + */ + public long[] toArray() { + return Arrays.copyOf(values, size); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/MediaClock.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/MediaClock.java new file mode 100644 index 0000000000..029f3aa8f5 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/MediaClock.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; + +/** + * Tracks the progression of media time. + */ +public interface MediaClock { + + /** + * Returns the current media position in microseconds. + */ + long getPositionUs(); + + /** + * Attempts to set the playback parameters. The media clock may override these parameters if they + * are not supported. + * + * @param playbackParameters The playback parameters to attempt to set. + */ + void setPlaybackParameters(PlaybackParameters playbackParameters); + + /** + * Returns the active playback parameters. + */ + PlaybackParameters getPlaybackParameters(); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/MimeTypes.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/MimeTypes.java new file mode 100644 index 0000000000..594a62d63a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/MimeTypes.java @@ -0,0 +1,465 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.text.TextUtils; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.util.ArrayList; + +/** + * Defines common MIME types and helper methods. + */ +public final class MimeTypes { + + public static final String BASE_TYPE_VIDEO = "video"; + public static final String BASE_TYPE_AUDIO = "audio"; + public static final String BASE_TYPE_TEXT = "text"; + public static final String BASE_TYPE_APPLICATION = "application"; + + public static final String VIDEO_MP4 = BASE_TYPE_VIDEO + "/mp4"; + public static final String VIDEO_WEBM = BASE_TYPE_VIDEO + "/webm"; + public static final String VIDEO_H263 = BASE_TYPE_VIDEO + "/3gpp"; + public static final String VIDEO_H264 = BASE_TYPE_VIDEO + "/avc"; + public static final String VIDEO_H265 = BASE_TYPE_VIDEO + "/hevc"; + public static final String VIDEO_VP8 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp8"; + public static final String VIDEO_VP9 = BASE_TYPE_VIDEO + "/x-vnd.on2.vp9"; + public static final String VIDEO_AV1 = BASE_TYPE_VIDEO + "/av01"; + public static final String VIDEO_MP4V = BASE_TYPE_VIDEO + "/mp4v-es"; + public static final String VIDEO_MPEG = BASE_TYPE_VIDEO + "/mpeg"; + public static final String VIDEO_MPEG2 = BASE_TYPE_VIDEO + "/mpeg2"; + public static final String VIDEO_VC1 = BASE_TYPE_VIDEO + "/wvc1"; + public static final String VIDEO_DIVX = BASE_TYPE_VIDEO + "/divx"; + public static final String VIDEO_DOLBY_VISION = BASE_TYPE_VIDEO + "/dolby-vision"; + public static final String VIDEO_UNKNOWN = BASE_TYPE_VIDEO + "/x-unknown"; + + public static final String AUDIO_MP4 = BASE_TYPE_AUDIO + "/mp4"; + public static final String AUDIO_AAC = BASE_TYPE_AUDIO + "/mp4a-latm"; + public static final String AUDIO_WEBM = BASE_TYPE_AUDIO + "/webm"; + public static final String AUDIO_MPEG = BASE_TYPE_AUDIO + "/mpeg"; + public static final String AUDIO_MPEG_L1 = BASE_TYPE_AUDIO + "/mpeg-L1"; + public static final String AUDIO_MPEG_L2 = BASE_TYPE_AUDIO + "/mpeg-L2"; + public static final String AUDIO_RAW = BASE_TYPE_AUDIO + "/raw"; + public static final String AUDIO_ALAW = BASE_TYPE_AUDIO + "/g711-alaw"; + public static final String AUDIO_MLAW = BASE_TYPE_AUDIO + "/g711-mlaw"; + public static final String AUDIO_AC3 = BASE_TYPE_AUDIO + "/ac3"; + public static final String AUDIO_E_AC3 = BASE_TYPE_AUDIO + "/eac3"; + public static final String AUDIO_E_AC3_JOC = BASE_TYPE_AUDIO + "/eac3-joc"; + public static final String AUDIO_AC4 = BASE_TYPE_AUDIO + "/ac4"; + public static final String AUDIO_TRUEHD = BASE_TYPE_AUDIO + "/true-hd"; + public static final String AUDIO_DTS = BASE_TYPE_AUDIO + "/vnd.dts"; + public static final String AUDIO_DTS_HD = BASE_TYPE_AUDIO + "/vnd.dts.hd"; + public static final String AUDIO_DTS_EXPRESS = BASE_TYPE_AUDIO + "/vnd.dts.hd;profile=lbr"; + public static final String AUDIO_VORBIS = BASE_TYPE_AUDIO + "/vorbis"; + public static final String AUDIO_OPUS = BASE_TYPE_AUDIO + "/opus"; + public static final String AUDIO_AMR_NB = BASE_TYPE_AUDIO + "/3gpp"; + public static final String AUDIO_AMR_WB = BASE_TYPE_AUDIO + "/amr-wb"; + public static final String AUDIO_FLAC = BASE_TYPE_AUDIO + "/flac"; + public static final String AUDIO_ALAC = BASE_TYPE_AUDIO + "/alac"; + public static final String AUDIO_MSGSM = BASE_TYPE_AUDIO + "/gsm"; + public static final String AUDIO_UNKNOWN = BASE_TYPE_AUDIO + "/x-unknown"; + + public static final String TEXT_VTT = BASE_TYPE_TEXT + "/vtt"; + public static final String TEXT_SSA = BASE_TYPE_TEXT + "/x-ssa"; + + public static final String APPLICATION_MP4 = BASE_TYPE_APPLICATION + "/mp4"; + public static final String APPLICATION_WEBM = BASE_TYPE_APPLICATION + "/webm"; + public static final String APPLICATION_MPD = BASE_TYPE_APPLICATION + "/dash+xml"; + public static final String APPLICATION_M3U8 = BASE_TYPE_APPLICATION + "/x-mpegURL"; + public static final String APPLICATION_SS = BASE_TYPE_APPLICATION + "/vnd.ms-sstr+xml"; + public static final String APPLICATION_ID3 = BASE_TYPE_APPLICATION + "/id3"; + public static final String APPLICATION_CEA608 = BASE_TYPE_APPLICATION + "/cea-608"; + public static final String APPLICATION_CEA708 = BASE_TYPE_APPLICATION + "/cea-708"; + public static final String APPLICATION_SUBRIP = BASE_TYPE_APPLICATION + "/x-subrip"; + public static final String APPLICATION_TTML = BASE_TYPE_APPLICATION + "/ttml+xml"; + public static final String APPLICATION_TX3G = BASE_TYPE_APPLICATION + "/x-quicktime-tx3g"; + public static final String APPLICATION_MP4VTT = BASE_TYPE_APPLICATION + "/x-mp4-vtt"; + public static final String APPLICATION_MP4CEA608 = BASE_TYPE_APPLICATION + "/x-mp4-cea-608"; + public static final String APPLICATION_RAWCC = BASE_TYPE_APPLICATION + "/x-rawcc"; + public static final String APPLICATION_VOBSUB = BASE_TYPE_APPLICATION + "/vobsub"; + public static final String APPLICATION_PGS = BASE_TYPE_APPLICATION + "/pgs"; + public static final String APPLICATION_SCTE35 = BASE_TYPE_APPLICATION + "/x-scte35"; + public static final String APPLICATION_CAMERA_MOTION = BASE_TYPE_APPLICATION + "/x-camera-motion"; + public static final String APPLICATION_EMSG = BASE_TYPE_APPLICATION + "/x-emsg"; + public static final String APPLICATION_DVBSUBS = BASE_TYPE_APPLICATION + "/dvbsubs"; + public static final String APPLICATION_EXIF = BASE_TYPE_APPLICATION + "/x-exif"; + public static final String APPLICATION_ICY = BASE_TYPE_APPLICATION + "/x-icy"; + + private static final ArrayList<CustomMimeType> customMimeTypes = new ArrayList<>(); + + /** + * Registers a custom MIME type. Most applications do not need to call this method, as handling of + * standard MIME types is built in. These built-in MIME types take precedence over any registered + * via this method. If this method is used, it must be called before creating any player(s). + * + * @param mimeType The custom MIME type to register. + * @param codecPrefix The RFC 6381-style codec string prefix associated with the MIME type. + * @param trackType The {@link C}{@code .TRACK_TYPE_*} constant associated with the MIME type. + * This value is ignored if the top-level type of {@code mimeType} is audio, video or text. + */ + public static void registerCustomMimeType(String mimeType, String codecPrefix, int trackType) { + CustomMimeType customMimeType = new CustomMimeType(mimeType, codecPrefix, trackType); + int customMimeTypeCount = customMimeTypes.size(); + for (int i = 0; i < customMimeTypeCount; i++) { + if (mimeType.equals(customMimeTypes.get(i).mimeType)) { + customMimeTypes.remove(i); + break; + } + } + customMimeTypes.add(customMimeType); + } + + /** Returns whether the given string is an audio MIME type. */ + public static boolean isAudio(@Nullable String mimeType) { + return BASE_TYPE_AUDIO.equals(getTopLevelType(mimeType)); + } + + /** Returns whether the given string is a video MIME type. */ + public static boolean isVideo(@Nullable String mimeType) { + return BASE_TYPE_VIDEO.equals(getTopLevelType(mimeType)); + } + + /** Returns whether the given string is a text MIME type. */ + public static boolean isText(@Nullable String mimeType) { + return BASE_TYPE_TEXT.equals(getTopLevelType(mimeType)); + } + + /** Returns whether the given string is an application MIME type. */ + public static boolean isApplication(@Nullable String mimeType) { + return BASE_TYPE_APPLICATION.equals(getTopLevelType(mimeType)); + } + + /** + * Returns true if it is known that all samples in a stream of the given sample MIME type are + * guaranteed to be sync samples (i.e., {@link C#BUFFER_FLAG_KEY_FRAME} is guaranteed to be set on + * every sample). + * + * @param mimeType The sample MIME type. + * @return True if it is known that all samples in a stream of the given sample MIME type are + * guaranteed to be sync samples. False otherwise, including if {@code null} is passed. + */ + public static boolean allSamplesAreSyncSamples(@Nullable String mimeType) { + if (mimeType == null) { + return false; + } + // TODO: Consider adding additional audio MIME types here. + switch (mimeType) { + case AUDIO_AAC: + case AUDIO_MPEG: + case AUDIO_MPEG_L1: + case AUDIO_MPEG_L2: + return true; + default: + return false; + } + } + + /** + * Derives a video sample mimeType from a codecs attribute. + * + * @param codecs The codecs attribute. + * @return The derived video mimeType, or null if it could not be derived. + */ + @Nullable + public static String getVideoMediaMimeType(@Nullable String codecs) { + if (codecs == null) { + return null; + } + String[] codecList = Util.splitCodecs(codecs); + for (String codec : codecList) { + @Nullable String mimeType = getMediaMimeType(codec); + if (mimeType != null && isVideo(mimeType)) { + return mimeType; + } + } + return null; + } + + /** + * Derives a audio sample mimeType from a codecs attribute. + * + * @param codecs The codecs attribute. + * @return The derived audio mimeType, or null if it could not be derived. + */ + @Nullable + public static String getAudioMediaMimeType(@Nullable String codecs) { + if (codecs == null) { + return null; + } + String[] codecList = Util.splitCodecs(codecs); + for (String codec : codecList) { + @Nullable String mimeType = getMediaMimeType(codec); + if (mimeType != null && isAudio(mimeType)) { + return mimeType; + } + } + return null; + } + + /** + * Derives a mimeType from a codec identifier, as defined in RFC 6381. + * + * @param codec The codec identifier to derive. + * @return The mimeType, or null if it could not be derived. + */ + @Nullable + public static String getMediaMimeType(@Nullable String codec) { + if (codec == null) { + return null; + } + codec = Util.toLowerInvariant(codec.trim()); + if (codec.startsWith("avc1") || codec.startsWith("avc3")) { + return MimeTypes.VIDEO_H264; + } else if (codec.startsWith("hev1") || codec.startsWith("hvc1")) { + return MimeTypes.VIDEO_H265; + } else if (codec.startsWith("dvav") + || codec.startsWith("dva1") + || codec.startsWith("dvhe") + || codec.startsWith("dvh1")) { + return MimeTypes.VIDEO_DOLBY_VISION; + } else if (codec.startsWith("av01")) { + return MimeTypes.VIDEO_AV1; + } else if (codec.startsWith("vp9") || codec.startsWith("vp09")) { + return MimeTypes.VIDEO_VP9; + } else if (codec.startsWith("vp8") || codec.startsWith("vp08")) { + return MimeTypes.VIDEO_VP8; + } else if (codec.startsWith("mp4a")) { + @Nullable String mimeType = null; + if (codec.startsWith("mp4a.")) { + String objectTypeString = codec.substring(5); // remove the 'mp4a.' prefix + if (objectTypeString.length() >= 2) { + try { + String objectTypeHexString = Util.toUpperInvariant(objectTypeString.substring(0, 2)); + int objectTypeInt = Integer.parseInt(objectTypeHexString, 16); + mimeType = getMimeTypeFromMp4ObjectType(objectTypeInt); + } catch (NumberFormatException ignored) { + // Ignored. + } + } + } + return mimeType == null ? MimeTypes.AUDIO_AAC : mimeType; + } else if (codec.startsWith("ac-3") || codec.startsWith("dac3")) { + return MimeTypes.AUDIO_AC3; + } else if (codec.startsWith("ec-3") || codec.startsWith("dec3")) { + return MimeTypes.AUDIO_E_AC3; + } else if (codec.startsWith("ec+3")) { + return MimeTypes.AUDIO_E_AC3_JOC; + } else if (codec.startsWith("ac-4") || codec.startsWith("dac4")) { + return MimeTypes.AUDIO_AC4; + } else if (codec.startsWith("dtsc") || codec.startsWith("dtse")) { + return MimeTypes.AUDIO_DTS; + } else if (codec.startsWith("dtsh") || codec.startsWith("dtsl")) { + return MimeTypes.AUDIO_DTS_HD; + } else if (codec.startsWith("opus")) { + return MimeTypes.AUDIO_OPUS; + } else if (codec.startsWith("vorbis")) { + return MimeTypes.AUDIO_VORBIS; + } else if (codec.startsWith("flac")) { + return MimeTypes.AUDIO_FLAC; + } else if (codec.startsWith("stpp")) { + return MimeTypes.APPLICATION_TTML; + } else if (codec.startsWith("wvtt")) { + return MimeTypes.TEXT_VTT; + } else { + return getCustomMimeTypeForCodec(codec); + } + } + + /** + * Derives a mimeType from MP4 object type identifier, as defined in RFC 6381 and + * https://mp4ra.org/#/object_types. + * + * @param objectType The objectType identifier to derive. + * @return The mimeType, or null if it could not be derived. + */ + @Nullable + public static String getMimeTypeFromMp4ObjectType(int objectType) { + switch (objectType) { + case 0x20: + return MimeTypes.VIDEO_MP4V; + case 0x21: + return MimeTypes.VIDEO_H264; + case 0x23: + return MimeTypes.VIDEO_H265; + case 0x60: + case 0x61: + case 0x62: + case 0x63: + case 0x64: + case 0x65: + return MimeTypes.VIDEO_MPEG2; + case 0x6A: + return MimeTypes.VIDEO_MPEG; + case 0x69: + case 0x6B: + return MimeTypes.AUDIO_MPEG; + case 0xA3: + return MimeTypes.VIDEO_VC1; + case 0xB1: + return MimeTypes.VIDEO_VP9; + case 0x40: + case 0x66: + case 0x67: + case 0x68: + return MimeTypes.AUDIO_AAC; + case 0xA5: + return MimeTypes.AUDIO_AC3; + case 0xA6: + return MimeTypes.AUDIO_E_AC3; + case 0xA9: + case 0xAC: + return MimeTypes.AUDIO_DTS; + case 0xAA: + case 0xAB: + return MimeTypes.AUDIO_DTS_HD; + case 0xAD: + return MimeTypes.AUDIO_OPUS; + case 0xAE: + return MimeTypes.AUDIO_AC4; + default: + return null; + } + } + + /** + * Returns the {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified MIME type. + * {@link C#TRACK_TYPE_UNKNOWN} if the MIME type is not known or the mapping cannot be + * established. + * + * @param mimeType The MIME type. + * @return The {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified MIME type. + */ + public static int getTrackType(@Nullable String mimeType) { + if (TextUtils.isEmpty(mimeType)) { + return C.TRACK_TYPE_UNKNOWN; + } else if (isAudio(mimeType)) { + return C.TRACK_TYPE_AUDIO; + } else if (isVideo(mimeType)) { + return C.TRACK_TYPE_VIDEO; + } else if (isText(mimeType) || APPLICATION_CEA608.equals(mimeType) + || APPLICATION_CEA708.equals(mimeType) || APPLICATION_MP4CEA608.equals(mimeType) + || APPLICATION_SUBRIP.equals(mimeType) || APPLICATION_TTML.equals(mimeType) + || APPLICATION_TX3G.equals(mimeType) || APPLICATION_MP4VTT.equals(mimeType) + || APPLICATION_RAWCC.equals(mimeType) || APPLICATION_VOBSUB.equals(mimeType) + || APPLICATION_PGS.equals(mimeType) || APPLICATION_DVBSUBS.equals(mimeType)) { + return C.TRACK_TYPE_TEXT; + } else if (APPLICATION_ID3.equals(mimeType) + || APPLICATION_EMSG.equals(mimeType) + || APPLICATION_SCTE35.equals(mimeType)) { + return C.TRACK_TYPE_METADATA; + } else if (APPLICATION_CAMERA_MOTION.equals(mimeType)) { + return C.TRACK_TYPE_CAMERA_MOTION; + } else { + return getTrackTypeForCustomMimeType(mimeType); + } + } + + /** + * Returns the {@link C}{@code .ENCODING_*} constant that corresponds to specified MIME type, if + * it is an encoded (non-PCM) audio format, or {@link C#ENCODING_INVALID} otherwise. + * + * @param mimeType The MIME type. + * @return The {@link C}{@code .ENCODING_*} constant that corresponds to a specified MIME type, or + * {@link C#ENCODING_INVALID}. + */ + public static @C.Encoding int getEncoding(String mimeType) { + switch (mimeType) { + case MimeTypes.AUDIO_MPEG: + return C.ENCODING_MP3; + case MimeTypes.AUDIO_AC3: + return C.ENCODING_AC3; + case MimeTypes.AUDIO_E_AC3: + return C.ENCODING_E_AC3; + case MimeTypes.AUDIO_E_AC3_JOC: + return C.ENCODING_E_AC3_JOC; + case MimeTypes.AUDIO_AC4: + return C.ENCODING_AC4; + case MimeTypes.AUDIO_DTS: + return C.ENCODING_DTS; + case MimeTypes.AUDIO_DTS_HD: + return C.ENCODING_DTS_HD; + case MimeTypes.AUDIO_TRUEHD: + return C.ENCODING_DOLBY_TRUEHD; + default: + return C.ENCODING_INVALID; + } + } + + /** + * Equivalent to {@code getTrackType(getMediaMimeType(codec))}. + * + * @param codec The codec. + * @return The {@link C}{@code .TRACK_TYPE_*} constant that corresponds to a specified codec. + */ + public static int getTrackTypeOfCodec(String codec) { + return getTrackType(getMediaMimeType(codec)); + } + + /** + * Returns the top-level type of {@code mimeType}, or null if {@code mimeType} is null or does not + * contain a forward slash character ({@code '/'}). + */ + @Nullable + private static String getTopLevelType(@Nullable String mimeType) { + if (mimeType == null) { + return null; + } + int indexOfSlash = mimeType.indexOf('/'); + if (indexOfSlash == -1) { + return null; + } + return mimeType.substring(0, indexOfSlash); + } + + @Nullable + private static String getCustomMimeTypeForCodec(String codec) { + int customMimeTypeCount = customMimeTypes.size(); + for (int i = 0; i < customMimeTypeCount; i++) { + CustomMimeType customMimeType = customMimeTypes.get(i); + if (codec.startsWith(customMimeType.codecPrefix)) { + return customMimeType.mimeType; + } + } + return null; + } + + private static int getTrackTypeForCustomMimeType(String mimeType) { + int customMimeTypeCount = customMimeTypes.size(); + for (int i = 0; i < customMimeTypeCount; i++) { + CustomMimeType customMimeType = customMimeTypes.get(i); + if (mimeType.equals(customMimeType.mimeType)) { + return customMimeType.trackType; + } + } + return C.TRACK_TYPE_UNKNOWN; + } + + private MimeTypes() { + // Prevent instantiation. + } + + private static final class CustomMimeType { + public final String mimeType; + public final String codecPrefix; + public final int trackType; + + public CustomMimeType(String mimeType, String codecPrefix, int trackType) { + this.mimeType = mimeType; + this.codecPrefix = codecPrefix; + this.trackType = trackType; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NalUnitUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NalUnitUtil.java new file mode 100644 index 0000000000..d7409daa66 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NalUnitUtil.java @@ -0,0 +1,519 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** + * Utility methods for handling H.264/AVC and H.265/HEVC NAL units. + */ +public final class NalUnitUtil { + + private static final String TAG = "NalUnitUtil"; + + /** + * Holds data parsed from a sequence parameter set NAL unit. + */ + public static final class SpsData { + + public final int profileIdc; + public final int constraintsFlagsAndReservedZero2Bits; + public final int levelIdc; + public final int seqParameterSetId; + public final int width; + public final int height; + public final float pixelWidthAspectRatio; + public final boolean separateColorPlaneFlag; + public final boolean frameMbsOnlyFlag; + public final int frameNumLength; + public final int picOrderCountType; + public final int picOrderCntLsbLength; + public final boolean deltaPicOrderAlwaysZeroFlag; + + public SpsData( + int profileIdc, + int constraintsFlagsAndReservedZero2Bits, + int levelIdc, + int seqParameterSetId, + int width, + int height, + float pixelWidthAspectRatio, + boolean separateColorPlaneFlag, + boolean frameMbsOnlyFlag, + int frameNumLength, + int picOrderCountType, + int picOrderCntLsbLength, + boolean deltaPicOrderAlwaysZeroFlag) { + this.profileIdc = profileIdc; + this.constraintsFlagsAndReservedZero2Bits = constraintsFlagsAndReservedZero2Bits; + this.levelIdc = levelIdc; + this.seqParameterSetId = seqParameterSetId; + this.width = width; + this.height = height; + this.pixelWidthAspectRatio = pixelWidthAspectRatio; + this.separateColorPlaneFlag = separateColorPlaneFlag; + this.frameMbsOnlyFlag = frameMbsOnlyFlag; + this.frameNumLength = frameNumLength; + this.picOrderCountType = picOrderCountType; + this.picOrderCntLsbLength = picOrderCntLsbLength; + this.deltaPicOrderAlwaysZeroFlag = deltaPicOrderAlwaysZeroFlag; + } + + } + + /** + * Holds data parsed from a picture parameter set NAL unit. + */ + public static final class PpsData { + + public final int picParameterSetId; + public final int seqParameterSetId; + public final boolean bottomFieldPicOrderInFramePresentFlag; + + public PpsData(int picParameterSetId, int seqParameterSetId, + boolean bottomFieldPicOrderInFramePresentFlag) { + this.picParameterSetId = picParameterSetId; + this.seqParameterSetId = seqParameterSetId; + this.bottomFieldPicOrderInFramePresentFlag = bottomFieldPicOrderInFramePresentFlag; + } + + } + + /** Four initial bytes that must prefix NAL units for decoding. */ + public static final byte[] NAL_START_CODE = new byte[] {0, 0, 0, 1}; + + /** Value for aspect_ratio_idc indicating an extended aspect ratio, in H.264 and H.265 SPSs. */ + public static final int EXTENDED_SAR = 0xFF; + /** Aspect ratios indexed by aspect_ratio_idc, in H.264 and H.265 SPSs. */ + public static final float[] ASPECT_RATIO_IDC_VALUES = new float[] { + 1f /* Unspecified. Assume square */, + 1f, + 12f / 11f, + 10f / 11f, + 16f / 11f, + 40f / 33f, + 24f / 11f, + 20f / 11f, + 32f / 11f, + 80f / 33f, + 18f / 11f, + 15f / 11f, + 64f / 33f, + 160f / 99f, + 4f / 3f, + 3f / 2f, + 2f + }; + + private static final int H264_NAL_UNIT_TYPE_SEI = 6; // Supplemental enhancement information + private static final int H264_NAL_UNIT_TYPE_SPS = 7; // Sequence parameter set + private static final int H265_NAL_UNIT_TYPE_PREFIX_SEI = 39; + + private static final Object scratchEscapePositionsLock = new Object(); + + /** + * Temporary store for positions of escape codes in {@link #unescapeStream(byte[], int)}. Guarded + * by {@link #scratchEscapePositionsLock}. + */ + private static int[] scratchEscapePositions = new int[10]; + + /** + * Unescapes {@code data} up to the specified limit, replacing occurrences of [0, 0, 3] with + * [0, 0]. The unescaped data is returned in-place, with the return value indicating its length. + * <p> + * Executions of this method are mutually exclusive, so it should not be called with very large + * buffers. + * + * @param data The data to unescape. + * @param limit The limit (exclusive) of the data to unescape. + * @return The length of the unescaped data. + */ + public static int unescapeStream(byte[] data, int limit) { + synchronized (scratchEscapePositionsLock) { + int position = 0; + int scratchEscapeCount = 0; + while (position < limit) { + position = findNextUnescapeIndex(data, position, limit); + if (position < limit) { + if (scratchEscapePositions.length <= scratchEscapeCount) { + // Grow scratchEscapePositions to hold a larger number of positions. + scratchEscapePositions = Arrays.copyOf(scratchEscapePositions, + scratchEscapePositions.length * 2); + } + scratchEscapePositions[scratchEscapeCount++] = position; + position += 3; + } + } + + int unescapedLength = limit - scratchEscapeCount; + int escapedPosition = 0; // The position being read from. + int unescapedPosition = 0; // The position being written to. + for (int i = 0; i < scratchEscapeCount; i++) { + int nextEscapePosition = scratchEscapePositions[i]; + int copyLength = nextEscapePosition - escapedPosition; + System.arraycopy(data, escapedPosition, data, unescapedPosition, copyLength); + unescapedPosition += copyLength; + data[unescapedPosition++] = 0; + data[unescapedPosition++] = 0; + escapedPosition += copyLength + 3; + } + + int remainingLength = unescapedLength - unescapedPosition; + System.arraycopy(data, escapedPosition, data, unescapedPosition, remainingLength); + return unescapedLength; + } + } + + /** + * Discards data from the buffer up to the first SPS, where {@code data.position()} is interpreted + * as the length of the buffer. + * <p> + * When the method returns, {@code data.position()} will contain the new length of the buffer. If + * the buffer is not empty it is guaranteed to start with an SPS. + * + * @param data Buffer containing start code delimited NAL units. + */ + public static void discardToSps(ByteBuffer data) { + int length = data.position(); + int consecutiveZeros = 0; + int offset = 0; + while (offset + 1 < length) { + int value = data.get(offset) & 0xFF; + if (consecutiveZeros == 3) { + if (value == 1 && (data.get(offset + 1) & 0x1F) == H264_NAL_UNIT_TYPE_SPS) { + // Copy from this NAL unit onwards to the start of the buffer. + ByteBuffer offsetData = data.duplicate(); + offsetData.position(offset - 3); + offsetData.limit(length); + data.position(0); + data.put(offsetData); + return; + } + } else if (value == 0) { + consecutiveZeros++; + } + if (value != 0) { + consecutiveZeros = 0; + } + offset++; + } + // Empty the buffer if the SPS NAL unit was not found. + data.clear(); + } + + /** + * Returns whether the NAL unit with the specified header contains supplemental enhancement + * information. + * + * @param mimeType The sample MIME type. + * @param nalUnitHeaderFirstByte The first byte of nal_unit(). + * @return Whether the NAL unit with the specified header is an SEI NAL unit. + */ + public static boolean isNalUnitSei(String mimeType, byte nalUnitHeaderFirstByte) { + return (MimeTypes.VIDEO_H264.equals(mimeType) + && (nalUnitHeaderFirstByte & 0x1F) == H264_NAL_UNIT_TYPE_SEI) + || (MimeTypes.VIDEO_H265.equals(mimeType) + && ((nalUnitHeaderFirstByte & 0x7E) >> 1) == H265_NAL_UNIT_TYPE_PREFIX_SEI); + } + + /** + * Returns the type of the NAL unit in {@code data} that starts at {@code offset}. + * + * @param data The data to search. + * @param offset The start offset of a NAL unit. Must lie between {@code -3} (inclusive) and + * {@code data.length - 3} (exclusive). + * @return The type of the unit. + */ + public static int getNalUnitType(byte[] data, int offset) { + return data[offset + 3] & 0x1F; + } + + /** + * Returns the type of the H.265 NAL unit in {@code data} that starts at {@code offset}. + * + * @param data The data to search. + * @param offset The start offset of a NAL unit. Must lie between {@code -3} (inclusive) and + * {@code data.length - 3} (exclusive). + * @return The type of the unit. + */ + public static int getH265NalUnitType(byte[] data, int offset) { + return (data[offset + 3] & 0x7E) >> 1; + } + + /** + * Parses an SPS NAL unit using the syntax defined in ITU-T Recommendation H.264 (2013) subsection + * 7.3.2.1.1. + * + * @param nalData A buffer containing escaped SPS data. + * @param nalOffset The offset of the NAL unit header in {@code nalData}. + * @param nalLimit The limit of the NAL unit in {@code nalData}. + * @return A parsed representation of the SPS data. + */ + public static SpsData parseSpsNalUnit(byte[] nalData, int nalOffset, int nalLimit) { + ParsableNalUnitBitArray data = new ParsableNalUnitBitArray(nalData, nalOffset, nalLimit); + data.skipBits(8); // nal_unit + int profileIdc = data.readBits(8); + int constraintsFlagsAndReservedZero2Bits = data.readBits(8); + int levelIdc = data.readBits(8); + int seqParameterSetId = data.readUnsignedExpGolombCodedInt(); + + int chromaFormatIdc = 1; // Default is 4:2:0 + boolean separateColorPlaneFlag = false; + if (profileIdc == 100 || profileIdc == 110 || profileIdc == 122 || profileIdc == 244 + || profileIdc == 44 || profileIdc == 83 || profileIdc == 86 || profileIdc == 118 + || profileIdc == 128 || profileIdc == 138) { + chromaFormatIdc = data.readUnsignedExpGolombCodedInt(); + if (chromaFormatIdc == 3) { + separateColorPlaneFlag = data.readBit(); + } + data.readUnsignedExpGolombCodedInt(); // bit_depth_luma_minus8 + data.readUnsignedExpGolombCodedInt(); // bit_depth_chroma_minus8 + data.skipBit(); // qpprime_y_zero_transform_bypass_flag + boolean seqScalingMatrixPresentFlag = data.readBit(); + if (seqScalingMatrixPresentFlag) { + int limit = (chromaFormatIdc != 3) ? 8 : 12; + for (int i = 0; i < limit; i++) { + boolean seqScalingListPresentFlag = data.readBit(); + if (seqScalingListPresentFlag) { + skipScalingList(data, i < 6 ? 16 : 64); + } + } + } + } + + int frameNumLength = data.readUnsignedExpGolombCodedInt() + 4; // log2_max_frame_num_minus4 + 4 + int picOrderCntType = data.readUnsignedExpGolombCodedInt(); + int picOrderCntLsbLength = 0; + boolean deltaPicOrderAlwaysZeroFlag = false; + if (picOrderCntType == 0) { + // log2_max_pic_order_cnt_lsb_minus4 + 4 + picOrderCntLsbLength = data.readUnsignedExpGolombCodedInt() + 4; + } else if (picOrderCntType == 1) { + deltaPicOrderAlwaysZeroFlag = data.readBit(); // delta_pic_order_always_zero_flag + data.readSignedExpGolombCodedInt(); // offset_for_non_ref_pic + data.readSignedExpGolombCodedInt(); // offset_for_top_to_bottom_field + long numRefFramesInPicOrderCntCycle = data.readUnsignedExpGolombCodedInt(); + for (int i = 0; i < numRefFramesInPicOrderCntCycle; i++) { + data.readUnsignedExpGolombCodedInt(); // offset_for_ref_frame[i] + } + } + data.readUnsignedExpGolombCodedInt(); // max_num_ref_frames + data.skipBit(); // gaps_in_frame_num_value_allowed_flag + + int picWidthInMbs = data.readUnsignedExpGolombCodedInt() + 1; + int picHeightInMapUnits = data.readUnsignedExpGolombCodedInt() + 1; + boolean frameMbsOnlyFlag = data.readBit(); + int frameHeightInMbs = (2 - (frameMbsOnlyFlag ? 1 : 0)) * picHeightInMapUnits; + if (!frameMbsOnlyFlag) { + data.skipBit(); // mb_adaptive_frame_field_flag + } + + data.skipBit(); // direct_8x8_inference_flag + int frameWidth = picWidthInMbs * 16; + int frameHeight = frameHeightInMbs * 16; + boolean frameCroppingFlag = data.readBit(); + if (frameCroppingFlag) { + int frameCropLeftOffset = data.readUnsignedExpGolombCodedInt(); + int frameCropRightOffset = data.readUnsignedExpGolombCodedInt(); + int frameCropTopOffset = data.readUnsignedExpGolombCodedInt(); + int frameCropBottomOffset = data.readUnsignedExpGolombCodedInt(); + int cropUnitX; + int cropUnitY; + if (chromaFormatIdc == 0) { + cropUnitX = 1; + cropUnitY = 2 - (frameMbsOnlyFlag ? 1 : 0); + } else { + int subWidthC = (chromaFormatIdc == 3) ? 1 : 2; + int subHeightC = (chromaFormatIdc == 1) ? 2 : 1; + cropUnitX = subWidthC; + cropUnitY = subHeightC * (2 - (frameMbsOnlyFlag ? 1 : 0)); + } + frameWidth -= (frameCropLeftOffset + frameCropRightOffset) * cropUnitX; + frameHeight -= (frameCropTopOffset + frameCropBottomOffset) * cropUnitY; + } + + float pixelWidthHeightRatio = 1; + boolean vuiParametersPresentFlag = data.readBit(); + if (vuiParametersPresentFlag) { + boolean aspectRatioInfoPresentFlag = data.readBit(); + if (aspectRatioInfoPresentFlag) { + int aspectRatioIdc = data.readBits(8); + if (aspectRatioIdc == NalUnitUtil.EXTENDED_SAR) { + int sarWidth = data.readBits(16); + int sarHeight = data.readBits(16); + if (sarWidth != 0 && sarHeight != 0) { + pixelWidthHeightRatio = (float) sarWidth / sarHeight; + } + } else if (aspectRatioIdc < NalUnitUtil.ASPECT_RATIO_IDC_VALUES.length) { + pixelWidthHeightRatio = NalUnitUtil.ASPECT_RATIO_IDC_VALUES[aspectRatioIdc]; + } else { + Log.w(TAG, "Unexpected aspect_ratio_idc value: " + aspectRatioIdc); + } + } + } + + return new SpsData( + profileIdc, + constraintsFlagsAndReservedZero2Bits, + levelIdc, + seqParameterSetId, + frameWidth, + frameHeight, + pixelWidthHeightRatio, + separateColorPlaneFlag, + frameMbsOnlyFlag, + frameNumLength, + picOrderCntType, + picOrderCntLsbLength, + deltaPicOrderAlwaysZeroFlag); + } + + /** + * Parses a PPS NAL unit using the syntax defined in ITU-T Recommendation H.264 (2013) subsection + * 7.3.2.2. + * + * @param nalData A buffer containing escaped PPS data. + * @param nalOffset The offset of the NAL unit header in {@code nalData}. + * @param nalLimit The limit of the NAL unit in {@code nalData}. + * @return A parsed representation of the PPS data. + */ + public static PpsData parsePpsNalUnit(byte[] nalData, int nalOffset, int nalLimit) { + ParsableNalUnitBitArray data = new ParsableNalUnitBitArray(nalData, nalOffset, nalLimit); + data.skipBits(8); // nal_unit + int picParameterSetId = data.readUnsignedExpGolombCodedInt(); + int seqParameterSetId = data.readUnsignedExpGolombCodedInt(); + data.skipBit(); // entropy_coding_mode_flag + boolean bottomFieldPicOrderInFramePresentFlag = data.readBit(); + return new PpsData(picParameterSetId, seqParameterSetId, bottomFieldPicOrderInFramePresentFlag); + } + + /** + * Finds the first NAL unit in {@code data}. + * <p> + * If {@code prefixFlags} is null then the first three bytes of a NAL unit must be entirely + * contained within the part of the array being searched in order for it to be found. + * <p> + * When {@code prefixFlags} is non-null, this method supports finding NAL units whose first four + * bytes span {@code data} arrays passed to successive calls. To use this feature, pass the same + * {@code prefixFlags} parameter to successive calls. State maintained in this parameter enables + * the detection of such NAL units. Note that when using this feature, the return value may be 3, + * 2 or 1 less than {@code startOffset}, to indicate a NAL unit starting 3, 2 or 1 bytes before + * the first byte in the current array. + * + * @param data The data to search. + * @param startOffset The offset (inclusive) in the data to start the search. + * @param endOffset The offset (exclusive) in the data to end the search. + * @param prefixFlags A boolean array whose first three elements are used to store the state + * required to detect NAL units where the NAL unit prefix spans array boundaries. The array + * must be at least 3 elements long. + * @return The offset of the NAL unit, or {@code endOffset} if a NAL unit was not found. + */ + public static int findNalUnit(byte[] data, int startOffset, int endOffset, + boolean[] prefixFlags) { + int length = endOffset - startOffset; + + Assertions.checkState(length >= 0); + if (length == 0) { + return endOffset; + } + + if (prefixFlags != null) { + if (prefixFlags[0]) { + clearPrefixFlags(prefixFlags); + return startOffset - 3; + } else if (length > 1 && prefixFlags[1] && data[startOffset] == 1) { + clearPrefixFlags(prefixFlags); + return startOffset - 2; + } else if (length > 2 && prefixFlags[2] && data[startOffset] == 0 + && data[startOffset + 1] == 1) { + clearPrefixFlags(prefixFlags); + return startOffset - 1; + } + } + + int limit = endOffset - 1; + // We're looking for the NAL unit start code prefix 0x000001. The value of i tracks the index of + // the third byte. + for (int i = startOffset + 2; i < limit; i += 3) { + if ((data[i] & 0xFE) != 0) { + // There isn't a NAL prefix here, or at the next two positions. Do nothing and let the + // loop advance the index by three. + } else if (data[i - 2] == 0 && data[i - 1] == 0 && data[i] == 1) { + if (prefixFlags != null) { + clearPrefixFlags(prefixFlags); + } + return i - 2; + } else { + // There isn't a NAL prefix here, but there might be at the next position. We should + // only skip forward by one. The loop will skip forward by three, so subtract two here. + i -= 2; + } + } + + if (prefixFlags != null) { + // True if the last three bytes in the data seen so far are {0,0,1}. + prefixFlags[0] = length > 2 + ? (data[endOffset - 3] == 0 && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) + : length == 2 ? (prefixFlags[2] && data[endOffset - 2] == 0 && data[endOffset - 1] == 1) + : (prefixFlags[1] && data[endOffset - 1] == 1); + // True if the last two bytes in the data seen so far are {0,0}. + prefixFlags[1] = length > 1 ? data[endOffset - 2] == 0 && data[endOffset - 1] == 0 + : prefixFlags[2] && data[endOffset - 1] == 0; + // True if the last byte in the data seen so far is {0}. + prefixFlags[2] = data[endOffset - 1] == 0; + } + + return endOffset; + } + + /** + * Clears prefix flags, as used by {@link #findNalUnit(byte[], int, int, boolean[])}. + * + * @param prefixFlags The flags to clear. + */ + public static void clearPrefixFlags(boolean[] prefixFlags) { + prefixFlags[0] = false; + prefixFlags[1] = false; + prefixFlags[2] = false; + } + + private static int findNextUnescapeIndex(byte[] bytes, int offset, int limit) { + for (int i = offset; i < limit - 2; i++) { + if (bytes[i] == 0x00 && bytes[i + 1] == 0x00 && bytes[i + 2] == 0x03) { + return i; + } + } + return limit; + } + + private static void skipScalingList(ParsableNalUnitBitArray bitArray, int size) { + int lastScale = 8; + int nextScale = 8; + for (int i = 0; i < size; i++) { + if (nextScale != 0) { + int deltaScale = bitArray.readSignedExpGolombCodedInt(); + nextScale = (lastScale + deltaScale + 256) % 256; + } + lastScale = (nextScale == 0) ? lastScale : nextScale; + } + } + + private NalUnitUtil() { + // Prevent instantiation. + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NonNullApi.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NonNullApi.java new file mode 100644 index 0000000000..0c9b9b2182 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NonNullApi.java @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.annotation.Nonnull; +import javax.annotation.meta.TypeQualifierDefault; +import kotlin.annotations.jvm.MigrationStatus; +import kotlin.annotations.jvm.UnderMigration; + +/** + * Annotation to declare all type usages in the annotated instance as {@link Nonnull}, unless + * explicitly marked with a nullable annotation. + */ +@Nonnull +@TypeQualifierDefault(ElementType.TYPE_USE) +@UnderMigration(status = MigrationStatus.STRICT) +@Retention(RetentionPolicy.CLASS) +public @interface NonNullApi {} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NotificationUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NotificationUtil.java new file mode 100644 index 0000000000..df68c8fe59 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/NotificationUtil.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Utility methods for displaying {@link Notification Notifications}. */ +@SuppressLint("InlinedApi") +public final class NotificationUtil { + + /** + * Notification channel importance levels. One of {@link #IMPORTANCE_UNSPECIFIED}, {@link + * #IMPORTANCE_NONE}, {@link #IMPORTANCE_MIN}, {@link #IMPORTANCE_LOW}, {@link + * #IMPORTANCE_DEFAULT} or {@link #IMPORTANCE_HIGH}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + IMPORTANCE_UNSPECIFIED, + IMPORTANCE_NONE, + IMPORTANCE_MIN, + IMPORTANCE_LOW, + IMPORTANCE_DEFAULT, + IMPORTANCE_HIGH + }) + public @interface Importance {} + /** @see NotificationManager#IMPORTANCE_UNSPECIFIED */ + public static final int IMPORTANCE_UNSPECIFIED = NotificationManager.IMPORTANCE_UNSPECIFIED; + /** @see NotificationManager#IMPORTANCE_NONE */ + public static final int IMPORTANCE_NONE = NotificationManager.IMPORTANCE_NONE; + /** @see NotificationManager#IMPORTANCE_MIN */ + public static final int IMPORTANCE_MIN = NotificationManager.IMPORTANCE_MIN; + /** @see NotificationManager#IMPORTANCE_LOW */ + public static final int IMPORTANCE_LOW = NotificationManager.IMPORTANCE_LOW; + /** @see NotificationManager#IMPORTANCE_DEFAULT */ + public static final int IMPORTANCE_DEFAULT = NotificationManager.IMPORTANCE_DEFAULT; + /** @see NotificationManager#IMPORTANCE_HIGH */ + public static final int IMPORTANCE_HIGH = NotificationManager.IMPORTANCE_HIGH; + + /** @deprecated Use {@link #createNotificationChannel(Context, String, int, int, int)}. */ + @Deprecated + public static void createNotificationChannel( + Context context, String id, @StringRes int nameResourceId, @Importance int importance) { + createNotificationChannel( + context, id, nameResourceId, /* descriptionResourceId= */ 0, importance); + } + + /** + * Creates a notification channel that notifications can be posted to. See {@link + * NotificationChannel} and {@link + * NotificationManager#createNotificationChannel(NotificationChannel)} for details. + * + * @param context A {@link Context}. + * @param id The id of the channel. Must be unique per package. The value may be truncated if it's + * too long. + * @param nameResourceId A string resource identifier for the user visible name of the channel. + * The recommended maximum length is 40 characters. The string may be truncated if it's too + * long. You can rename the channel when the system locale changes by listening for the {@link + * Intent#ACTION_LOCALE_CHANGED} broadcast. + * @param descriptionResourceId A string resource identifier for the user visible description of + * the channel, or 0 if no description is provided. The recommended maximum length is 300 + * characters. The value may be truncated if it is too long. You can change the description of + * the channel when the system locale changes by listening for the {@link + * Intent#ACTION_LOCALE_CHANGED} broadcast. + * @param importance The importance of the channel. This controls how interruptive notifications + * posted to this channel are. One of {@link #IMPORTANCE_UNSPECIFIED}, {@link + * #IMPORTANCE_NONE}, {@link #IMPORTANCE_MIN}, {@link #IMPORTANCE_LOW}, {@link + * #IMPORTANCE_DEFAULT} and {@link #IMPORTANCE_HIGH}. + */ + public static void createNotificationChannel( + Context context, + String id, + @StringRes int nameResourceId, + @StringRes int descriptionResourceId, + @Importance int importance) { + if (Util.SDK_INT >= 26) { + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel channel = + new NotificationChannel(id, context.getString(nameResourceId), importance); + if (descriptionResourceId != 0) { + channel.setDescription(context.getString(descriptionResourceId)); + } + notificationManager.createNotificationChannel(channel); + } + } + + /** + * Post a notification to be shown in the status bar. If a notification with the same id has + * already been posted by your application and has not yet been canceled, it will be replaced by + * the updated information. If {@code notification} is {@code null} then any notification + * previously shown with the specified id will be cancelled. + * + * @param context A {@link Context}. + * @param id The notification id. + * @param notification The {@link Notification} to post, or {@code null} to cancel a previously + * shown notification. + */ + public static void setNotification(Context context, int id, @Nullable Notification notification) { + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (notification != null) { + notificationManager.notify(id, notification); + } else { + notificationManager.cancel(id); + } + } + + private NotificationUtil() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableBitArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableBitArray.java new file mode 100644 index 0000000000..3d6a702723 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableBitArray.java @@ -0,0 +1,323 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +/** + * Wraps a byte array, providing methods that allow it to be read as a bitstream. + */ +public final class ParsableBitArray { + + public byte[] data; + + // The offset within the data, stored as the current byte offset, and the bit offset within that + // byte (from 0 to 7). + private int byteOffset; + private int bitOffset; + private int byteLimit; + + /** Creates a new instance that initially has no backing data. */ + public ParsableBitArray() { + data = Util.EMPTY_BYTE_ARRAY; + } + + /** + * Creates a new instance that wraps an existing array. + * + * @param data The data to wrap. + */ + public ParsableBitArray(byte[] data) { + this(data, data.length); + } + + /** + * Creates a new instance that wraps an existing array. + * + * @param data The data to wrap. + * @param limit The limit in bytes. + */ + public ParsableBitArray(byte[] data, int limit) { + this.data = data; + byteLimit = limit; + } + + /** + * Updates the instance to wrap {@code data}, and resets the position to zero. + * + * @param data The array to wrap. + */ + public void reset(byte[] data) { + reset(data, data.length); + } + + /** + * Sets this instance's data, position and limit to match the provided {@code parsableByteArray}. + * Any modifications to the underlying data array will be visible in both instances + * + * @param parsableByteArray The {@link ParsableByteArray}. + */ + public void reset(ParsableByteArray parsableByteArray) { + reset(parsableByteArray.data, parsableByteArray.limit()); + setPosition(parsableByteArray.getPosition() * 8); + } + + /** + * Updates the instance to wrap {@code data}, and resets the position to zero. + * + * @param data The array to wrap. + * @param limit The limit in bytes. + */ + public void reset(byte[] data, int limit) { + this.data = data; + byteOffset = 0; + bitOffset = 0; + byteLimit = limit; + } + + /** + * Returns the number of bits yet to be read. + */ + public int bitsLeft() { + return (byteLimit - byteOffset) * 8 - bitOffset; + } + + /** + * Returns the current bit offset. + */ + public int getPosition() { + return byteOffset * 8 + bitOffset; + } + + /** + * Returns the current byte offset. Must only be called when the position is byte aligned. + * + * @throws IllegalStateException If the position isn't byte aligned. + */ + public int getBytePosition() { + Assertions.checkState(bitOffset == 0); + return byteOffset; + } + + /** + * Sets the current bit offset. + * + * @param position The position to set. + */ + public void setPosition(int position) { + byteOffset = position / 8; + bitOffset = position - (byteOffset * 8); + assertValidOffset(); + } + + /** + * Skips a single bit. + */ + public void skipBit() { + if (++bitOffset == 8) { + bitOffset = 0; + byteOffset++; + } + assertValidOffset(); + } + + /** + * Skips bits and moves current reading position forward. + * + * @param numBits The number of bits to skip. + */ + public void skipBits(int numBits) { + int numBytes = numBits / 8; + byteOffset += numBytes; + bitOffset += numBits - (numBytes * 8); + if (bitOffset > 7) { + byteOffset++; + bitOffset -= 8; + } + assertValidOffset(); + } + + /** + * Reads a single bit. + * + * @return Whether the bit is set. + */ + public boolean readBit() { + boolean returnValue = (data[byteOffset] & (0x80 >> bitOffset)) != 0; + skipBit(); + return returnValue; + } + + /** + * Reads up to 32 bits. + * + * @param numBits The number of bits to read. + * @return An integer whose bottom {@code numBits} bits hold the read data. + */ + public int readBits(int numBits) { + if (numBits == 0) { + return 0; + } + int returnValue = 0; + bitOffset += numBits; + while (bitOffset > 8) { + bitOffset -= 8; + returnValue |= (data[byteOffset++] & 0xFF) << bitOffset; + } + returnValue |= (data[byteOffset] & 0xFF) >> (8 - bitOffset); + returnValue &= 0xFFFFFFFF >>> (32 - numBits); + if (bitOffset == 8) { + bitOffset = 0; + byteOffset++; + } + assertValidOffset(); + return returnValue; + } + + /** + * Reads up to 64 bits. + * + * @param numBits The number of bits to read. + * @return A long whose bottom {@code numBits} bits hold the read data. + */ + public long readBitsToLong(int numBits) { + if (numBits <= 32) { + return Util.toUnsignedLong(readBits(numBits)); + } + return Util.toLong(readBits(numBits - 32), readBits(32)); + } + + /** + * Reads {@code numBits} bits into {@code buffer}. + * + * @param buffer The array into which the read data should be written. The trailing {@code numBits + * % 8} bits are written into the most significant bits of the last modified {@code buffer} + * byte. The remaining ones are unmodified. + * @param offset The offset in {@code buffer} at which the read data should be written. + * @param numBits The number of bits to read. + */ + public void readBits(byte[] buffer, int offset, int numBits) { + // Whole bytes. + int to = offset + (numBits >> 3) /* numBits / 8 */; + for (int i = offset; i < to; i++) { + buffer[i] = (byte) (data[byteOffset++] << bitOffset); + buffer[i] = (byte) (buffer[i] | ((data[byteOffset] & 0xFF) >> (8 - bitOffset))); + } + // Trailing bits. + int bitsLeft = numBits & 7 /* numBits % 8 */; + if (bitsLeft == 0) { + return; + } + // Set bits that are going to be overwritten to 0. + buffer[to] = (byte) (buffer[to] & (0xFF >> bitsLeft)); + if (bitOffset + bitsLeft > 8) { + // We read the rest of data[byteOffset] and increase byteOffset. + buffer[to] = (byte) (buffer[to] | ((data[byteOffset++] & 0xFF) << bitOffset)); + bitOffset -= 8; + } + bitOffset += bitsLeft; + int lastDataByteTrailingBits = (data[byteOffset] & 0xFF) >> (8 - bitOffset); + buffer[to] |= (byte) (lastDataByteTrailingBits << (8 - bitsLeft)); + if (bitOffset == 8) { + bitOffset = 0; + byteOffset++; + } + assertValidOffset(); + } + + /** + * Aligns the position to the next byte boundary. Does nothing if the position is already aligned. + */ + public void byteAlign() { + if (bitOffset == 0) { + return; + } + bitOffset = 0; + byteOffset++; + assertValidOffset(); + } + + /** + * Reads the next {@code length} bytes into {@code buffer}. Must only be called when the position + * is byte aligned. + * + * @see System#arraycopy(Object, int, Object, int, int) + * @param buffer The array into which the read data should be written. + * @param offset The offset in {@code buffer} at which the read data should be written. + * @param length The number of bytes to read. + * @throws IllegalStateException If the position isn't byte aligned. + */ + public void readBytes(byte[] buffer, int offset, int length) { + Assertions.checkState(bitOffset == 0); + System.arraycopy(data, byteOffset, buffer, offset, length); + byteOffset += length; + assertValidOffset(); + } + + /** + * Skips the next {@code length} bytes. Must only be called when the position is byte aligned. + * + * @param length The number of bytes to read. + * @throws IllegalStateException If the position isn't byte aligned. + */ + public void skipBytes(int length) { + Assertions.checkState(bitOffset == 0); + byteOffset += length; + assertValidOffset(); + } + + /** + * Overwrites {@code numBits} from this array using the {@code numBits} least significant bits + * from {@code value}. Bits are written in order from most significant to least significant. The + * read position is advanced by {@code numBits}. + * + * @param value The integer whose {@code numBits} least significant bits are written into {@link + * #data}. + * @param numBits The number of bits to write. + */ + public void putInt(int value, int numBits) { + int remainingBitsToRead = numBits; + if (numBits < 32) { + value &= (1 << numBits) - 1; + } + int firstByteReadSize = Math.min(8 - bitOffset, numBits); + int firstByteRightPaddingSize = 8 - bitOffset - firstByteReadSize; + int firstByteBitmask = (0xFF00 >> bitOffset) | ((1 << firstByteRightPaddingSize) - 1); + data[byteOffset] = (byte) (data[byteOffset] & firstByteBitmask); + int firstByteInputBits = value >>> (numBits - firstByteReadSize); + data[byteOffset] = + (byte) (data[byteOffset] | (firstByteInputBits << firstByteRightPaddingSize)); + remainingBitsToRead -= firstByteReadSize; + int currentByteIndex = byteOffset + 1; + while (remainingBitsToRead > 8) { + data[currentByteIndex++] = (byte) (value >>> (remainingBitsToRead - 8)); + remainingBitsToRead -= 8; + } + int lastByteRightPaddingSize = 8 - remainingBitsToRead; + data[currentByteIndex] = + (byte) (data[currentByteIndex] & ((1 << lastByteRightPaddingSize) - 1)); + int lastByteInput = value & ((1 << remainingBitsToRead) - 1); + data[currentByteIndex] = + (byte) (data[currentByteIndex] | (lastByteInput << lastByteRightPaddingSize)); + skipBits(numBits); + assertValidOffset(); + } + + private void assertValidOffset() { + // It is fine for position to be at the end of the array, but no further. + Assertions.checkState(byteOffset >= 0 + && (byteOffset < byteLimit || (byteOffset == byteLimit && bitOffset == 0))); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableByteArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableByteArray.java new file mode 100644 index 0000000000..9ad9dd1aa7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableByteArray.java @@ -0,0 +1,586 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; + +/** + * Wraps a byte array, providing a set of methods for parsing data from it. Numerical values are + * parsed with the assumption that their constituent bytes are in big endian order. + */ +public final class ParsableByteArray { + + public byte[] data; + + private int position; + private int limit; + + /** Creates a new instance that initially has no backing data. */ + public ParsableByteArray() { + data = Util.EMPTY_BYTE_ARRAY; + } + + /** + * Creates a new instance with {@code limit} bytes and sets the limit. + * + * @param limit The limit to set. + */ + public ParsableByteArray(int limit) { + this.data = new byte[limit]; + this.limit = limit; + } + + /** + * Creates a new instance wrapping {@code data}, and sets the limit to {@code data.length}. + * + * @param data The array to wrap. + */ + public ParsableByteArray(byte[] data) { + this.data = data; + limit = data.length; + } + + /** + * Creates a new instance that wraps an existing array. + * + * @param data The data to wrap. + * @param limit The limit to set. + */ + public ParsableByteArray(byte[] data, int limit) { + this.data = data; + this.limit = limit; + } + + /** Sets the position and limit to zero. */ + public void reset() { + position = 0; + limit = 0; + } + + /** + * Resets the position to zero and the limit to the specified value. If the limit exceeds the + * capacity, {@code data} is replaced with a new array of sufficient size. + * + * @param limit The limit to set. + */ + public void reset(int limit) { + reset(capacity() < limit ? new byte[limit] : data, limit); + } + + /** + * Updates the instance to wrap {@code data}, and resets the position to zero and the limit to + * {@code data.length}. + * + * @param data The array to wrap. + */ + public void reset(byte[] data) { + reset(data, data.length); + } + + /** + * Updates the instance to wrap {@code data}, and resets the position to zero. + * + * @param data The array to wrap. + * @param limit The limit to set. + */ + public void reset(byte[] data, int limit) { + this.data = data; + this.limit = limit; + position = 0; + } + + /** + * Returns the number of bytes yet to be read. + */ + public int bytesLeft() { + return limit - position; + } + + /** + * Returns the limit. + */ + public int limit() { + return limit; + } + + /** + * Sets the limit. + * + * @param limit The limit to set. + */ + public void setLimit(int limit) { + Assertions.checkArgument(limit >= 0 && limit <= data.length); + this.limit = limit; + } + + /** + * Returns the current offset in the array, in bytes. + */ + public int getPosition() { + return position; + } + + /** + * Returns the capacity of the array, which may be larger than the limit. + */ + public int capacity() { + return data.length; + } + + /** + * Sets the reading offset in the array. + * + * @param position Byte offset in the array from which to read. + * @throws IllegalArgumentException Thrown if the new position is neither in nor at the end of the + * array. + */ + public void setPosition(int position) { + // It is fine for position to be at the end of the array. + Assertions.checkArgument(position >= 0 && position <= limit); + this.position = position; + } + + /** + * Moves the reading offset by {@code bytes}. + * + * @param bytes The number of bytes to skip. + * @throws IllegalArgumentException Thrown if the new position is neither in nor at the end of the + * array. + */ + public void skipBytes(int bytes) { + setPosition(position + bytes); + } + + /** + * Reads the next {@code length} bytes into {@code bitArray}, and resets the position of + * {@code bitArray} to zero. + * + * @param bitArray The {@link ParsableBitArray} into which the bytes should be read. + * @param length The number of bytes to write. + */ + public void readBytes(ParsableBitArray bitArray, int length) { + readBytes(bitArray.data, 0, length); + bitArray.setPosition(0); + } + + /** + * Reads the next {@code length} bytes into {@code buffer} at {@code offset}. + * + * @see System#arraycopy(Object, int, Object, int, int) + * @param buffer The array into which the read data should be written. + * @param offset The offset in {@code buffer} at which the read data should be written. + * @param length The number of bytes to read. + */ + public void readBytes(byte[] buffer, int offset, int length) { + System.arraycopy(data, position, buffer, offset, length); + position += length; + } + + /** + * Reads the next {@code length} bytes into {@code buffer}. + * + * @see ByteBuffer#put(byte[], int, int) + * @param buffer The {@link ByteBuffer} into which the read data should be written. + * @param length The number of bytes to read. + */ + public void readBytes(ByteBuffer buffer, int length) { + buffer.put(data, position, length); + position += length; + } + + /** + * Peeks at the next byte as an unsigned value. + */ + public int peekUnsignedByte() { + return (data[position] & 0xFF); + } + + /** + * Peeks at the next char. + */ + public char peekChar() { + return (char) ((data[position] & 0xFF) << 8 + | (data[position + 1] & 0xFF)); + } + + /** + * Reads the next byte as an unsigned value. + */ + public int readUnsignedByte() { + return (data[position++] & 0xFF); + } + + /** + * Reads the next two bytes as an unsigned value. + */ + public int readUnsignedShort() { + return (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF); + } + + /** + * Reads the next two bytes as an unsigned value. + */ + public int readLittleEndianUnsignedShort() { + return (data[position++] & 0xFF) | (data[position++] & 0xFF) << 8; + } + + /** + * Reads the next two bytes as a signed value. + */ + public short readShort() { + return (short) ((data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF)); + } + + /** + * Reads the next two bytes as a signed value. + */ + public short readLittleEndianShort() { + return (short) ((data[position++] & 0xFF) | (data[position++] & 0xFF) << 8); + } + + /** + * Reads the next three bytes as an unsigned value. + */ + public int readUnsignedInt24() { + return (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF); + } + + /** + * Reads the next three bytes as a signed value. + */ + public int readInt24() { + return ((data[position++] & 0xFF) << 24) >> 8 + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF); + } + + /** + * Reads the next three bytes as a signed value in little endian order. + */ + public int readLittleEndianInt24() { + return (data[position++] & 0xFF) + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF) << 16; + } + + /** + * Reads the next three bytes as an unsigned value in little endian order. + */ + public int readLittleEndianUnsignedInt24() { + return (data[position++] & 0xFF) + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF) << 16; + } + + /** + * Reads the next four bytes as an unsigned value. + */ + public long readUnsignedInt() { + return (data[position++] & 0xFFL) << 24 + | (data[position++] & 0xFFL) << 16 + | (data[position++] & 0xFFL) << 8 + | (data[position++] & 0xFFL); + } + + /** + * Reads the next four bytes as an unsigned value in little endian order. + */ + public long readLittleEndianUnsignedInt() { + return (data[position++] & 0xFFL) + | (data[position++] & 0xFFL) << 8 + | (data[position++] & 0xFFL) << 16 + | (data[position++] & 0xFFL) << 24; + } + + /** + * Reads the next four bytes as a signed value + */ + public int readInt() { + return (data[position++] & 0xFF) << 24 + | (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF); + } + + /** + * Reads the next four bytes as a signed value in little endian order. + */ + public int readLittleEndianInt() { + return (data[position++] & 0xFF) + | (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF) << 16 + | (data[position++] & 0xFF) << 24; + } + + /** + * Reads the next eight bytes as a signed value. + */ + public long readLong() { + return (data[position++] & 0xFFL) << 56 + | (data[position++] & 0xFFL) << 48 + | (data[position++] & 0xFFL) << 40 + | (data[position++] & 0xFFL) << 32 + | (data[position++] & 0xFFL) << 24 + | (data[position++] & 0xFFL) << 16 + | (data[position++] & 0xFFL) << 8 + | (data[position++] & 0xFFL); + } + + /** + * Reads the next eight bytes as a signed value in little endian order. + */ + public long readLittleEndianLong() { + return (data[position++] & 0xFFL) + | (data[position++] & 0xFFL) << 8 + | (data[position++] & 0xFFL) << 16 + | (data[position++] & 0xFFL) << 24 + | (data[position++] & 0xFFL) << 32 + | (data[position++] & 0xFFL) << 40 + | (data[position++] & 0xFFL) << 48 + | (data[position++] & 0xFFL) << 56; + } + + /** + * Reads the next four bytes, returning the integer portion of the fixed point 16.16 integer. + */ + public int readUnsignedFixedPoint1616() { + int result = (data[position++] & 0xFF) << 8 + | (data[position++] & 0xFF); + position += 2; // Skip the non-integer portion. + return result; + } + + /** + * Reads a Synchsafe integer. + * <p> + * Synchsafe integers keep the highest bit of every byte zeroed. A 32 bit synchsafe integer can + * store 28 bits of information. + * + * @return The parsed value. + */ + public int readSynchSafeInt() { + int b1 = readUnsignedByte(); + int b2 = readUnsignedByte(); + int b3 = readUnsignedByte(); + int b4 = readUnsignedByte(); + return (b1 << 21) | (b2 << 14) | (b3 << 7) | b4; + } + + /** + * Reads the next four bytes as an unsigned integer into an integer, if the top bit is a zero. + * + * @throws IllegalStateException Thrown if the top bit of the input data is set. + */ + public int readUnsignedIntToInt() { + int result = readInt(); + if (result < 0) { + throw new IllegalStateException("Top bit not zero: " + result); + } + return result; + } + + /** + * Reads the next four bytes as a little endian unsigned integer into an integer, if the top bit + * is a zero. + * + * @throws IllegalStateException Thrown if the top bit of the input data is set. + */ + public int readLittleEndianUnsignedIntToInt() { + int result = readLittleEndianInt(); + if (result < 0) { + throw new IllegalStateException("Top bit not zero: " + result); + } + return result; + } + + /** + * Reads the next eight bytes as an unsigned long into a long, if the top bit is a zero. + * + * @throws IllegalStateException Thrown if the top bit of the input data is set. + */ + public long readUnsignedLongToLong() { + long result = readLong(); + if (result < 0) { + throw new IllegalStateException("Top bit not zero: " + result); + } + return result; + } + + /** + * Reads the next four bytes as a 32-bit floating point value. + */ + public float readFloat() { + return Float.intBitsToFloat(readInt()); + } + + /** + * Reads the next eight bytes as a 64-bit floating point value. + */ + public double readDouble() { + return Double.longBitsToDouble(readLong()); + } + + /** + * Reads the next {@code length} bytes as UTF-8 characters. + * + * @param length The number of bytes to read. + * @return The string encoded by the bytes. + */ + public String readString(int length) { + return readString(length, Charset.forName(C.UTF8_NAME)); + } + + /** + * Reads the next {@code length} bytes as characters in the specified {@link Charset}. + * + * @param length The number of bytes to read. + * @param charset The character set of the encoded characters. + * @return The string encoded by the bytes in the specified character set. + */ + public String readString(int length, Charset charset) { + String result = new String(data, position, length, charset); + position += length; + return result; + } + + /** + * Reads the next {@code length} bytes as UTF-8 characters. A terminating NUL byte is discarded, + * if present. + * + * @param length The number of bytes to read. + * @return The string, not including any terminating NUL byte. + */ + public String readNullTerminatedString(int length) { + if (length == 0) { + return ""; + } + int stringLength = length; + int lastIndex = position + length - 1; + if (lastIndex < limit && data[lastIndex] == 0) { + stringLength--; + } + String result = Util.fromUtf8Bytes(data, position, stringLength); + position += length; + return result; + } + + /** + * Reads up to the next NUL byte (or the limit) as UTF-8 characters. + * + * @return The string not including any terminating NUL byte, or null if the end of the data has + * already been reached. + */ + @Nullable + public String readNullTerminatedString() { + if (bytesLeft() == 0) { + return null; + } + int stringLimit = position; + while (stringLimit < limit && data[stringLimit] != 0) { + stringLimit++; + } + String string = Util.fromUtf8Bytes(data, position, stringLimit - position); + position = stringLimit; + if (position < limit) { + position++; + } + return string; + } + + /** + * Reads a line of text. + * + * <p>A line is considered to be terminated by any one of a carriage return ('\r'), a line feed + * ('\n'), or a carriage return followed immediately by a line feed ('\r\n'). The system's default + * charset (UTF-8) is used. This method discards leading UTF-8 byte order marks, if present. + * + * @return The line not including any line-termination characters, or null if the end of the data + * has already been reached. + */ + @Nullable + public String readLine() { + if (bytesLeft() == 0) { + return null; + } + int lineLimit = position; + while (lineLimit < limit && !Util.isLinebreak(data[lineLimit])) { + lineLimit++; + } + if (lineLimit - position >= 3 && data[position] == (byte) 0xEF + && data[position + 1] == (byte) 0xBB && data[position + 2] == (byte) 0xBF) { + // There's a UTF-8 byte order mark at the start of the line. Discard it. + position += 3; + } + String line = Util.fromUtf8Bytes(data, position, lineLimit - position); + position = lineLimit; + if (position == limit) { + return line; + } + if (data[position] == '\r') { + position++; + if (position == limit) { + return line; + } + } + if (data[position] == '\n') { + position++; + } + return line; + } + + /** + * Reads a long value encoded by UTF-8 encoding + * + * @throws NumberFormatException if there is a problem with decoding + * @return Decoded long value + */ + public long readUtf8EncodedLong() { + int length = 0; + long value = data[position]; + // find the high most 0 bit + for (int j = 7; j >= 0; j--) { + if ((value & (1 << j)) == 0) { + if (j < 6) { + value &= (1 << j) - 1; + length = 7 - j; + } else if (j == 7) { + length = 1; + } + break; + } + } + if (length == 0) { + throw new NumberFormatException("Invalid UTF-8 sequence first byte: " + value); + } + for (int i = 1; i < length; i++) { + int x = data[position + i]; + if ((x & 0xC0) != 0x80) { // if the high most 0 bit not 7th + throw new NumberFormatException("Invalid UTF-8 sequence continuation byte: " + value); + } + value = (value << 6) | (x & 0x3F); + } + position += length; + return value; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableNalUnitBitArray.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableNalUnitBitArray.java new file mode 100644 index 0000000000..e73404fd91 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ParsableNalUnitBitArray.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +/** + * Wraps a byte array, providing methods that allow it to be read as a NAL unit bitstream. + * <p> + * Whenever the byte sequence [0, 0, 3] appears in the wrapped byte array, it is treated as [0, 0] + * for all reading/skipping operations, which makes the bitstream appear to be unescaped. + */ +public final class ParsableNalUnitBitArray { + + private byte[] data; + private int byteLimit; + + // The byte offset is never equal to the offset of the 3rd byte in a subsequence [0, 0, 3]. + private int byteOffset; + private int bitOffset; + + /** + * @param data The data to wrap. + * @param offset The byte offset in {@code data} to start reading from. + * @param limit The byte offset of the end of the bitstream in {@code data}. + */ + @SuppressWarnings({"initialization.fields.uninitialized", "method.invocation.invalid"}) + public ParsableNalUnitBitArray(byte[] data, int offset, int limit) { + reset(data, offset, limit); + } + + /** + * Resets the wrapped data, limit and offset. + * + * @param data The data to wrap. + * @param offset The byte offset in {@code data} to start reading from. + * @param limit The byte offset of the end of the bitstream in {@code data}. + */ + public void reset(byte[] data, int offset, int limit) { + this.data = data; + byteOffset = offset; + byteLimit = limit; + bitOffset = 0; + assertValidOffset(); + } + + /** + * Skips a single bit. + */ + public void skipBit() { + if (++bitOffset == 8) { + bitOffset = 0; + byteOffset += shouldSkipByte(byteOffset + 1) ? 2 : 1; + } + assertValidOffset(); + } + + /** + * Skips bits and moves current reading position forward. + * + * @param numBits The number of bits to skip. + */ + public void skipBits(int numBits) { + int oldByteOffset = byteOffset; + int numBytes = numBits / 8; + byteOffset += numBytes; + bitOffset += numBits - (numBytes * 8); + if (bitOffset > 7) { + byteOffset++; + bitOffset -= 8; + } + for (int i = oldByteOffset + 1; i <= byteOffset; i++) { + if (shouldSkipByte(i)) { + // Skip the byte and move forward to check three bytes ahead. + byteOffset++; + i += 2; + } + } + assertValidOffset(); + } + + /** + * Returns whether it's possible to read {@code n} bits starting from the current offset. The + * offset is not modified. + * + * @param numBits The number of bits. + * @return Whether it is possible to read {@code n} bits. + */ + public boolean canReadBits(int numBits) { + int oldByteOffset = byteOffset; + int numBytes = numBits / 8; + int newByteOffset = byteOffset + numBytes; + int newBitOffset = bitOffset + numBits - (numBytes * 8); + if (newBitOffset > 7) { + newByteOffset++; + newBitOffset -= 8; + } + for (int i = oldByteOffset + 1; i <= newByteOffset && newByteOffset < byteLimit; i++) { + if (shouldSkipByte(i)) { + // Skip the byte and move forward to check three bytes ahead. + newByteOffset++; + i += 2; + } + } + return newByteOffset < byteLimit || (newByteOffset == byteLimit && newBitOffset == 0); + } + + /** + * Reads a single bit. + * + * @return Whether the bit is set. + */ + public boolean readBit() { + boolean returnValue = (data[byteOffset] & (0x80 >> bitOffset)) != 0; + skipBit(); + return returnValue; + } + + /** + * Reads up to 32 bits. + * + * @param numBits The number of bits to read. + * @return An integer whose bottom n bits hold the read data. + */ + public int readBits(int numBits) { + int returnValue = 0; + bitOffset += numBits; + while (bitOffset > 8) { + bitOffset -= 8; + returnValue |= (data[byteOffset] & 0xFF) << bitOffset; + byteOffset += shouldSkipByte(byteOffset + 1) ? 2 : 1; + } + returnValue |= (data[byteOffset] & 0xFF) >> (8 - bitOffset); + returnValue &= 0xFFFFFFFF >>> (32 - numBits); + if (bitOffset == 8) { + bitOffset = 0; + byteOffset += shouldSkipByte(byteOffset + 1) ? 2 : 1; + } + assertValidOffset(); + return returnValue; + } + + /** + * Returns whether it is possible to read an Exp-Golomb-coded integer starting from the current + * offset. The offset is not modified. + * + * @return Whether it is possible to read an Exp-Golomb-coded integer. + */ + public boolean canReadExpGolombCodedNum() { + int initialByteOffset = byteOffset; + int initialBitOffset = bitOffset; + int leadingZeros = 0; + while (byteOffset < byteLimit && !readBit()) { + leadingZeros++; + } + boolean hitLimit = byteOffset == byteLimit; + byteOffset = initialByteOffset; + bitOffset = initialBitOffset; + return !hitLimit && canReadBits(leadingZeros * 2 + 1); + } + + /** + * Reads an unsigned Exp-Golomb-coded format integer. + * + * @return The value of the parsed Exp-Golomb-coded integer. + */ + public int readUnsignedExpGolombCodedInt() { + return readExpGolombCodeNum(); + } + + /** + * Reads an signed Exp-Golomb-coded format integer. + * + * @return The value of the parsed Exp-Golomb-coded integer. + */ + public int readSignedExpGolombCodedInt() { + int codeNum = readExpGolombCodeNum(); + return ((codeNum % 2) == 0 ? -1 : 1) * ((codeNum + 1) / 2); + } + + private int readExpGolombCodeNum() { + int leadingZeros = 0; + while (!readBit()) { + leadingZeros++; + } + return (1 << leadingZeros) - 1 + (leadingZeros > 0 ? readBits(leadingZeros) : 0); + } + + private boolean shouldSkipByte(int offset) { + return 2 <= offset && offset < byteLimit && data[offset] == (byte) 0x03 + && data[offset - 2] == (byte) 0x00 && data[offset - 1] == (byte) 0x00; + } + + private void assertValidOffset() { + // It is fine for position to be at the end of the array, but no further. + Assertions.checkState(byteOffset >= 0 + && (byteOffset < byteLimit || (byteOffset == byteLimit && bitOffset == 0))); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Predicate.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Predicate.java new file mode 100644 index 0000000000..d91d9f7254 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Predicate.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +/** + * Determines a true or false value for a given input. + * + * @param <T> The input type of the predicate. + */ +public interface Predicate<T> { + + /** + * Evaluates an input. + * + * @param input The input to evaluate. + * @return The evaluated result. + */ + boolean evaluate(T input); + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/PriorityTaskManager.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/PriorityTaskManager.java new file mode 100644 index 0000000000..1067014b40 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/PriorityTaskManager.java @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.io.IOException; +import java.util.Collections; +import java.util.PriorityQueue; + +/** + * Allows tasks with associated priorities to control how they proceed relative to one another. + * <p> + * A task should call {@link #add(int)} to register with the manager and {@link #remove(int)} to + * unregister. A registered task will prevent tasks of lower priority from proceeding, and should + * call {@link #proceed(int)}, {@link #proceedNonBlocking(int)} or {@link #proceedOrThrow(int)} each + * time it wishes to check whether it is itself allowed to proceed. + */ +public final class PriorityTaskManager { + + /** + * Thrown when task attempts to proceed when another registered task has a higher priority. + */ + public static class PriorityTooLowException extends IOException { + + public PriorityTooLowException(int priority, int highestPriority) { + super("Priority too low [priority=" + priority + ", highest=" + highestPriority + "]"); + } + + } + + private final Object lock = new Object(); + + // Guarded by lock. + private final PriorityQueue<Integer> queue; + private int highestPriority; + + public PriorityTaskManager() { + queue = new PriorityQueue<>(10, Collections.reverseOrder()); + highestPriority = Integer.MIN_VALUE; + } + + /** + * Register a new task. The task must call {@link #remove(int)} when done. + * + * @param priority The priority of the task. Larger values indicate higher priorities. + */ + public void add(int priority) { + synchronized (lock) { + queue.add(priority); + highestPriority = Math.max(highestPriority, priority); + } + } + + /** + * Blocks until the task is allowed to proceed. + * + * @param priority The priority of the task. + * @throws InterruptedException If the thread is interrupted. + */ + public void proceed(int priority) throws InterruptedException { + synchronized (lock) { + while (highestPriority != priority) { + lock.wait(); + } + } + } + + /** + * A non-blocking variant of {@link #proceed(int)}. + * + * @param priority The priority of the task. + * @return Whether the task is allowed to proceed. + */ + public boolean proceedNonBlocking(int priority) { + synchronized (lock) { + return highestPriority == priority; + } + } + + /** + * A throwing variant of {@link #proceed(int)}. + * + * @param priority The priority of the task. + * @throws PriorityTooLowException If the task is not allowed to proceed. + */ + public void proceedOrThrow(int priority) throws PriorityTooLowException { + synchronized (lock) { + if (highestPriority != priority) { + throw new PriorityTooLowException(priority, highestPriority); + } + } + } + + /** + * Unregister a task. + * + * @param priority The priority of the task. + */ + public void remove(int priority) { + synchronized (lock) { + queue.remove(priority); + highestPriority = queue.isEmpty() ? Integer.MIN_VALUE : Util.castNonNull(queue.peek()); + lock.notifyAll(); + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/RepeatModeUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/RepeatModeUtil.java new file mode 100644 index 0000000000..c4964e6848 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/RepeatModeUtil.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Player; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Util class for repeat mode handling. + */ +public final class RepeatModeUtil { + + // LINT.IfChange + /** + * Set of repeat toggle modes. Can be combined using bit-wise operations. Possible flag values are + * {@link #REPEAT_TOGGLE_MODE_NONE}, {@link #REPEAT_TOGGLE_MODE_ONE} and {@link + * #REPEAT_TOGGLE_MODE_ALL}. + */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {REPEAT_TOGGLE_MODE_NONE, REPEAT_TOGGLE_MODE_ONE, REPEAT_TOGGLE_MODE_ALL}) + public @interface RepeatToggleModes {} + /** + * All repeat mode buttons disabled. + */ + public static final int REPEAT_TOGGLE_MODE_NONE = 0; + /** + * "Repeat One" button enabled. + */ + public static final int REPEAT_TOGGLE_MODE_ONE = 1; + /** "Repeat All" button enabled. */ + public static final int REPEAT_TOGGLE_MODE_ALL = 1 << 1; // 2 + // LINT.ThenChange(../../../../../../../../../ui/src/main/res/values/attrs.xml) + + private RepeatModeUtil() { + // Prevent instantiation. + } + + /** + * Gets the next repeat mode out of {@code enabledModes} starting from {@code currentMode}. + * + * @param currentMode The current repeat mode. + * @param enabledModes Bitmask of enabled modes. + * @return The next repeat mode. + */ + public static @Player.RepeatMode int getNextRepeatMode(@Player.RepeatMode int currentMode, + int enabledModes) { + for (int offset = 1; offset <= 2; offset++) { + @Player.RepeatMode int proposedMode = (currentMode + offset) % 3; + if (isRepeatModeEnabled(proposedMode, enabledModes)) { + return proposedMode; + } + } + return currentMode; + } + + /** + * Verifies whether a given {@code repeatMode} is enabled in the bitmask {@code enabledModes}. + * + * @param repeatMode The mode to check. + * @param enabledModes The bitmask representing the enabled modes. + * @return {@code true} if enabled. + */ + public static boolean isRepeatModeEnabled(@Player.RepeatMode int repeatMode, int enabledModes) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + return true; + case Player.REPEAT_MODE_ONE: + return (enabledModes & REPEAT_TOGGLE_MODE_ONE) != 0; + case Player.REPEAT_MODE_ALL: + return (enabledModes & REPEAT_TOGGLE_MODE_ALL) != 0; + default: + return false; + } + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java new file mode 100644 index 0000000000..cd38892be0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/ReusableBufferedOutputStream.java @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * This is a subclass of {@link BufferedOutputStream} with a {@link #reset(OutputStream)} method + * that allows an instance to be re-used with another underlying output stream. + */ +public final class ReusableBufferedOutputStream extends BufferedOutputStream { + + private boolean closed; + + public ReusableBufferedOutputStream(OutputStream out) { + super(out); + } + + public ReusableBufferedOutputStream(OutputStream out, int size) { + super(out, size); + } + + @Override + public void close() throws IOException { + closed = true; + + Throwable thrown = null; + try { + flush(); + } catch (Throwable e) { + thrown = e; + } + try { + out.close(); + } catch (Throwable e) { + if (thrown == null) { + thrown = e; + } + } + if (thrown != null) { + Util.sneakyThrow(thrown); + } + } + + /** + * Resets this stream and uses the given output stream for writing. This stream must be closed + * before resetting. + * + * @param out New output stream to be used for writing. + * @throws IllegalStateException If the stream isn't closed. + */ + public void reset(OutputStream out) { + Assertions.checkState(closed); + this.out = out; + count = 0; + closed = false; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SlidingPercentile.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SlidingPercentile.java new file mode 100644 index 0000000000..9048de2f34 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SlidingPercentile.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; + +/** + * Calculate any percentile over a sliding window of weighted values. A maximum weight is + * configured. Once the total weight of the values reaches the maximum weight, the oldest value is + * reduced in weight until it reaches zero and is removed. This maintains a constant total weight, + * equal to the maximum allowed, at the steady state. + * <p> + * This class can be used for bandwidth estimation based on a sliding window of past transfer rate + * observations. This is an alternative to sliding mean and exponential averaging which suffer from + * susceptibility to outliers and slow adaptation to step functions. + * + * @see <a href="http://en.wikipedia.org/wiki/Moving_average">Wiki: Moving average</a> + * @see <a href="http://en.wikipedia.org/wiki/Selection_algorithm">Wiki: Selection algorithm</a> + */ +public class SlidingPercentile { + + // Orderings. + private static final Comparator<Sample> INDEX_COMPARATOR = (a, b) -> a.index - b.index; + private static final Comparator<Sample> VALUE_COMPARATOR = + (a, b) -> Float.compare(a.value, b.value); + + private static final int SORT_ORDER_NONE = -1; + private static final int SORT_ORDER_BY_VALUE = 0; + private static final int SORT_ORDER_BY_INDEX = 1; + + private static final int MAX_RECYCLED_SAMPLES = 5; + + private final int maxWeight; + private final ArrayList<Sample> samples; + + private final Sample[] recycledSamples; + + private int currentSortOrder; + private int nextSampleIndex; + private int totalWeight; + private int recycledSampleCount; + + /** + * @param maxWeight The maximum weight. + */ + public SlidingPercentile(int maxWeight) { + this.maxWeight = maxWeight; + recycledSamples = new Sample[MAX_RECYCLED_SAMPLES]; + samples = new ArrayList<>(); + currentSortOrder = SORT_ORDER_NONE; + } + + /** Resets the sliding percentile. */ + public void reset() { + samples.clear(); + currentSortOrder = SORT_ORDER_NONE; + nextSampleIndex = 0; + totalWeight = 0; + } + + /** + * Adds a new weighted value. + * + * @param weight The weight of the new observation. + * @param value The value of the new observation. + */ + public void addSample(int weight, float value) { + ensureSortedByIndex(); + + Sample newSample = recycledSampleCount > 0 ? recycledSamples[--recycledSampleCount] + : new Sample(); + newSample.index = nextSampleIndex++; + newSample.weight = weight; + newSample.value = value; + samples.add(newSample); + totalWeight += weight; + + while (totalWeight > maxWeight) { + int excessWeight = totalWeight - maxWeight; + Sample oldestSample = samples.get(0); + if (oldestSample.weight <= excessWeight) { + totalWeight -= oldestSample.weight; + samples.remove(0); + if (recycledSampleCount < MAX_RECYCLED_SAMPLES) { + recycledSamples[recycledSampleCount++] = oldestSample; + } + } else { + oldestSample.weight -= excessWeight; + totalWeight -= excessWeight; + } + } + } + + /** + * Computes a percentile by integration. + * + * @param percentile The desired percentile, expressed as a fraction in the range (0,1]. + * @return The requested percentile value or {@link Float#NaN} if no samples have been added. + */ + public float getPercentile(float percentile) { + ensureSortedByValue(); + float desiredWeight = percentile * totalWeight; + int accumulatedWeight = 0; + for (int i = 0; i < samples.size(); i++) { + Sample currentSample = samples.get(i); + accumulatedWeight += currentSample.weight; + if (accumulatedWeight >= desiredWeight) { + return currentSample.value; + } + } + // Clamp to maximum value or NaN if no values. + return samples.isEmpty() ? Float.NaN : samples.get(samples.size() - 1).value; + } + + /** + * Sorts the samples by index. + */ + private void ensureSortedByIndex() { + if (currentSortOrder != SORT_ORDER_BY_INDEX) { + Collections.sort(samples, INDEX_COMPARATOR); + currentSortOrder = SORT_ORDER_BY_INDEX; + } + } + + /** + * Sorts the samples by value. + */ + private void ensureSortedByValue() { + if (currentSortOrder != SORT_ORDER_BY_VALUE) { + Collections.sort(samples, VALUE_COMPARATOR); + currentSortOrder = SORT_ORDER_BY_VALUE; + } + } + + private static class Sample { + + public int index; + public int weight; + public float value; + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/StandaloneMediaClock.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/StandaloneMediaClock.java new file mode 100644 index 0000000000..f72867694d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/StandaloneMediaClock.java @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; + +/** + * A {@link MediaClock} whose position advances with real time based on the playback parameters when + * started. + */ +public final class StandaloneMediaClock implements MediaClock { + + private final Clock clock; + + private boolean started; + private long baseUs; + private long baseElapsedMs; + private PlaybackParameters playbackParameters; + + /** + * Creates a new standalone media clock using the given {@link Clock} implementation. + * + * @param clock A {@link Clock}. + */ + public StandaloneMediaClock(Clock clock) { + this.clock = clock; + this.playbackParameters = PlaybackParameters.DEFAULT; + } + + /** + * Starts the clock. Does nothing if the clock is already started. + */ + public void start() { + if (!started) { + baseElapsedMs = clock.elapsedRealtime(); + started = true; + } + } + + /** + * Stops the clock. Does nothing if the clock is already stopped. + */ + public void stop() { + if (started) { + resetPosition(getPositionUs()); + started = false; + } + } + + /** + * Resets the clock's position. + * + * @param positionUs The position to set in microseconds. + */ + public void resetPosition(long positionUs) { + baseUs = positionUs; + if (started) { + baseElapsedMs = clock.elapsedRealtime(); + } + } + + @Override + public long getPositionUs() { + long positionUs = baseUs; + if (started) { + long elapsedSinceBaseMs = clock.elapsedRealtime() - baseElapsedMs; + if (playbackParameters.speed == 1f) { + positionUs += C.msToUs(elapsedSinceBaseMs); + } else { + positionUs += playbackParameters.getMediaTimeUsForPlayoutTimeMs(elapsedSinceBaseMs); + } + } + return positionUs; + } + + @Override + public void setPlaybackParameters(PlaybackParameters playbackParameters) { + // Store the current position as the new base, in case the playback speed has changed. + if (started) { + resetPosition(getPositionUs()); + } + this.playbackParameters = playbackParameters; + } + + @Override + public PlaybackParameters getPlaybackParameters() { + return playbackParameters; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SystemClock.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SystemClock.java new file mode 100644 index 0000000000..a2f915866d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SystemClock.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.Looper; +import androidx.annotation.Nullable; + +/** + * The standard implementation of {@link Clock}. + */ +/* package */ final class SystemClock implements Clock { + + @Override + public long elapsedRealtime() { + return android.os.SystemClock.elapsedRealtime(); + } + + @Override + public long uptimeMillis() { + return android.os.SystemClock.uptimeMillis(); + } + + @Override + public void sleep(long sleepTimeMs) { + android.os.SystemClock.sleep(sleepTimeMs); + } + + @Override + public HandlerWrapper createHandler(Looper looper, @Nullable Callback callback) { + return new SystemHandlerWrapper(new Handler(looper, callback)); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SystemHandlerWrapper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SystemHandlerWrapper.java new file mode 100644 index 0000000000..e69a24cc10 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/SystemHandlerWrapper.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.os.Looper; +import android.os.Message; +import androidx.annotation.Nullable; + +/** The standard implementation of {@link HandlerWrapper}. */ +/* package */ final class SystemHandlerWrapper implements HandlerWrapper { + + private final android.os.Handler handler; + + public SystemHandlerWrapper(android.os.Handler handler) { + this.handler = handler; + } + + @Override + public Looper getLooper() { + return handler.getLooper(); + } + + @Override + public Message obtainMessage(int what) { + return handler.obtainMessage(what); + } + + @Override + public Message obtainMessage(int what, @Nullable Object obj) { + return handler.obtainMessage(what, obj); + } + + @Override + public Message obtainMessage(int what, int arg1, int arg2) { + return handler.obtainMessage(what, arg1, arg2); + } + + @Override + public Message obtainMessage(int what, int arg1, int arg2, @Nullable Object obj) { + return handler.obtainMessage(what, arg1, arg2, obj); + } + + @Override + public boolean sendEmptyMessage(int what) { + return handler.sendEmptyMessage(what); + } + + @Override + public boolean sendEmptyMessageAtTime(int what, long uptimeMs) { + return handler.sendEmptyMessageAtTime(what, uptimeMs); + } + + @Override + public void removeMessages(int what) { + handler.removeMessages(what); + } + + @Override + public void removeCallbacksAndMessages(@Nullable Object token) { + handler.removeCallbacksAndMessages(token); + } + + @Override + public boolean post(Runnable runnable) { + return handler.post(runnable); + } + + @Override + public boolean postDelayed(Runnable runnable, long delayMs) { + return handler.postDelayed(runnable, delayMs); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TimedValueQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TimedValueQueue.java new file mode 100644 index 0000000000..396e50dcff --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TimedValueQueue.java @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import androidx.annotation.Nullable; +import java.util.Arrays; +import org.checkerframework.checker.nullness.compatqual.NullableType; + +/** A utility class to keep a queue of values with timestamps. This class is thread safe. */ +public final class TimedValueQueue<V> { + private static final int INITIAL_BUFFER_SIZE = 10; + + // Looping buffer for timestamps and values + private long[] timestamps; + private @NullableType V[] values; + private int first; + private int size; + + public TimedValueQueue() { + this(INITIAL_BUFFER_SIZE); + } + + /** Creates a TimedValueBuffer with the given initial buffer size. */ + public TimedValueQueue(int initialBufferSize) { + timestamps = new long[initialBufferSize]; + values = newArray(initialBufferSize); + } + + /** + * Associates the specified value with the specified timestamp. All new values should have a + * greater timestamp than the previously added values. Otherwise all values are removed before + * adding the new one. + */ + public synchronized void add(long timestamp, V value) { + clearBufferOnTimeDiscontinuity(timestamp); + doubleCapacityIfFull(); + addUnchecked(timestamp, value); + } + + /** Removes all of the values. */ + public synchronized void clear() { + first = 0; + size = 0; + Arrays.fill(values, null); + } + + /** Returns number of the values buffered. */ + public synchronized int size() { + return size; + } + + /** + * Returns the value with the greatest timestamp which is less than or equal to the given + * timestamp. Removes all older values and the returned one from the buffer. + * + * @param timestamp The timestamp value. + * @return The value with the greatest timestamp which is less than or equal to the given + * timestamp or null if there is no such value. + * @see #poll(long) + */ + public synchronized @Nullable V pollFloor(long timestamp) { + return poll(timestamp, /* onlyOlder= */ true); + } + + /** + * Returns the value with the closest timestamp to the given timestamp. Removes all older values + * including the returned one from the buffer. + * + * @param timestamp The timestamp value. + * @return The value with the closest timestamp or null if the buffer is empty. + * @see #pollFloor(long) + */ + public synchronized @Nullable V poll(long timestamp) { + return poll(timestamp, /* onlyOlder= */ false); + } + + /** + * Returns the value with the closest timestamp to the given timestamp. Removes all older values + * including the returned one from the buffer. + * + * @param timestamp The timestamp value. + * @param onlyOlder Whether this method can return a new value in case its timestamp value is + * closest to {@code timestamp}. + * @return The value with the closest timestamp or null if the buffer is empty or there is no + * older value and {@code onlyOlder} is true. + */ + @Nullable + private V poll(long timestamp, boolean onlyOlder) { + V value = null; + long previousTimeDiff = Long.MAX_VALUE; + while (size > 0) { + long timeDiff = timestamp - timestamps[first]; + if (timeDiff < 0 && (onlyOlder || -timeDiff >= previousTimeDiff)) { + break; + } + previousTimeDiff = timeDiff; + value = values[first]; + values[first] = null; + first = (first + 1) % values.length; + size--; + } + return value; + } + + private void clearBufferOnTimeDiscontinuity(long timestamp) { + if (size > 0) { + int last = (first + size - 1) % values.length; + if (timestamp <= timestamps[last]) { + clear(); + } + } + } + + private void doubleCapacityIfFull() { + int capacity = values.length; + if (size < capacity) { + return; + } + int newCapacity = capacity * 2; + long[] newTimestamps = new long[newCapacity]; + V[] newValues = newArray(newCapacity); + // Reset the loop starting index to 0 while coping to the new buffer. + // First copy the values from 'first' index to the end of original array. + int length = capacity - first; + System.arraycopy(timestamps, first, newTimestamps, 0, length); + System.arraycopy(values, first, newValues, 0, length); + // Then the values from index 0 to 'first' index. + if (first > 0) { + System.arraycopy(timestamps, 0, newTimestamps, length, first); + System.arraycopy(values, 0, newValues, length, first); + } + timestamps = newTimestamps; + values = newValues; + first = 0; + } + + private void addUnchecked(long timestamp, V value) { + int next = (first + size) % values.length; + timestamps[next] = timestamp; + values[next] = value; + size++; + } + + @SuppressWarnings("unchecked") + private static <V> V[] newArray(int length) { + return (V[]) new Object[length]; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TimestampAdjuster.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TimestampAdjuster.java new file mode 100644 index 0000000000..e824251282 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TimestampAdjuster.java @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; + +/** + * Offsets timestamps according to an initial sample timestamp offset. MPEG-2 TS timestamps scaling + * and adjustment is supported, taking into account timestamp rollover. + */ +public final class TimestampAdjuster { + + /** + * A special {@code firstSampleTimestampUs} value indicating that presentation timestamps should + * not be offset. + */ + public static final long DO_NOT_OFFSET = Long.MAX_VALUE; + + /** + * The value one greater than the largest representable (33 bit) MPEG-2 TS 90 kHz clock + * presentation timestamp. + */ + private static final long MAX_PTS_PLUS_ONE = 0x200000000L; + + private long firstSampleTimestampUs; + private long timestampOffsetUs; + + // Volatile to allow isInitialized to be called on a different thread to adjustSampleTimestamp. + private volatile long lastSampleTimestampUs; + + /** + * @param firstSampleTimestampUs See {@link #setFirstSampleTimestampUs(long)}. + */ + public TimestampAdjuster(long firstSampleTimestampUs) { + lastSampleTimestampUs = C.TIME_UNSET; + setFirstSampleTimestampUs(firstSampleTimestampUs); + } + + /** + * Sets the desired result of the first call to {@link #adjustSampleTimestamp(long)}. Can only be + * called before any timestamps have been adjusted. + * + * @param firstSampleTimestampUs The first adjusted sample timestamp in microseconds, or + * {@link #DO_NOT_OFFSET} if presentation timestamps should not be offset. + */ + public synchronized void setFirstSampleTimestampUs(long firstSampleTimestampUs) { + Assertions.checkState(lastSampleTimestampUs == C.TIME_UNSET); + this.firstSampleTimestampUs = firstSampleTimestampUs; + } + + /** Returns the last value passed to {@link #setFirstSampleTimestampUs(long)}. */ + public long getFirstSampleTimestampUs() { + return firstSampleTimestampUs; + } + + /** + * Returns the last value obtained from {@link #adjustSampleTimestamp}. If {@link + * #adjustSampleTimestamp} has not been called, returns the result of calling {@link + * #getFirstSampleTimestampUs()}. If this value is {@link #DO_NOT_OFFSET}, returns {@link + * C#TIME_UNSET}. + */ + public long getLastAdjustedTimestampUs() { + return lastSampleTimestampUs != C.TIME_UNSET + ? (lastSampleTimestampUs + timestampOffsetUs) + : firstSampleTimestampUs != DO_NOT_OFFSET ? firstSampleTimestampUs : C.TIME_UNSET; + } + + /** + * Returns the offset between the input of {@link #adjustSampleTimestamp(long)} and its output. + * If {@link #DO_NOT_OFFSET} was provided to the constructor, 0 is returned. If the timestamp + * adjuster is yet not initialized, {@link C#TIME_UNSET} is returned. + * + * @return The offset between {@link #adjustSampleTimestamp(long)}'s input and output. + * {@link C#TIME_UNSET} if the adjuster is not yet initialized and 0 if timestamps should not + * be offset. + */ + public long getTimestampOffsetUs() { + return firstSampleTimestampUs == DO_NOT_OFFSET + ? 0 + : lastSampleTimestampUs == C.TIME_UNSET ? C.TIME_UNSET : timestampOffsetUs; + } + + /** + * Resets the instance to its initial state. + */ + public void reset() { + lastSampleTimestampUs = C.TIME_UNSET; + } + + /** + * Scales and offsets an MPEG-2 TS presentation timestamp considering wraparound. + * + * @param pts90Khz A 90 kHz clock MPEG-2 TS presentation timestamp. + * @return The adjusted timestamp in microseconds. + */ + public long adjustTsTimestamp(long pts90Khz) { + if (pts90Khz == C.TIME_UNSET) { + return C.TIME_UNSET; + } + if (lastSampleTimestampUs != C.TIME_UNSET) { + // The wrap count for the current PTS may be closestWrapCount or (closestWrapCount - 1), + // and we need to snap to the one closest to lastSampleTimestampUs. + long lastPts = usToPts(lastSampleTimestampUs); + long closestWrapCount = (lastPts + (MAX_PTS_PLUS_ONE / 2)) / MAX_PTS_PLUS_ONE; + long ptsWrapBelow = pts90Khz + (MAX_PTS_PLUS_ONE * (closestWrapCount - 1)); + long ptsWrapAbove = pts90Khz + (MAX_PTS_PLUS_ONE * closestWrapCount); + pts90Khz = + Math.abs(ptsWrapBelow - lastPts) < Math.abs(ptsWrapAbove - lastPts) + ? ptsWrapBelow + : ptsWrapAbove; + } + return adjustSampleTimestamp(ptsToUs(pts90Khz)); + } + + /** + * Offsets a timestamp in microseconds. + * + * @param timeUs The timestamp to adjust in microseconds. + * @return The adjusted timestamp in microseconds. + */ + public long adjustSampleTimestamp(long timeUs) { + if (timeUs == C.TIME_UNSET) { + return C.TIME_UNSET; + } + // Record the adjusted PTS to adjust for wraparound next time. + if (lastSampleTimestampUs != C.TIME_UNSET) { + lastSampleTimestampUs = timeUs; + } else { + if (firstSampleTimestampUs != DO_NOT_OFFSET) { + // Calculate the timestamp offset. + timestampOffsetUs = firstSampleTimestampUs - timeUs; + } + synchronized (this) { + lastSampleTimestampUs = timeUs; + // Notify threads waiting for this adjuster to be initialized. + notifyAll(); + } + } + return timeUs + timestampOffsetUs; + } + + /** + * Blocks the calling thread until this adjuster is initialized. + * + * @throws InterruptedException If the thread was interrupted. + */ + public synchronized void waitUntilInitialized() throws InterruptedException { + while (lastSampleTimestampUs == C.TIME_UNSET) { + wait(); + } + } + + /** + * Converts a 90 kHz clock timestamp to a timestamp in microseconds. + * + * @param pts A 90 kHz clock timestamp. + * @return The corresponding value in microseconds. + */ + public static long ptsToUs(long pts) { + return (pts * C.MICROS_PER_SECOND) / 90000; + } + + /** + * Converts a timestamp in microseconds to a 90 kHz clock timestamp. + * + * @param us A value in microseconds. + * @return The corresponding value as a 90 kHz clock timestamp. + */ + public static long usToPts(long us) { + return (us * 90000) / C.MICROS_PER_SECOND; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TraceUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TraceUtil.java new file mode 100644 index 0000000000..5f53c3130d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/TraceUtil.java @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.annotation.TargetApi; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo; + +/** + * Calls through to {@link android.os.Trace} methods on supported API levels. + */ +public final class TraceUtil { + + private TraceUtil() {} + + /** + * Writes a trace message to indicate that a given section of code has begun. + * + * @see android.os.Trace#beginSection(String) + * @param sectionName The name of the code section to appear in the trace. This may be at most 127 + * Unicode code units long. + */ + public static void beginSection(String sectionName) { + if (ExoPlayerLibraryInfo.TRACE_ENABLED && Util.SDK_INT >= 18) { + beginSectionV18(sectionName); + } + } + + /** + * Writes a trace message to indicate that a given section of code has ended. + * + * @see android.os.Trace#endSection() + */ + public static void endSection() { + if (ExoPlayerLibraryInfo.TRACE_ENABLED && Util.SDK_INT >= 18) { + endSectionV18(); + } + } + + @TargetApi(18) + private static void beginSectionV18(String sectionName) { + android.os.Trace.beginSection(sectionName); + } + + @TargetApi(18) + private static void endSectionV18() { + android.os.Trace.endSection(); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/UriUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/UriUtil.java new file mode 100644 index 0000000000..03b5d26a51 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/UriUtil.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.Nullable; + +/** + * Utility methods for manipulating URIs. + */ +public final class UriUtil { + + /** + * The length of arrays returned by {@link #getUriIndices(String)}. + */ + private static final int INDEX_COUNT = 4; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + * <p> + * The value at this position in the array is the index of the ':' after the scheme. Equals -1 if + * the URI is a relative reference (no scheme). The hier-part starts at (schemeColon + 1), + * including when the URI has no scheme. + */ + private static final int SCHEME_COLON = 0; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + * <p> + * The value at this position in the array is the index of the path part. Equals (schemeColon + 1) + * if no authority part, (schemeColon + 3) if the authority part consists of just "//", and + * (query) if no path part. The characters starting at this index can be "//" only if the + * authority part is non-empty (in this case the double-slash means the first segment is empty). + */ + private static final int PATH = 1; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + * <p> + * The value at this position in the array is the index of the query part, including the '?' + * before the query. Equals fragment if no query part, and (fragment - 1) if the query part is a + * single '?' with no data. + */ + private static final int QUERY = 2; + /** + * An index into an array returned by {@link #getUriIndices(String)}. + * <p> + * The value at this position in the array is the index of the fragment part, including the '#' + * before the fragment. Equal to the length of the URI if no fragment part, and (length - 1) if + * the fragment part is a single '#' with no data. + */ + private static final int FRAGMENT = 3; + + private UriUtil() {} + + /** + * Like {@link #resolve(String, String)}, but returns a {@link Uri} instead of a {@link String}. + * + * @param baseUri The base URI. + * @param referenceUri The reference URI to resolve. + */ + public static Uri resolveToUri(@Nullable String baseUri, @Nullable String referenceUri) { + return Uri.parse(resolve(baseUri, referenceUri)); + } + + /** + * Performs relative resolution of a {@code referenceUri} with respect to a {@code baseUri}. + * + * <p>The resolution is performed as specified by RFC-3986. + * + * @param baseUri The base URI. + * @param referenceUri The reference URI to resolve. + */ + public static String resolve(@Nullable String baseUri, @Nullable String referenceUri) { + StringBuilder uri = new StringBuilder(); + + // Map null onto empty string, to make the following logic simpler. + baseUri = baseUri == null ? "" : baseUri; + referenceUri = referenceUri == null ? "" : referenceUri; + + int[] refIndices = getUriIndices(referenceUri); + if (refIndices[SCHEME_COLON] != -1) { + // The reference is absolute. The target Uri is the reference. + uri.append(referenceUri); + removeDotSegments(uri, refIndices[PATH], refIndices[QUERY]); + return uri.toString(); + } + + int[] baseIndices = getUriIndices(baseUri); + if (refIndices[FRAGMENT] == 0) { + // The reference is empty or contains just the fragment part, then the target Uri is the + // concatenation of the base Uri without its fragment, and the reference. + return uri.append(baseUri, 0, baseIndices[FRAGMENT]).append(referenceUri).toString(); + } + + if (refIndices[QUERY] == 0) { + // The reference starts with the query part. The target is the base up to (but excluding) the + // query, plus the reference. + return uri.append(baseUri, 0, baseIndices[QUERY]).append(referenceUri).toString(); + } + + if (refIndices[PATH] != 0) { + // The reference has authority. The target is the base scheme plus the reference. + int baseLimit = baseIndices[SCHEME_COLON] + 1; + uri.append(baseUri, 0, baseLimit).append(referenceUri); + return removeDotSegments(uri, baseLimit + refIndices[PATH], baseLimit + refIndices[QUERY]); + } + + if (referenceUri.charAt(refIndices[PATH]) == '/') { + // The reference path is rooted. The target is the base scheme and authority (if any), plus + // the reference. + uri.append(baseUri, 0, baseIndices[PATH]).append(referenceUri); + return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY]); + } + + // The target Uri is the concatenation of the base Uri up to (but excluding) the last segment, + // and the reference. This can be split into 2 cases: + if (baseIndices[SCHEME_COLON] + 2 < baseIndices[PATH] + && baseIndices[PATH] == baseIndices[QUERY]) { + // Case 1: The base hier-part is just the authority, with an empty path. An additional '/' is + // needed after the authority, before appending the reference. + uri.append(baseUri, 0, baseIndices[PATH]).append('/').append(referenceUri); + return removeDotSegments(uri, baseIndices[PATH], baseIndices[PATH] + refIndices[QUERY] + 1); + } else { + // Case 2: Otherwise, find the last '/' in the base hier-part and append the reference after + // it. If base hier-part has no '/', it could only mean that it is completely empty or + // contains only one segment, in which case the whole hier-part is excluded and the reference + // is appended right after the base scheme colon without an added '/'. + int lastSlashIndex = baseUri.lastIndexOf('/', baseIndices[QUERY] - 1); + int baseLimit = lastSlashIndex == -1 ? baseIndices[PATH] : lastSlashIndex + 1; + uri.append(baseUri, 0, baseLimit).append(referenceUri); + return removeDotSegments(uri, baseIndices[PATH], baseLimit + refIndices[QUERY]); + } + } + + /** + * Removes query parameter from an Uri, if present. + * + * @param uri The uri. + * @param queryParameterName The name of the query parameter. + * @return The uri without the query parameter. + */ + public static Uri removeQueryParameter(Uri uri, String queryParameterName) { + Uri.Builder builder = uri.buildUpon(); + builder.clearQuery(); + for (String key : uri.getQueryParameterNames()) { + if (!key.equals(queryParameterName)) { + for (String value : uri.getQueryParameters(key)) { + builder.appendQueryParameter(key, value); + } + } + } + return builder.build(); + } + + /** + * Removes dot segments from the path of a URI. + * + * @param uri A {@link StringBuilder} containing the URI. + * @param offset The index of the start of the path in {@code uri}. + * @param limit The limit (exclusive) of the path in {@code uri}. + */ + private static String removeDotSegments(StringBuilder uri, int offset, int limit) { + if (offset >= limit) { + // Nothing to do. + return uri.toString(); + } + if (uri.charAt(offset) == '/') { + // If the path starts with a /, always retain it. + offset++; + } + // The first character of the current path segment. + int segmentStart = offset; + int i = offset; + while (i <= limit) { + int nextSegmentStart; + if (i == limit) { + nextSegmentStart = i; + } else if (uri.charAt(i) == '/') { + nextSegmentStart = i + 1; + } else { + i++; + continue; + } + // We've encountered the end of a segment or the end of the path. If the final segment was + // "." or "..", remove the appropriate segments of the path. + if (i == segmentStart + 1 && uri.charAt(segmentStart) == '.') { + // Given "abc/def/./ghi", remove "./" to get "abc/def/ghi". + uri.delete(segmentStart, nextSegmentStart); + limit -= nextSegmentStart - segmentStart; + i = segmentStart; + } else if (i == segmentStart + 2 && uri.charAt(segmentStart) == '.' + && uri.charAt(segmentStart + 1) == '.') { + // Given "abc/def/../ghi", remove "def/../" to get "abc/ghi". + int prevSegmentStart = uri.lastIndexOf("/", segmentStart - 2) + 1; + int removeFrom = prevSegmentStart > offset ? prevSegmentStart : offset; + uri.delete(removeFrom, nextSegmentStart); + limit -= nextSegmentStart - removeFrom; + segmentStart = prevSegmentStart; + i = prevSegmentStart; + } else { + i++; + segmentStart = i; + } + } + return uri.toString(); + } + + /** + * Calculates indices of the constituent components of a URI. + * + * @param uriString The URI as a string. + * @return The corresponding indices. + */ + private static int[] getUriIndices(String uriString) { + int[] indices = new int[INDEX_COUNT]; + if (TextUtils.isEmpty(uriString)) { + indices[SCHEME_COLON] = -1; + return indices; + } + + // Determine outer structure from right to left. + // Uri = scheme ":" hier-part [ "?" query ] [ "#" fragment ] + int length = uriString.length(); + int fragmentIndex = uriString.indexOf('#'); + if (fragmentIndex == -1) { + fragmentIndex = length; + } + int queryIndex = uriString.indexOf('?'); + if (queryIndex == -1 || queryIndex > fragmentIndex) { + // '#' before '?': '?' is within the fragment. + queryIndex = fragmentIndex; + } + // Slashes are allowed only in hier-part so any colon after the first slash is part of the + // hier-part, not the scheme colon separator. + int schemeIndexLimit = uriString.indexOf('/'); + if (schemeIndexLimit == -1 || schemeIndexLimit > queryIndex) { + schemeIndexLimit = queryIndex; + } + int schemeIndex = uriString.indexOf(':'); + if (schemeIndex > schemeIndexLimit) { + // '/' before ':' + schemeIndex = -1; + } + + // Determine hier-part structure: hier-part = "//" authority path / path + // This block can also cope with schemeIndex == -1. + boolean hasAuthority = schemeIndex + 2 < queryIndex + && uriString.charAt(schemeIndex + 1) == '/' + && uriString.charAt(schemeIndex + 2) == '/'; + int pathIndex; + if (hasAuthority) { + pathIndex = uriString.indexOf('/', schemeIndex + 3); // find first '/' after "://" + if (pathIndex == -1 || pathIndex > queryIndex) { + pathIndex = queryIndex; + } + } else { + pathIndex = schemeIndex + 1; + } + + indices[SCHEME_COLON] = schemeIndex; + indices[PATH] = pathIndex; + indices[QUERY] = queryIndex; + indices[FRAGMENT] = fragmentIndex; + return indices; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Util.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Util.java new file mode 100644 index 0000000000..4d7d8014dd --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/Util.java @@ -0,0 +1,2298 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import static android.content.Context.UI_MODE_SERVICE; + +import android.Manifest.permission; +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.UiModeManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Point; +import android.media.AudioFormat; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Parcel; +import android.security.NetworkSecurityPolicy; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.view.Display; +import android.view.SurfaceView; +import android.view.WindowManager; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayerLibraryInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Renderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RenderersFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.SeekParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.audio.AudioRendererEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Method; +import java.math.BigDecimal; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Formatter; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.DataFormatException; +import java.util.zip.Inflater; +import org.checkerframework.checker.initialization.qual.UnknownInitialization; +import org.checkerframework.checker.nullness.compatqual.NullableType; +import org.checkerframework.checker.nullness.qual.EnsuresNonNull; +import org.checkerframework.checker.nullness.qual.PolyNull; + +/** + * Miscellaneous utility methods. + */ +public final class Util { + + /** + * Like {@link android.os.Build.VERSION#SDK_INT}, but in a place where it can be conveniently + * overridden for local testing. + */ + public static final int SDK_INT = Build.VERSION.SDK_INT; + + /** + * Like {@link Build#DEVICE}, but in a place where it can be conveniently overridden for local + * testing. + */ + public static final String DEVICE = Build.DEVICE; + + /** + * Like {@link Build#MANUFACTURER}, but in a place where it can be conveniently overridden for + * local testing. + */ + public static final String MANUFACTURER = Build.MANUFACTURER; + + /** + * Like {@link Build#MODEL}, but in a place where it can be conveniently overridden for local + * testing. + */ + public static final String MODEL = Build.MODEL; + + /** + * A concise description of the device that it can be useful to log for debugging purposes. + */ + public static final String DEVICE_DEBUG_INFO = DEVICE + ", " + MODEL + ", " + MANUFACTURER + ", " + + SDK_INT; + + /** An empty byte array. */ + public static final byte[] EMPTY_BYTE_ARRAY = new byte[0]; + + private static final String TAG = "Util"; + private static final Pattern XS_DATE_TIME_PATTERN = Pattern.compile( + "(\\d\\d\\d\\d)\\-(\\d\\d)\\-(\\d\\d)[Tt]" + + "(\\d\\d):(\\d\\d):(\\d\\d)([\\.,](\\d+))?" + + "([Zz]|((\\+|\\-)(\\d?\\d):?(\\d\\d)))?"); + private static final Pattern XS_DURATION_PATTERN = + Pattern.compile("^(-)?P(([0-9]*)Y)?(([0-9]*)M)?(([0-9]*)D)?" + + "(T(([0-9]*)H)?(([0-9]*)M)?(([0-9.]*)S)?)?$"); + private static final Pattern ESCAPED_CHARACTER_PATTERN = Pattern.compile("%([A-Fa-f0-9]{2})"); + + // Replacement map of ISO language codes used for normalization. + @Nullable private static HashMap<String, String> languageTagReplacementMap; + + private Util() {} + + /** + * Converts the entirety of an {@link InputStream} to a byte array. + * + * @param inputStream the {@link InputStream} to be read. The input stream is not closed by this + * method. + * @return a byte array containing all of the inputStream's bytes. + * @throws IOException if an error occurs reading from the stream. + */ + public static byte[] toByteArray(InputStream inputStream) throws IOException { + byte[] buffer = new byte[1024 * 4]; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + return outputStream.toByteArray(); + } + + /** + * Calls {@link Context#startForegroundService(Intent)} if {@link #SDK_INT} is 26 or higher, or + * {@link Context#startService(Intent)} otherwise. + * + * @param context The context to call. + * @param intent The intent to pass to the called method. + * @return The result of the called method. + */ + @Nullable + public static ComponentName startForegroundService(Context context, Intent intent) { + if (Util.SDK_INT >= 26) { + return context.startForegroundService(intent); + } else { + return context.startService(intent); + } + } + + /** + * Checks whether it's necessary to request the {@link permission#READ_EXTERNAL_STORAGE} + * permission read the specified {@link Uri}s, requesting the permission if necessary. + * + * @param activity The host activity for checking and requesting the permission. + * @param uris {@link Uri}s that may require {@link permission#READ_EXTERNAL_STORAGE} to read. + * @return Whether a permission request was made. + */ + @TargetApi(23) + public static boolean maybeRequestReadExternalStoragePermission(Activity activity, Uri... uris) { + if (Util.SDK_INT < 23) { + return false; + } + for (Uri uri : uris) { + if (isLocalFileUri(uri)) { + if (activity.checkSelfPermission(permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + activity.requestPermissions(new String[] {permission.READ_EXTERNAL_STORAGE}, 0); + return true; + } + break; + } + } + return false; + } + + /** + * Returns whether it may be possible to load the given URIs based on the network security + * policy's cleartext traffic permissions. + * + * @param uris A list of URIs that will be loaded. + * @return Whether it may be possible to load the given URIs. + */ + @TargetApi(24) + public static boolean checkCleartextTrafficPermitted(Uri... uris) { + if (Util.SDK_INT < 24) { + // We assume cleartext traffic is permitted. + return true; + } + for (Uri uri : uris) { + if ("http".equals(uri.getScheme()) + && !NetworkSecurityPolicy.getInstance() + .isCleartextTrafficPermitted(Assertions.checkNotNull(uri.getHost()))) { + // The security policy prevents cleartext traffic. + return false; + } + } + return true; + } + + /** + * Returns true if the URI is a path to a local file or a reference to a local file. + * + * @param uri The uri to test. + */ + public static boolean isLocalFileUri(Uri uri) { + String scheme = uri.getScheme(); + return TextUtils.isEmpty(scheme) || "file".equals(scheme); + } + + /** + * Tests two objects for {@link Object#equals(Object)} equality, handling the case where one or + * both may be null. + * + * @param o1 The first object. + * @param o2 The second object. + * @return {@code o1 == null ? o2 == null : o1.equals(o2)}. + */ + public static boolean areEqual(@Nullable Object o1, @Nullable Object o2) { + return o1 == null ? o2 == null : o1.equals(o2); + } + + /** + * Tests whether an {@code items} array contains an object equal to {@code item}, according to + * {@link Object#equals(Object)}. + * + * <p>If {@code item} is null then true is returned if and only if {@code items} contains null. + * + * @param items The array of items to search. + * @param item The item to search for. + * @return True if the array contains an object equal to the item being searched for. + */ + public static boolean contains(@NullableType Object[] items, @Nullable Object item) { + for (Object arrayItem : items) { + if (areEqual(arrayItem, item)) { + return true; + } + } + return false; + } + + /** + * Removes an indexed range from a List. + * + * <p>Does nothing if the provided range is valid and {@code fromIndex == toIndex}. + * + * @param list The List to remove the range from. + * @param fromIndex The first index to be removed (inclusive). + * @param toIndex The last index to be removed (exclusive). + * @throws IllegalArgumentException If {@code fromIndex} < 0, {@code toIndex} > {@code + * list.size()}, or {@code fromIndex} > {@code toIndex}. + */ + public static <T> void removeRange(List<T> list, int fromIndex, int toIndex) { + if (fromIndex < 0 || toIndex > list.size() || fromIndex > toIndex) { + throw new IllegalArgumentException(); + } else if (fromIndex != toIndex) { + // Checking index inequality prevents an unnecessary allocation. + list.subList(fromIndex, toIndex).clear(); + } + } + + /** + * Casts a nullable variable to a non-null variable without runtime null check. + * + * <p>Use {@link Assertions#checkNotNull(Object)} to throw if the value is null. + */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull("#1") + public static <T> T castNonNull(@Nullable T value) { + return value; + } + + /** Casts a nullable type array to a non-null type array without runtime null check. */ + @SuppressWarnings({"contracts.postcondition.not.satisfied", "return.type.incompatible"}) + @EnsuresNonNull("#1") + public static <T> T[] castNonNullTypeArray(@NullableType T[] value) { + return value; + } + + /** + * Copies and optionally truncates an array. Prevents null array elements created by {@link + * Arrays#copyOf(Object[], int)} by ensuring the new length does not exceed the current length. + * + * @param input The input array. + * @param length The output array length. Must be less or equal to the length of the input array. + * @return The copied array. + */ + @SuppressWarnings({"nullness:argument.type.incompatible", "nullness:return.type.incompatible"}) + public static <T> T[] nullSafeArrayCopy(T[] input, int length) { + Assertions.checkArgument(length <= input.length); + return Arrays.copyOf(input, length); + } + + /** + * Copies a subset of an array. + * + * @param input The input array. + * @param from The start the range to be copied, inclusive + * @param to The end of the range to be copied, exclusive. + * @return The copied array. + */ + @SuppressWarnings({"nullness:argument.type.incompatible", "nullness:return.type.incompatible"}) + public static <T> T[] nullSafeArrayCopyOfRange(T[] input, int from, int to) { + Assertions.checkArgument(0 <= from); + Assertions.checkArgument(to <= input.length); + return Arrays.copyOfRange(input, from, to); + } + + /** + * Creates a new array containing {@code original} with {@code newElement} appended. + * + * @param original The input array. + * @param newElement The element to append. + * @return The new array. + */ + public static <T> T[] nullSafeArrayAppend(T[] original, T newElement) { + @NullableType T[] result = Arrays.copyOf(original, original.length + 1); + result[original.length] = newElement; + return castNonNullTypeArray(result); + } + + /** + * Creates a new array containing the concatenation of two non-null type arrays. + * + * @param first The first array. + * @param second The second array. + * @return The concatenated result. + */ + @SuppressWarnings({"nullness:assignment.type.incompatible"}) + public static <T> T[] nullSafeArrayConcatenation(T[] first, T[] second) { + T[] concatenation = Arrays.copyOf(first, first.length + second.length); + System.arraycopy( + /* src= */ second, + /* srcPos= */ 0, + /* dest= */ concatenation, + /* destPos= */ first.length, + /* length= */ second.length); + return concatenation; + } + /** + * Creates a {@link Handler} with the specified {@link Handler.Callback} on the current {@link + * Looper} thread. The method accepts partially initialized objects as callback under the + * assumption that the Handler won't be used to send messages until the callback is fully + * initialized. + * + * <p>If the current thread doesn't have a {@link Looper}, the application's main thread {@link + * Looper} is used. + * + * @param callback A {@link Handler.Callback}. May be a partially initialized class. + * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. + */ + public static Handler createHandler(Handler.@UnknownInitialization Callback callback) { + return createHandler(getLooper(), callback); + } + + /** + * Creates a {@link Handler} with the specified {@link Handler.Callback} on the specified {@link + * Looper} thread. The method accepts partially initialized objects as callback under the + * assumption that the Handler won't be used to send messages until the callback is fully + * initialized. + * + * @param looper A {@link Looper} to run the callback on. + * @param callback A {@link Handler.Callback}. May be a partially initialized class. + * @return A {@link Handler} with the specified callback on the current {@link Looper} thread. + */ + @SuppressWarnings({"nullness:argument.type.incompatible", "nullness:return.type.incompatible"}) + public static Handler createHandler( + Looper looper, Handler.@UnknownInitialization Callback callback) { + return new Handler(looper, callback); + } + + /** + * Returns the {@link Looper} associated with the current thread, or the {@link Looper} of the + * application's main thread if the current thread doesn't have a {@link Looper}. + */ + public static Looper getLooper() { + Looper myLooper = Looper.myLooper(); + return myLooper != null ? myLooper : Looper.getMainLooper(); + } + + /** + * Instantiates a new single threaded executor whose thread has the specified name. + * + * @param threadName The name of the thread. + * @return The executor. + */ + public static ExecutorService newSingleThreadExecutor(final String threadName) { + return Executors.newSingleThreadExecutor(runnable -> new Thread(runnable, threadName)); + } + + /** + * Closes a {@link DataSource}, suppressing any {@link IOException} that may occur. + * + * @param dataSource The {@link DataSource} to close. + */ + public static void closeQuietly(@Nullable DataSource dataSource) { + try { + if (dataSource != null) { + dataSource.close(); + } + } catch (IOException e) { + // Ignore. + } + } + + /** + * Closes a {@link Closeable}, suppressing any {@link IOException} that may occur. Both {@link + * java.io.OutputStream} and {@link InputStream} are {@code Closeable}. + * + * @param closeable The {@link Closeable} to close. + */ + public static void closeQuietly(@Nullable Closeable closeable) { + try { + if (closeable != null) { + closeable.close(); + } + } catch (IOException e) { + // Ignore. + } + } + + /** + * Reads an integer from a {@link Parcel} and interprets it as a boolean, with 0 mapping to false + * and all other values mapping to true. + * + * @param parcel The {@link Parcel} to read from. + * @return The read value. + */ + public static boolean readBoolean(Parcel parcel) { + return parcel.readInt() != 0; + } + + /** + * Writes a boolean to a {@link Parcel}. The boolean is written as an integer with value 1 (true) + * or 0 (false). + * + * @param parcel The {@link Parcel} to write to. + * @param value The value to write. + */ + public static void writeBoolean(Parcel parcel, boolean value) { + parcel.writeInt(value ? 1 : 0); + } + + /** + * Returns the language tag for a {@link Locale}. + * + * <p>For API levels ≥ 21, this tag is IETF BCP 47 compliant. Use {@link + * #normalizeLanguageCode(String)} to retrieve a normalized IETF BCP 47 language tag for all API + * levels if needed. + * + * @param locale A {@link Locale}. + * @return The language tag. + */ + public static String getLocaleLanguageTag(Locale locale) { + return SDK_INT >= 21 ? getLocaleLanguageTagV21(locale) : locale.toString(); + } + + /** + * Returns a normalized IETF BCP 47 language tag for {@code language}. + * + * @param language A case-insensitive language code supported by {@link + * Locale#forLanguageTag(String)}. + * @return The all-lowercase normalized code, or null if the input was null, or {@code + * language.toLowerCase()} if the language could not be normalized. + */ + public static @PolyNull String normalizeLanguageCode(@PolyNull String language) { + if (language == null) { + return null; + } + // Locale data (especially for API < 21) may produce tags with '_' instead of the + // standard-conformant '-'. + String normalizedTag = language.replace('_', '-'); + if (normalizedTag.isEmpty() || "und".equals(normalizedTag)) { + // Tag isn't valid, keep using the original. + normalizedTag = language; + } + normalizedTag = Util.toLowerInvariant(normalizedTag); + String mainLanguage = Util.splitAtFirst(normalizedTag, "-")[0]; + if (languageTagReplacementMap == null) { + languageTagReplacementMap = createIsoLanguageReplacementMap(); + } + @Nullable String replacedLanguage = languageTagReplacementMap.get(mainLanguage); + if (replacedLanguage != null) { + normalizedTag = + replacedLanguage + normalizedTag.substring(/* beginIndex= */ mainLanguage.length()); + mainLanguage = replacedLanguage; + } + if ("no".equals(mainLanguage) || "i".equals(mainLanguage) || "zh".equals(mainLanguage)) { + normalizedTag = maybeReplaceGrandfatheredLanguageTags(normalizedTag); + } + return normalizedTag; + } + + /** + * Returns a new {@link String} constructed by decoding UTF-8 encoded bytes. + * + * @param bytes The UTF-8 encoded bytes to decode. + * @return The string. + */ + public static String fromUtf8Bytes(byte[] bytes) { + return new String(bytes, Charset.forName(C.UTF8_NAME)); + } + + /** + * Returns a new {@link String} constructed by decoding UTF-8 encoded bytes in a subarray. + * + * @param bytes The UTF-8 encoded bytes to decode. + * @param offset The index of the first byte to decode. + * @param length The number of bytes to decode. + * @return The string. + */ + public static String fromUtf8Bytes(byte[] bytes, int offset, int length) { + return new String(bytes, offset, length, Charset.forName(C.UTF8_NAME)); + } + + /** + * Returns a new byte array containing the code points of a {@link String} encoded using UTF-8. + * + * @param value The {@link String} whose bytes should be obtained. + * @return The code points encoding using UTF-8. + */ + public static byte[] getUtf8Bytes(String value) { + return value.getBytes(Charset.forName(C.UTF8_NAME)); + } + + /** + * Splits a string using {@code value.split(regex, -1}). Note: this is is similar to {@link + * String#split(String)} but empty matches at the end of the string will not be omitted from the + * returned array. + * + * @param value The string to split. + * @param regex A delimiting regular expression. + * @return The array of strings resulting from splitting the string. + */ + public static String[] split(String value, String regex) { + return value.split(regex, /* limit= */ -1); + } + + /** + * Splits the string at the first occurrence of the delimiter {@code regex}. If the delimiter does + * not match, returns an array with one element which is the input string. If the delimiter does + * match, returns an array with the portion of the string before the delimiter and the rest of the + * string. + * + * @param value The string. + * @param regex A delimiting regular expression. + * @return The string split by the first occurrence of the delimiter. + */ + public static String[] splitAtFirst(String value, String regex) { + return value.split(regex, /* limit= */ 2); + } + + /** + * Returns whether the given character is a carriage return ('\r') or a line feed ('\n'). + * + * @param c The character. + * @return Whether the given character is a linebreak. + */ + public static boolean isLinebreak(int c) { + return c == '\n' || c == '\r'; + } + + /** + * Converts text to lower case using {@link Locale#US}. + * + * @param text The text to convert. + * @return The lower case text, or null if {@code text} is null. + */ + public static @PolyNull String toLowerInvariant(@PolyNull String text) { + return text == null ? text : text.toLowerCase(Locale.US); + } + + /** + * Converts text to upper case using {@link Locale#US}. + * + * @param text The text to convert. + * @return The upper case text, or null if {@code text} is null. + */ + public static @PolyNull String toUpperInvariant(@PolyNull String text) { + return text == null ? text : text.toUpperCase(Locale.US); + } + + /** + * Formats a string using {@link Locale#US}. + * + * @see String#format(String, Object...) + */ + public static String formatInvariant(String format, Object... args) { + return String.format(Locale.US, format, args); + } + + /** + * Divides a {@code numerator} by a {@code denominator}, returning the ceiled result. + * + * @param numerator The numerator to divide. + * @param denominator The denominator to divide by. + * @return The ceiled result of the division. + */ + public static int ceilDivide(int numerator, int denominator) { + return (numerator + denominator - 1) / denominator; + } + + /** + * Divides a {@code numerator} by a {@code denominator}, returning the ceiled result. + * + * @param numerator The numerator to divide. + * @param denominator The denominator to divide by. + * @return The ceiled result of the division. + */ + public static long ceilDivide(long numerator, long denominator) { + return (numerator + denominator - 1) / denominator; + } + + /** + * Constrains a value to the specified bounds. + * + * @param value The value to constrain. + * @param min The lower bound. + * @param max The upper bound. + * @return The constrained value {@code Math.max(min, Math.min(value, max))}. + */ + public static int constrainValue(int value, int min, int max) { + return Math.max(min, Math.min(value, max)); + } + + /** + * Constrains a value to the specified bounds. + * + * @param value The value to constrain. + * @param min The lower bound. + * @param max The upper bound. + * @return The constrained value {@code Math.max(min, Math.min(value, max))}. + */ + public static long constrainValue(long value, long min, long max) { + return Math.max(min, Math.min(value, max)); + } + + /** + * Constrains a value to the specified bounds. + * + * @param value The value to constrain. + * @param min The lower bound. + * @param max The upper bound. + * @return The constrained value {@code Math.max(min, Math.min(value, max))}. + */ + public static float constrainValue(float value, float min, float max) { + return Math.max(min, Math.min(value, max)); + } + + /** + * Returns the sum of two arguments, or a third argument if the result overflows. + * + * @param x The first value. + * @param y The second value. + * @param overflowResult The return value if {@code x + y} overflows. + * @return {@code x + y}, or {@code overflowResult} if the result overflows. + */ + public static long addWithOverflowDefault(long x, long y, long overflowResult) { + long result = x + y; + // See Hacker's Delight 2-13 (H. Warren Jr). + if (((x ^ result) & (y ^ result)) < 0) { + return overflowResult; + } + return result; + } + + /** + * Returns the difference between two arguments, or a third argument if the result overflows. + * + * @param x The first value. + * @param y The second value. + * @param overflowResult The return value if {@code x - y} overflows. + * @return {@code x - y}, or {@code overflowResult} if the result overflows. + */ + public static long subtractWithOverflowDefault(long x, long y, long overflowResult) { + long result = x - y; + // See Hacker's Delight 2-13 (H. Warren Jr). + if (((x ^ y) & (x ^ result)) < 0) { + return overflowResult; + } + return result; + } + + /** + * Returns the index of the first occurrence of {@code value} in {@code array}, or {@link + * C#INDEX_UNSET} if {@code value} is not contained in {@code array}. + * + * @param array The array to search. + * @param value The value to search for. + * @return The index of the first occurrence of value in {@code array}, or {@link C#INDEX_UNSET} + * if {@code value} is not contained in {@code array}. + */ + public static int linearSearch(int[] array, int value) { + for (int i = 0; i < array.length; i++) { + if (array[i] == value) { + return i; + } + } + return C.INDEX_UNSET; + } + + /** + * Returns the index of the largest element in {@code array} that is less than (or optionally + * equal to) a specified {@code value}. + * + * <p>The search is performed using a binary search algorithm, so the array must be sorted. If the + * array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the first one will be returned. + * + * @param array The array to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the array, whether to return the corresponding + * index. If false then the returned index corresponds to the largest element strictly less + * than the value. + * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than + * the smallest element in the array. If false then -1 will be returned. + * @return The index of the largest element in {@code array} that is less than (or optionally + * equal to) {@code value}. + */ + public static int binarySearchFloor( + int[] array, int value, boolean inclusive, boolean stayInBounds) { + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = -(index + 2); + } else { + while (--index >= 0 && array[index] == value) {} + if (inclusive) { + index++; + } + } + return stayInBounds ? Math.max(0, index) : index; + } + + /** + * Returns the index of the largest element in {@code array} that is less than (or optionally + * equal to) a specified {@code value}. + * <p> + * The search is performed using a binary search algorithm, so the array must be sorted. If the + * array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the first one will be returned. + * + * @param array The array to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the array, whether to return the corresponding + * index. If false then the returned index corresponds to the largest element strictly less + * than the value. + * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than + * the smallest element in the array. If false then -1 will be returned. + * @return The index of the largest element in {@code array} that is less than (or optionally + * equal to) {@code value}. + */ + public static int binarySearchFloor(long[] array, long value, boolean inclusive, + boolean stayInBounds) { + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = -(index + 2); + } else { + while (--index >= 0 && array[index] == value) {} + if (inclusive) { + index++; + } + } + return stayInBounds ? Math.max(0, index) : index; + } + + /** + * Returns the index of the largest element in {@code list} that is less than (or optionally equal + * to) a specified {@code value}. + * + * <p>The search is performed using a binary search algorithm, so the list must be sorted. If the + * list contains multiple elements equal to {@code value} and {@code inclusive} is true, the index + * of the first one will be returned. + * + * @param <T> The type of values being searched. + * @param list The list to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the list, whether to return the corresponding + * index. If false then the returned index corresponds to the largest element strictly less + * than the value. + * @param stayInBounds If true, then 0 will be returned in the case that the value is smaller than + * the smallest element in the list. If false then -1 will be returned. + * @return The index of the largest element in {@code list} that is less than (or optionally equal + * to) {@code value}. + */ + public static <T extends Comparable<? super T>> int binarySearchFloor( + List<? extends Comparable<? super T>> list, + T value, + boolean inclusive, + boolean stayInBounds) { + int index = Collections.binarySearch(list, value); + if (index < 0) { + index = -(index + 2); + } else { + while (--index >= 0 && list.get(index).compareTo(value) == 0) {} + if (inclusive) { + index++; + } + } + return stayInBounds ? Math.max(0, index) : index; + } + + /** + * Returns the index of the smallest element in {@code array} that is greater than (or optionally + * equal to) a specified {@code value}. + * + * <p>The search is performed using a binary search algorithm, so the array must be sorted. If the + * array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the last one will be returned. + * + * @param array The array to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the array, whether to return the corresponding + * index. If false then the returned index corresponds to the smallest element strictly + * greater than the value. + * @param stayInBounds If true, then {@code (a.length - 1)} will be returned in the case that the + * value is greater than the largest element in the array. If false then {@code a.length} will + * be returned. + * @return The index of the smallest element in {@code array} that is greater than (or optionally + * equal to) {@code value}. + */ + public static int binarySearchCeil( + int[] array, int value, boolean inclusive, boolean stayInBounds) { + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = ~index; + } else { + while (++index < array.length && array[index] == value) {} + if (inclusive) { + index--; + } + } + return stayInBounds ? Math.min(array.length - 1, index) : index; + } + + /** + * Returns the index of the smallest element in {@code array} that is greater than (or optionally + * equal to) a specified {@code value}. + * + * <p>The search is performed using a binary search algorithm, so the array must be sorted. If the + * array contains multiple elements equal to {@code value} and {@code inclusive} is true, the + * index of the last one will be returned. + * + * @param array The array to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the array, whether to return the corresponding + * index. If false then the returned index corresponds to the smallest element strictly + * greater than the value. + * @param stayInBounds If true, then {@code (a.length - 1)} will be returned in the case that the + * value is greater than the largest element in the array. If false then {@code a.length} will + * be returned. + * @return The index of the smallest element in {@code array} that is greater than (or optionally + * equal to) {@code value}. + */ + public static int binarySearchCeil( + long[] array, long value, boolean inclusive, boolean stayInBounds) { + int index = Arrays.binarySearch(array, value); + if (index < 0) { + index = ~index; + } else { + while (++index < array.length && array[index] == value) {} + if (inclusive) { + index--; + } + } + return stayInBounds ? Math.min(array.length - 1, index) : index; + } + + /** + * Returns the index of the smallest element in {@code list} that is greater than (or optionally + * equal to) a specified value. + * + * <p>The search is performed using a binary search algorithm, so the list must be sorted. If the + * list contains multiple elements equal to {@code value} and {@code inclusive} is true, the index + * of the last one will be returned. + * + * @param <T> The type of values being searched. + * @param list The list to search. + * @param value The value being searched for. + * @param inclusive If the value is present in the list, whether to return the corresponding + * index. If false then the returned index corresponds to the smallest element strictly + * greater than the value. + * @param stayInBounds If true, then {@code (list.size() - 1)} will be returned in the case that + * the value is greater than the largest element in the list. If false then {@code + * list.size()} will be returned. + * @return The index of the smallest element in {@code list} that is greater than (or optionally + * equal to) {@code value}. + */ + public static <T extends Comparable<? super T>> int binarySearchCeil( + List<? extends Comparable<? super T>> list, + T value, + boolean inclusive, + boolean stayInBounds) { + int index = Collections.binarySearch(list, value); + if (index < 0) { + index = ~index; + } else { + int listSize = list.size(); + while (++index < listSize && list.get(index).compareTo(value) == 0) {} + if (inclusive) { + index--; + } + } + return stayInBounds ? Math.min(list.size() - 1, index) : index; + } + + /** + * Compares two long values and returns the same value as {@code Long.compare(long, long)}. + * + * @param left The left operand. + * @param right The right operand. + * @return 0, if left == right, a negative value if left < right, or a positive value if left + * > right. + */ + public static int compareLong(long left, long right) { + return left < right ? -1 : left == right ? 0 : 1; + } + + /** + * Parses an xs:duration attribute value, returning the parsed duration in milliseconds. + * + * @param value The attribute value to decode. + * @return The parsed duration in milliseconds. + */ + public static long parseXsDuration(String value) { + Matcher matcher = XS_DURATION_PATTERN.matcher(value); + if (matcher.matches()) { + boolean negated = !TextUtils.isEmpty(matcher.group(1)); + // Durations containing years and months aren't completely defined. We assume there are + // 30.4368 days in a month, and 365.242 days in a year. + String years = matcher.group(3); + double durationSeconds = (years != null) ? Double.parseDouble(years) * 31556908 : 0; + String months = matcher.group(5); + durationSeconds += (months != null) ? Double.parseDouble(months) * 2629739 : 0; + String days = matcher.group(7); + durationSeconds += (days != null) ? Double.parseDouble(days) * 86400 : 0; + String hours = matcher.group(10); + durationSeconds += (hours != null) ? Double.parseDouble(hours) * 3600 : 0; + String minutes = matcher.group(12); + durationSeconds += (minutes != null) ? Double.parseDouble(minutes) * 60 : 0; + String seconds = matcher.group(14); + durationSeconds += (seconds != null) ? Double.parseDouble(seconds) : 0; + long durationMillis = (long) (durationSeconds * 1000); + return negated ? -durationMillis : durationMillis; + } else { + return (long) (Double.parseDouble(value) * 3600 * 1000); + } + } + + /** + * Parses an xs:dateTime attribute value, returning the parsed timestamp in milliseconds since + * the epoch. + * + * @param value The attribute value to decode. + * @return The parsed timestamp in milliseconds since the epoch. + * @throws ParserException if an error occurs parsing the dateTime attribute value. + */ + public static long parseXsDateTime(String value) throws ParserException { + Matcher matcher = XS_DATE_TIME_PATTERN.matcher(value); + if (!matcher.matches()) { + throw new ParserException("Invalid date/time format: " + value); + } + + int timezoneShift; + if (matcher.group(9) == null) { + // No time zone specified. + timezoneShift = 0; + } else if (matcher.group(9).equalsIgnoreCase("Z")) { + timezoneShift = 0; + } else { + timezoneShift = ((Integer.parseInt(matcher.group(12)) * 60 + + Integer.parseInt(matcher.group(13)))); + if ("-".equals(matcher.group(11))) { + timezoneShift *= -1; + } + } + + Calendar dateTime = new GregorianCalendar(TimeZone.getTimeZone("GMT")); + + dateTime.clear(); + // Note: The month value is 0-based, hence the -1 on group(2) + dateTime.set(Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)) - 1, + Integer.parseInt(matcher.group(3)), + Integer.parseInt(matcher.group(4)), + Integer.parseInt(matcher.group(5)), + Integer.parseInt(matcher.group(6))); + if (!TextUtils.isEmpty(matcher.group(8))) { + final BigDecimal bd = new BigDecimal("0." + matcher.group(8)); + // we care only for milliseconds, so movePointRight(3) + dateTime.set(Calendar.MILLISECOND, bd.movePointRight(3).intValue()); + } + + long time = dateTime.getTimeInMillis(); + if (timezoneShift != 0) { + time -= timezoneShift * 60000; + } + + return time; + } + + /** + * Scales a large timestamp. + * <p> + * Logically, scaling consists of a multiplication followed by a division. The actual operations + * performed are designed to minimize the probability of overflow. + * + * @param timestamp The timestamp to scale. + * @param multiplier The multiplier. + * @param divisor The divisor. + * @return The scaled timestamp. + */ + public static long scaleLargeTimestamp(long timestamp, long multiplier, long divisor) { + if (divisor >= multiplier && (divisor % multiplier) == 0) { + long divisionFactor = divisor / multiplier; + return timestamp / divisionFactor; + } else if (divisor < multiplier && (multiplier % divisor) == 0) { + long multiplicationFactor = multiplier / divisor; + return timestamp * multiplicationFactor; + } else { + double multiplicationFactor = (double) multiplier / divisor; + return (long) (timestamp * multiplicationFactor); + } + } + + /** + * Applies {@link #scaleLargeTimestamp(long, long, long)} to a list of unscaled timestamps. + * + * @param timestamps The timestamps to scale. + * @param multiplier The multiplier. + * @param divisor The divisor. + * @return The scaled timestamps. + */ + public static long[] scaleLargeTimestamps(List<Long> timestamps, long multiplier, long divisor) { + long[] scaledTimestamps = new long[timestamps.size()]; + if (divisor >= multiplier && (divisor % multiplier) == 0) { + long divisionFactor = divisor / multiplier; + for (int i = 0; i < scaledTimestamps.length; i++) { + scaledTimestamps[i] = timestamps.get(i) / divisionFactor; + } + } else if (divisor < multiplier && (multiplier % divisor) == 0) { + long multiplicationFactor = multiplier / divisor; + for (int i = 0; i < scaledTimestamps.length; i++) { + scaledTimestamps[i] = timestamps.get(i) * multiplicationFactor; + } + } else { + double multiplicationFactor = (double) multiplier / divisor; + for (int i = 0; i < scaledTimestamps.length; i++) { + scaledTimestamps[i] = (long) (timestamps.get(i) * multiplicationFactor); + } + } + return scaledTimestamps; + } + + /** + * Applies {@link #scaleLargeTimestamp(long, long, long)} to an array of unscaled timestamps. + * + * @param timestamps The timestamps to scale. + * @param multiplier The multiplier. + * @param divisor The divisor. + */ + public static void scaleLargeTimestampsInPlace(long[] timestamps, long multiplier, long divisor) { + if (divisor >= multiplier && (divisor % multiplier) == 0) { + long divisionFactor = divisor / multiplier; + for (int i = 0; i < timestamps.length; i++) { + timestamps[i] /= divisionFactor; + } + } else if (divisor < multiplier && (multiplier % divisor) == 0) { + long multiplicationFactor = multiplier / divisor; + for (int i = 0; i < timestamps.length; i++) { + timestamps[i] *= multiplicationFactor; + } + } else { + double multiplicationFactor = (double) multiplier / divisor; + for (int i = 0; i < timestamps.length; i++) { + timestamps[i] = (long) (timestamps[i] * multiplicationFactor); + } + } + } + + /** + * Returns the duration of media that will elapse in {@code playoutDuration}. + * + * @param playoutDuration The duration to scale. + * @param speed The playback speed. + * @return The scaled duration, in the same units as {@code playoutDuration}. + */ + public static long getMediaDurationForPlayoutDuration(long playoutDuration, float speed) { + if (speed == 1f) { + return playoutDuration; + } + return Math.round((double) playoutDuration * speed); + } + + /** + * Returns the playout duration of {@code mediaDuration} of media. + * + * @param mediaDuration The duration to scale. + * @return The scaled duration, in the same units as {@code mediaDuration}. + */ + public static long getPlayoutDurationForMediaDuration(long mediaDuration, float speed) { + if (speed == 1f) { + return mediaDuration; + } + return Math.round((double) mediaDuration / speed); + } + + /** + * Resolves a seek given the requested seek position, a {@link SeekParameters} and two candidate + * sync points. + * + * @param positionUs The requested seek position, in microseocnds. + * @param seekParameters The {@link SeekParameters}. + * @param firstSyncUs The first candidate seek point, in micrseconds. + * @param secondSyncUs The second candidate seek point, in microseconds. May equal {@code + * firstSyncUs} if there's only one candidate. + * @return The resolved seek position, in microseconds. + */ + public static long resolveSeekPositionUs( + long positionUs, SeekParameters seekParameters, long firstSyncUs, long secondSyncUs) { + if (SeekParameters.EXACT.equals(seekParameters)) { + return positionUs; + } + long minPositionUs = + subtractWithOverflowDefault(positionUs, seekParameters.toleranceBeforeUs, Long.MIN_VALUE); + long maxPositionUs = + addWithOverflowDefault(positionUs, seekParameters.toleranceAfterUs, Long.MAX_VALUE); + boolean firstSyncPositionValid = minPositionUs <= firstSyncUs && firstSyncUs <= maxPositionUs; + boolean secondSyncPositionValid = + minPositionUs <= secondSyncUs && secondSyncUs <= maxPositionUs; + if (firstSyncPositionValid && secondSyncPositionValid) { + if (Math.abs(firstSyncUs - positionUs) <= Math.abs(secondSyncUs - positionUs)) { + return firstSyncUs; + } else { + return secondSyncUs; + } + } else if (firstSyncPositionValid) { + return firstSyncUs; + } else if (secondSyncPositionValid) { + return secondSyncUs; + } else { + return minPositionUs; + } + } + + /** + * Converts a list of integers to a primitive array. + * + * @param list A list of integers. + * @return The list in array form, or null if the input list was null. + */ + public static int @PolyNull [] toArray(@PolyNull List<Integer> list) { + if (list == null) { + return null; + } + int length = list.size(); + int[] intArray = new int[length]; + for (int i = 0; i < length; i++) { + intArray[i] = list.get(i); + } + return intArray; + } + + /** + * Returns the integer equal to the big-endian concatenation of the characters in {@code string} + * as bytes. The string must be no more than four characters long. + * + * @param string A string no more than four characters long. + */ + public static int getIntegerCodeForString(String string) { + int length = string.length(); + Assertions.checkArgument(length <= 4); + int result = 0; + for (int i = 0; i < length; i++) { + result <<= 8; + result |= string.charAt(i); + } + return result; + } + + /** + * Converts an integer to a long by unsigned conversion. + * + * <p>This method is equivalent to {@link Integer#toUnsignedLong(int)} for API 26+. + */ + public static long toUnsignedLong(int x) { + // x is implicitly casted to a long before the bit operation is executed but this does not + // impact the method correctness. + return x & 0xFFFFFFFFL; + } + + /** + * Return the long that is composed of the bits of the 2 specified integers. + * + * @param mostSignificantBits The 32 most significant bits of the long to return. + * @param leastSignificantBits The 32 least significant bits of the long to return. + * @return a long where its 32 most significant bits are {@code mostSignificantBits} bits and its + * 32 least significant bits are {@code leastSignificantBits}. + */ + public static long toLong(int mostSignificantBits, int leastSignificantBits) { + return (toUnsignedLong(mostSignificantBits) << 32) | toUnsignedLong(leastSignificantBits); + } + + /** + * Returns a byte array containing values parsed from the hex string provided. + * + * @param hexString The hex string to convert to bytes. + * @return A byte array containing values parsed from the hex string provided. + */ + public static byte[] getBytesFromHexString(String hexString) { + byte[] data = new byte[hexString.length() / 2]; + for (int i = 0; i < data.length; i++) { + int stringOffset = i * 2; + data[i] = (byte) ((Character.digit(hexString.charAt(stringOffset), 16) << 4) + + Character.digit(hexString.charAt(stringOffset + 1), 16)); + } + return data; + } + + /** + * Returns a string with comma delimited simple names of each object's class. + * + * @param objects The objects whose simple class names should be comma delimited and returned. + * @return A string with comma delimited simple names of each object's class. + */ + public static String getCommaDelimitedSimpleClassNames(Object[] objects) { + StringBuilder stringBuilder = new StringBuilder(); + for (int i = 0; i < objects.length; i++) { + stringBuilder.append(objects[i].getClass().getSimpleName()); + if (i < objects.length - 1) { + stringBuilder.append(", "); + } + } + return stringBuilder.toString(); + } + + /** + * Returns a user agent string based on the given application name and the library version. + * + * @param context A valid context of the calling application. + * @param applicationName String that will be prefix'ed to the generated user agent. + * @return A user agent string generated using the applicationName and the library version. + */ + public static String getUserAgent(Context context, String applicationName) { + String versionName; + try { + String packageName = context.getPackageName(); + PackageInfo info = context.getPackageManager().getPackageInfo(packageName, 0); + versionName = info.versionName; + } catch (NameNotFoundException e) { + versionName = "?"; + } + return applicationName + "/" + versionName + " (Linux;Android " + Build.VERSION.RELEASE + + ") " + ExoPlayerLibraryInfo.VERSION_SLASHY; + } + + /** + * Returns a copy of {@code codecs} without the codecs whose track type doesn't match {@code + * trackType}. + * + * @param codecs A codec sequence string, as defined in RFC 6381. + * @param trackType One of {@link C}{@code .TRACK_TYPE_*}. + * @return A copy of {@code codecs} without the codecs whose track type doesn't match {@code + * trackType}. If this ends up empty, or {@code codecs} is null, return null. + */ + public static @Nullable String getCodecsOfType(@Nullable String codecs, int trackType) { + String[] codecArray = splitCodecs(codecs); + if (codecArray.length == 0) { + return null; + } + StringBuilder builder = new StringBuilder(); + for (String codec : codecArray) { + if (trackType == MimeTypes.getTrackTypeOfCodec(codec)) { + if (builder.length() > 0) { + builder.append(","); + } + builder.append(codec); + } + } + return builder.length() > 0 ? builder.toString() : null; + } + + /** + * Splits a codecs sequence string, as defined in RFC 6381, into individual codec strings. + * + * @param codecs A codec sequence string, as defined in RFC 6381. + * @return The split codecs, or an array of length zero if the input was empty or null. + */ + public static String[] splitCodecs(@Nullable String codecs) { + if (TextUtils.isEmpty(codecs)) { + return new String[0]; + } + return split(codecs.trim(), "(\\s*,\\s*)"); + } + + /** + * Converts a sample bit depth to a corresponding PCM encoding constant. + * + * @param bitDepth The bit depth. Supported values are 8, 16, 24 and 32. + * @return The corresponding encoding. One of {@link C#ENCODING_PCM_8BIT}, + * {@link C#ENCODING_PCM_16BIT}, {@link C#ENCODING_PCM_24BIT} and + * {@link C#ENCODING_PCM_32BIT}. If the bit depth is unsupported then + * {@link C#ENCODING_INVALID} is returned. + */ + @C.PcmEncoding + public static int getPcmEncoding(int bitDepth) { + switch (bitDepth) { + case 8: + return C.ENCODING_PCM_8BIT; + case 16: + return C.ENCODING_PCM_16BIT; + case 24: + return C.ENCODING_PCM_24BIT; + case 32: + return C.ENCODING_PCM_32BIT; + default: + return C.ENCODING_INVALID; + } + } + + /** + * Returns whether {@code encoding} is one of the linear PCM encodings. + * + * @param encoding The encoding of the audio data. + * @return Whether the encoding is one of the PCM encodings. + */ + public static boolean isEncodingLinearPcm(@C.Encoding int encoding) { + return encoding == C.ENCODING_PCM_8BIT + || encoding == C.ENCODING_PCM_16BIT + || encoding == C.ENCODING_PCM_16BIT_BIG_ENDIAN + || encoding == C.ENCODING_PCM_24BIT + || encoding == C.ENCODING_PCM_32BIT + || encoding == C.ENCODING_PCM_FLOAT; + } + + /** + * Returns whether {@code encoding} is high resolution (> 16-bit) PCM. + * + * @param encoding The encoding of the audio data. + * @return Whether the encoding is high resolution PCM. + */ + public static boolean isEncodingHighResolutionPcm(@C.PcmEncoding int encoding) { + return encoding == C.ENCODING_PCM_24BIT + || encoding == C.ENCODING_PCM_32BIT + || encoding == C.ENCODING_PCM_FLOAT; + } + + /** + * Returns the audio track channel configuration for the given channel count, or {@link + * AudioFormat#CHANNEL_INVALID} if output is not poossible. + * + * @param channelCount The number of channels in the input audio. + * @return The channel configuration or {@link AudioFormat#CHANNEL_INVALID} if output is not + * possible. + */ + public static int getAudioTrackChannelConfig(int channelCount) { + switch (channelCount) { + case 1: + return AudioFormat.CHANNEL_OUT_MONO; + case 2: + return AudioFormat.CHANNEL_OUT_STEREO; + case 3: + return AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER; + case 4: + return AudioFormat.CHANNEL_OUT_QUAD; + case 5: + return AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER; + case 6: + return AudioFormat.CHANNEL_OUT_5POINT1; + case 7: + return AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER; + case 8: + if (Util.SDK_INT >= 23) { + return AudioFormat.CHANNEL_OUT_7POINT1_SURROUND; + } else if (Util.SDK_INT >= 21) { + // Equal to AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, which is hidden before Android M. + return AudioFormat.CHANNEL_OUT_5POINT1 + | AudioFormat.CHANNEL_OUT_SIDE_LEFT + | AudioFormat.CHANNEL_OUT_SIDE_RIGHT; + } else { + // 8 ch output is not supported before Android L. + return AudioFormat.CHANNEL_INVALID; + } + default: + return AudioFormat.CHANNEL_INVALID; + } + } + + /** + * Returns the frame size for audio with {@code channelCount} channels in the specified encoding. + * + * @param pcmEncoding The encoding of the audio data. + * @param channelCount The channel count. + * @return The size of one audio frame in bytes. + */ + public static int getPcmFrameSize(@C.PcmEncoding int pcmEncoding, int channelCount) { + switch (pcmEncoding) { + case C.ENCODING_PCM_8BIT: + return channelCount; + case C.ENCODING_PCM_16BIT: + case C.ENCODING_PCM_16BIT_BIG_ENDIAN: + return channelCount * 2; + case C.ENCODING_PCM_24BIT: + return channelCount * 3; + case C.ENCODING_PCM_32BIT: + case C.ENCODING_PCM_FLOAT: + return channelCount * 4; + case C.ENCODING_INVALID: + case Format.NO_VALUE: + default: + throw new IllegalArgumentException(); + } + } + + /** + * Returns the {@link C.AudioUsage} corresponding to the specified {@link C.StreamType}. + */ + @C.AudioUsage + public static int getAudioUsageForStreamType(@C.StreamType int streamType) { + switch (streamType) { + case C.STREAM_TYPE_ALARM: + return C.USAGE_ALARM; + case C.STREAM_TYPE_DTMF: + return C.USAGE_VOICE_COMMUNICATION_SIGNALLING; + case C.STREAM_TYPE_NOTIFICATION: + return C.USAGE_NOTIFICATION; + case C.STREAM_TYPE_RING: + return C.USAGE_NOTIFICATION_RINGTONE; + case C.STREAM_TYPE_SYSTEM: + return C.USAGE_ASSISTANCE_SONIFICATION; + case C.STREAM_TYPE_VOICE_CALL: + return C.USAGE_VOICE_COMMUNICATION; + case C.STREAM_TYPE_USE_DEFAULT: + case C.STREAM_TYPE_MUSIC: + default: + return C.USAGE_MEDIA; + } + } + + /** + * Returns the {@link C.AudioContentType} corresponding to the specified {@link C.StreamType}. + */ + @C.AudioContentType + public static int getAudioContentTypeForStreamType(@C.StreamType int streamType) { + switch (streamType) { + case C.STREAM_TYPE_ALARM: + case C.STREAM_TYPE_DTMF: + case C.STREAM_TYPE_NOTIFICATION: + case C.STREAM_TYPE_RING: + case C.STREAM_TYPE_SYSTEM: + return C.CONTENT_TYPE_SONIFICATION; + case C.STREAM_TYPE_VOICE_CALL: + return C.CONTENT_TYPE_SPEECH; + case C.STREAM_TYPE_USE_DEFAULT: + case C.STREAM_TYPE_MUSIC: + default: + return C.CONTENT_TYPE_MUSIC; + } + } + + /** + * Returns the {@link C.StreamType} corresponding to the specified {@link C.AudioUsage}. + */ + @C.StreamType + public static int getStreamTypeForAudioUsage(@C.AudioUsage int usage) { + switch (usage) { + case C.USAGE_MEDIA: + case C.USAGE_GAME: + case C.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE: + return C.STREAM_TYPE_MUSIC; + case C.USAGE_ASSISTANCE_SONIFICATION: + return C.STREAM_TYPE_SYSTEM; + case C.USAGE_VOICE_COMMUNICATION: + return C.STREAM_TYPE_VOICE_CALL; + case C.USAGE_VOICE_COMMUNICATION_SIGNALLING: + return C.STREAM_TYPE_DTMF; + case C.USAGE_ALARM: + return C.STREAM_TYPE_ALARM; + case C.USAGE_NOTIFICATION_RINGTONE: + return C.STREAM_TYPE_RING; + case C.USAGE_NOTIFICATION: + case C.USAGE_NOTIFICATION_COMMUNICATION_REQUEST: + case C.USAGE_NOTIFICATION_COMMUNICATION_INSTANT: + case C.USAGE_NOTIFICATION_COMMUNICATION_DELAYED: + case C.USAGE_NOTIFICATION_EVENT: + return C.STREAM_TYPE_NOTIFICATION; + case C.USAGE_ASSISTANCE_ACCESSIBILITY: + case C.USAGE_ASSISTANT: + case C.USAGE_UNKNOWN: + default: + return C.STREAM_TYPE_DEFAULT; + } + } + + /** + * Derives a DRM {@link UUID} from {@code drmScheme}. + * + * @param drmScheme A UUID string, or {@code "widevine"}, {@code "playready"} or {@code + * "clearkey"}. + * @return The derived {@link UUID}, or {@code null} if one could not be derived. + */ + public static @Nullable UUID getDrmUuid(String drmScheme) { + switch (toLowerInvariant(drmScheme)) { + case "widevine": + return C.WIDEVINE_UUID; + case "playready": + return C.PLAYREADY_UUID; + case "clearkey": + return C.CLEARKEY_UUID; + default: + try { + return UUID.fromString(drmScheme); + } catch (RuntimeException e) { + return null; + } + } + } + + /** + * Makes a best guess to infer the type from a {@link Uri}. + * + * @param uri The {@link Uri}. + * @param overrideExtension If not null, used to infer the type. + * @return The content type. + */ + @C.ContentType + public static int inferContentType(Uri uri, @Nullable String overrideExtension) { + return TextUtils.isEmpty(overrideExtension) + ? inferContentType(uri) + : inferContentType("." + overrideExtension); + } + + /** + * Makes a best guess to infer the type from a {@link Uri}. + * + * @param uri The {@link Uri}. + * @return The content type. + */ + @C.ContentType + public static int inferContentType(Uri uri) { + String path = uri.getPath(); + return path == null ? C.TYPE_OTHER : inferContentType(path); + } + + /** + * Makes a best guess to infer the type from a file name. + * + * @param fileName Name of the file. It can include the path of the file. + * @return The content type. + */ + @C.ContentType + public static int inferContentType(String fileName) { + fileName = toLowerInvariant(fileName); + if (fileName.endsWith(".mpd")) { + return C.TYPE_DASH; + } else if (fileName.endsWith(".m3u8")) { + return C.TYPE_HLS; + } else if (fileName.matches(".*\\.ism(l)?(/manifest(\\(.+\\))?)?")) { + return C.TYPE_SS; + } else { + return C.TYPE_OTHER; + } + } + + /** + * Returns the specified millisecond time formatted as a string. + * + * @param builder The builder that {@code formatter} will write to. + * @param formatter The formatter. + * @param timeMs The time to format as a string, in milliseconds. + * @return The time formatted as a string. + */ + public static String getStringForTime(StringBuilder builder, Formatter formatter, long timeMs) { + if (timeMs == C.TIME_UNSET) { + timeMs = 0; + } + long totalSeconds = (timeMs + 500) / 1000; + long seconds = totalSeconds % 60; + long minutes = (totalSeconds / 60) % 60; + long hours = totalSeconds / 3600; + builder.setLength(0); + return hours > 0 ? formatter.format("%d:%02d:%02d", hours, minutes, seconds).toString() + : formatter.format("%02d:%02d", minutes, seconds).toString(); + } + + /** + * Escapes a string so that it's safe for use as a file or directory name on at least FAT32 + * filesystems. FAT32 is the most restrictive of all filesystems still commonly used today. + * + * <p>For simplicity, this only handles common characters known to be illegal on FAT32: + * <, >, :, ", /, \, |, ?, and *. % is also escaped since it is used as the escape + * character. Escaping is performed in a consistent way so that no collisions occur and + * {@link #unescapeFileName(String)} can be used to retrieve the original file name. + * + * @param fileName File name to be escaped. + * @return An escaped file name which will be safe for use on at least FAT32 filesystems. + */ + public static String escapeFileName(String fileName) { + int length = fileName.length(); + int charactersToEscapeCount = 0; + for (int i = 0; i < length; i++) { + if (shouldEscapeCharacter(fileName.charAt(i))) { + charactersToEscapeCount++; + } + } + if (charactersToEscapeCount == 0) { + return fileName; + } + + int i = 0; + StringBuilder builder = new StringBuilder(length + charactersToEscapeCount * 2); + while (charactersToEscapeCount > 0) { + char c = fileName.charAt(i++); + if (shouldEscapeCharacter(c)) { + builder.append('%').append(Integer.toHexString(c)); + charactersToEscapeCount--; + } else { + builder.append(c); + } + } + if (i < length) { + builder.append(fileName, i, length); + } + return builder.toString(); + } + + private static boolean shouldEscapeCharacter(char c) { + switch (c) { + case '<': + case '>': + case ':': + case '"': + case '/': + case '\\': + case '|': + case '?': + case '*': + case '%': + return true; + default: + return false; + } + } + + /** + * Unescapes an escaped file or directory name back to its original value. + * + * <p>See {@link #escapeFileName(String)} for more information. + * + * @param fileName File name to be unescaped. + * @return The original value of the file name before it was escaped, or null if the escaped + * fileName seems invalid. + */ + public static @Nullable String unescapeFileName(String fileName) { + int length = fileName.length(); + int percentCharacterCount = 0; + for (int i = 0; i < length; i++) { + if (fileName.charAt(i) == '%') { + percentCharacterCount++; + } + } + if (percentCharacterCount == 0) { + return fileName; + } + + int expectedLength = length - percentCharacterCount * 2; + StringBuilder builder = new StringBuilder(expectedLength); + Matcher matcher = ESCAPED_CHARACTER_PATTERN.matcher(fileName); + int startOfNotEscaped = 0; + while (percentCharacterCount > 0 && matcher.find()) { + char unescapedCharacter = (char) Integer.parseInt(matcher.group(1), 16); + builder.append(fileName, startOfNotEscaped, matcher.start()).append(unescapedCharacter); + startOfNotEscaped = matcher.end(); + percentCharacterCount--; + } + if (startOfNotEscaped < length) { + builder.append(fileName, startOfNotEscaped, length); + } + if (builder.length() != expectedLength) { + return null; + } + return builder.toString(); + } + + /** + * A hacky method that always throws {@code t} even if {@code t} is a checked exception, + * and is not declared to be thrown. + */ + public static void sneakyThrow(Throwable t) { + sneakyThrowInternal(t); + } + + @SuppressWarnings("unchecked") + private static <T extends Throwable> void sneakyThrowInternal(Throwable t) throws T { + throw (T) t; + } + + /** Recursively deletes a directory and its content. */ + public static void recursiveDelete(File fileOrDirectory) { + File[] directoryFiles = fileOrDirectory.listFiles(); + if (directoryFiles != null) { + for (File child : directoryFiles) { + recursiveDelete(child); + } + } + fileOrDirectory.delete(); + } + + /** Creates an empty directory in the directory returned by {@link Context#getCacheDir()}. */ + public static File createTempDirectory(Context context, String prefix) throws IOException { + File tempFile = createTempFile(context, prefix); + tempFile.delete(); // Delete the temp file. + tempFile.mkdir(); // Create a directory with the same name. + return tempFile; + } + + /** Creates a new empty file in the directory returned by {@link Context#getCacheDir()}. */ + public static File createTempFile(Context context, String prefix) throws IOException { + return File.createTempFile(prefix, null, context.getCacheDir()); + } + + /** + * Returns the result of updating a CRC-32 with the specified bytes in a "most significant bit + * first" order. + * + * @param bytes Array containing the bytes to update the crc value with. + * @param start The index to the first byte in the byte range to update the crc with. + * @param end The index after the last byte in the byte range to update the crc with. + * @param initialValue The initial value for the crc calculation. + * @return The result of updating the initial value with the specified bytes. + */ + public static int crc32(byte[] bytes, int start, int end, int initialValue) { + for (int i = start; i < end; i++) { + initialValue = (initialValue << 8) + ^ CRC32_BYTES_MSBF[((initialValue >>> 24) ^ (bytes[i] & 0xFF)) & 0xFF]; + } + return initialValue; + } + + /** + * Returns the result of updating a CRC-8 with the specified bytes in a "most significant bit + * first" order. + * + * @param bytes Array containing the bytes to update the crc value with. + * @param start The index to the first byte in the byte range to update the crc with. + * @param end The index after the last byte in the byte range to update the crc with. + * @param initialValue The initial value for the crc calculation. + * @return The result of updating the initial value with the specified bytes. + */ + public static int crc8(byte[] bytes, int start, int end, int initialValue) { + for (int i = start; i < end; i++) { + initialValue = CRC8_BYTES_MSBF[initialValue ^ (bytes[i] & 0xFF)]; + } + return initialValue; + } + + /** + * Returns the {@link C.NetworkType} of the current network connection. + * + * @param context A context to access the connectivity manager. + * @return The {@link C.NetworkType} of the current network connection. + */ + @C.NetworkType + public static int getNetworkType(Context context) { + if (context == null) { + // Note: This is for backward compatibility only (context used to be @Nullable). + return C.NETWORK_TYPE_UNKNOWN; + } + NetworkInfo networkInfo; + ConnectivityManager connectivityManager = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (connectivityManager == null) { + return C.NETWORK_TYPE_UNKNOWN; + } + try { + networkInfo = connectivityManager.getActiveNetworkInfo(); + } catch (SecurityException e) { + // Expected if permission was revoked. + return C.NETWORK_TYPE_UNKNOWN; + } + if (networkInfo == null || !networkInfo.isConnected()) { + return C.NETWORK_TYPE_OFFLINE; + } + switch (networkInfo.getType()) { + case ConnectivityManager.TYPE_WIFI: + return C.NETWORK_TYPE_WIFI; + case ConnectivityManager.TYPE_WIMAX: + return C.NETWORK_TYPE_4G; + case ConnectivityManager.TYPE_MOBILE: + case ConnectivityManager.TYPE_MOBILE_DUN: + case ConnectivityManager.TYPE_MOBILE_HIPRI: + return getMobileNetworkType(networkInfo); + case ConnectivityManager.TYPE_ETHERNET: + return C.NETWORK_TYPE_ETHERNET; + default: // VPN, Bluetooth, Dummy. + return C.NETWORK_TYPE_OTHER; + } + } + + /** + * Returns the upper-case ISO 3166-1 alpha-2 country code of the current registered operator's MCC + * (Mobile Country Code), or the country code of the default Locale if not available. + * + * @param context A context to access the telephony service. If null, only the Locale can be used. + * @return The upper-case ISO 3166-1 alpha-2 country code, or an empty String if unavailable. + */ + public static String getCountryCode(@Nullable Context context) { + if (context != null) { + TelephonyManager telephonyManager = + (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (telephonyManager != null) { + String countryCode = telephonyManager.getNetworkCountryIso(); + if (!TextUtils.isEmpty(countryCode)) { + return toUpperInvariant(countryCode); + } + } + } + return toUpperInvariant(Locale.getDefault().getCountry()); + } + + /** + * Returns a non-empty array of normalized IETF BCP 47 language tags for the system languages + * ordered by preference. + */ + public static String[] getSystemLanguageCodes() { + String[] systemLocales = getSystemLocales(); + for (int i = 0; i < systemLocales.length; i++) { + systemLocales[i] = normalizeLanguageCode(systemLocales[i]); + } + return systemLocales; + } + + /** + * Uncompresses the data in {@code input}. + * + * @param input Wraps the compressed input data. + * @param output Wraps an output buffer to be used to store the uncompressed data. If {@code + * output.data} isn't big enough to hold the uncompressed data, a new array is created. If + * {@code true} is returned then the output's position will be set to 0 and its limit will be + * set to the length of the uncompressed data. + * @param inflater If not null, used to uncompressed the input. Otherwise a new {@link Inflater} + * is created. + * @return Whether the input is uncompressed successfully. + */ + public static boolean inflate( + ParsableByteArray input, ParsableByteArray output, @Nullable Inflater inflater) { + if (input.bytesLeft() <= 0) { + return false; + } + byte[] outputData = output.data; + if (outputData.length < input.bytesLeft()) { + outputData = new byte[2 * input.bytesLeft()]; + } + if (inflater == null) { + inflater = new Inflater(); + } + inflater.setInput(input.data, input.getPosition(), input.bytesLeft()); + try { + int outputSize = 0; + while (true) { + outputSize += inflater.inflate(outputData, outputSize, outputData.length - outputSize); + if (inflater.finished()) { + output.reset(outputData, outputSize); + return true; + } + if (inflater.needsDictionary() || inflater.needsInput()) { + return false; + } + if (outputSize == outputData.length) { + outputData = Arrays.copyOf(outputData, outputData.length * 2); + } + } + } catch (DataFormatException e) { + return false; + } finally { + inflater.reset(); + } + } + + /** + * Returns whether the app is running on a TV device. + * + * @param context Any context. + * @return Whether the app is running on a TV device. + */ + public static boolean isTv(Context context) { + // See https://developer.android.com/training/tv/start/hardware.html#runtime-check. + UiModeManager uiModeManager = + (UiModeManager) context.getApplicationContext().getSystemService(UI_MODE_SERVICE); + return uiModeManager != null + && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; + } + + /** + * Gets the size of the current mode of the default display, in pixels. + * + * <p>Note that due to application UI scaling, the number of pixels made available to applications + * (as reported by {@link Display#getSize(Point)} may differ from the mode's actual resolution (as + * reported by this function). For example, applications running on a display configured with a 4K + * mode may have their UI laid out and rendered in 1080p and then scaled up. Applications can take + * advantage of the full mode resolution through a {@link SurfaceView} using full size buffers. + * + * @param context Any context. + * @return The size of the current mode, in pixels. + */ + public static Point getCurrentDisplayModeSize(Context context) { + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + return getCurrentDisplayModeSize(context, windowManager.getDefaultDisplay()); + } + + /** + * Gets the size of the current mode of the specified display, in pixels. + * + * <p>Note that due to application UI scaling, the number of pixels made available to applications + * (as reported by {@link Display#getSize(Point)} may differ from the mode's actual resolution (as + * reported by this function). For example, applications running on a display configured with a 4K + * mode may have their UI laid out and rendered in 1080p and then scaled up. Applications can take + * advantage of the full mode resolution through a {@link SurfaceView} using full size buffers. + * + * @param context Any context. + * @param display The display whose size is to be returned. + * @return The size of the current mode, in pixels. + */ + public static Point getCurrentDisplayModeSize(Context context, Display display) { + if (Util.SDK_INT <= 29 && display.getDisplayId() == Display.DEFAULT_DISPLAY && isTv(context)) { + // On Android TVs it is common for the UI to be configured for a lower resolution than + // SurfaceViews can output. Before API 26 the Display object does not provide a way to + // identify this case, and up to and including API 28 many devices still do not correctly set + // their hardware compositor output size. + + // Sony Android TVs advertise support for 4k output via a system feature. + if ("Sony".equals(Util.MANUFACTURER) + && Util.MODEL.startsWith("BRAVIA") + && context.getPackageManager().hasSystemFeature("com.sony.dtv.hardware.panel.qfhd")) { + return new Point(3840, 2160); + } + + // Otherwise check the system property for display size. From API 28 treble may prevent the + // system from writing sys.display-size so we check vendor.display-size instead. + String displaySize = + Util.SDK_INT < 28 + ? getSystemProperty("sys.display-size") + : getSystemProperty("vendor.display-size"); + // If we managed to read the display size, attempt to parse it. + if (!TextUtils.isEmpty(displaySize)) { + try { + String[] displaySizeParts = split(displaySize.trim(), "x"); + if (displaySizeParts.length == 2) { + int width = Integer.parseInt(displaySizeParts[0]); + int height = Integer.parseInt(displaySizeParts[1]); + if (width > 0 && height > 0) { + return new Point(width, height); + } + } + } catch (NumberFormatException e) { + // Do nothing. + } + Log.e(TAG, "Invalid display size: " + displaySize); + } + } + + Point displaySize = new Point(); + if (Util.SDK_INT >= 23) { + getDisplaySizeV23(display, displaySize); + } else if (Util.SDK_INT >= 17) { + getDisplaySizeV17(display, displaySize); + } else { + getDisplaySizeV16(display, displaySize); + } + return displaySize; + } + + /** + * Extract renderer capabilities for the renderers created by the provided renderers factory. + * + * @param renderersFactory A {@link RenderersFactory}. + * @return The {@link RendererCapabilities} for each renderer created by the {@code + * renderersFactory}. + */ + public static RendererCapabilities[] getRendererCapabilities(RenderersFactory renderersFactory) { + Renderer[] renderers = + renderersFactory.createRenderers( + new Handler(), + new VideoRendererEventListener() {}, + new AudioRendererEventListener() {}, + (cues) -> {}, + (metadata) -> {}, + /* drmSessionManager= */ null); + RendererCapabilities[] capabilities = new RendererCapabilities[renderers.length]; + for (int i = 0; i < renderers.length; i++) { + capabilities[i] = renderers[i].getCapabilities(); + } + return capabilities; + } + + /** + * Returns a string representation of a {@code TRACK_TYPE_*} constant defined in {@link C}. + * + * @param trackType A {@code TRACK_TYPE_*} constant, + * @return A string representation of this constant. + */ + public static String getTrackTypeString(int trackType) { + switch (trackType) { + case C.TRACK_TYPE_AUDIO: + return "audio"; + case C.TRACK_TYPE_DEFAULT: + return "default"; + case C.TRACK_TYPE_METADATA: + return "metadata"; + case C.TRACK_TYPE_CAMERA_MOTION: + return "camera motion"; + case C.TRACK_TYPE_NONE: + return "none"; + case C.TRACK_TYPE_TEXT: + return "text"; + case C.TRACK_TYPE_VIDEO: + return "video"; + default: + return trackType >= C.TRACK_TYPE_CUSTOM_BASE ? "custom (" + trackType + ")" : "?"; + } + } + + @Nullable + private static String getSystemProperty(String name) { + try { + @SuppressLint("PrivateApi") + Class<?> systemProperties = Class.forName("android.os.SystemProperties"); + Method getMethod = systemProperties.getMethod("get", String.class); + return (String) getMethod.invoke(systemProperties, name); + } catch (Exception e) { + Log.e(TAG, "Failed to read system property " + name, e); + return null; + } + } + + @TargetApi(23) + private static void getDisplaySizeV23(Display display, Point outSize) { + Display.Mode mode = display.getMode(); + outSize.x = mode.getPhysicalWidth(); + outSize.y = mode.getPhysicalHeight(); + } + + @TargetApi(17) + private static void getDisplaySizeV17(Display display, Point outSize) { + display.getRealSize(outSize); + } + + private static void getDisplaySizeV16(Display display, Point outSize) { + display.getSize(outSize); + } + + private static String[] getSystemLocales() { + Configuration config = Resources.getSystem().getConfiguration(); + return SDK_INT >= 24 + ? getSystemLocalesV24(config) + : new String[] {getLocaleLanguageTag(config.locale)}; + } + + @TargetApi(24) + private static String[] getSystemLocalesV24(Configuration config) { + return Util.split(config.getLocales().toLanguageTags(), ","); + } + + @TargetApi(21) + private static String getLocaleLanguageTagV21(Locale locale) { + return locale.toLanguageTag(); + } + + private static @C.NetworkType int getMobileNetworkType(NetworkInfo networkInfo) { + switch (networkInfo.getSubtype()) { + case TelephonyManager.NETWORK_TYPE_EDGE: + case TelephonyManager.NETWORK_TYPE_GPRS: + return C.NETWORK_TYPE_2G; + case TelephonyManager.NETWORK_TYPE_1xRTT: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_IDEN: + case TelephonyManager.NETWORK_TYPE_UMTS: + case TelephonyManager.NETWORK_TYPE_EHRPD: + case TelephonyManager.NETWORK_TYPE_HSPAP: + case TelephonyManager.NETWORK_TYPE_TD_SCDMA: + return C.NETWORK_TYPE_3G; + case TelephonyManager.NETWORK_TYPE_LTE: + return C.NETWORK_TYPE_4G; + case TelephonyManager.NETWORK_TYPE_NR: + return C.NETWORK_TYPE_5G; + case TelephonyManager.NETWORK_TYPE_IWLAN: + return C.NETWORK_TYPE_WIFI; + case TelephonyManager.NETWORK_TYPE_GSM: + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: // Future mobile network types. + return C.NETWORK_TYPE_CELLULAR_UNKNOWN; + } + } + + private static HashMap<String, String> createIsoLanguageReplacementMap() { + String[] iso2Languages = Locale.getISOLanguages(); + HashMap<String, String> replacedLanguages = + new HashMap<>( + /* initialCapacity= */ iso2Languages.length + additionalIsoLanguageReplacements.length); + for (String iso2 : iso2Languages) { + try { + // This returns the ISO 639-2/T code for the language. + String iso3 = new Locale(iso2).getISO3Language(); + if (!TextUtils.isEmpty(iso3)) { + replacedLanguages.put(iso3, iso2); + } + } catch (MissingResourceException e) { + // Shouldn't happen for list of known languages, but we don't want to throw either. + } + } + // Add additional replacement mappings. + for (int i = 0; i < additionalIsoLanguageReplacements.length; i += 2) { + replacedLanguages.put( + additionalIsoLanguageReplacements[i], additionalIsoLanguageReplacements[i + 1]); + } + return replacedLanguages; + } + + private static String maybeReplaceGrandfatheredLanguageTags(String languageTag) { + for (int i = 0; i < isoGrandfatheredTagReplacements.length; i += 2) { + if (languageTag.startsWith(isoGrandfatheredTagReplacements[i])) { + return isoGrandfatheredTagReplacements[i + 1] + + languageTag.substring(/* beginIndex= */ isoGrandfatheredTagReplacements[i].length()); + } + } + return languageTag; + } + + // Additional mapping from ISO3 to ISO2 language codes. + private static final String[] additionalIsoLanguageReplacements = + new String[] { + // Bibliographical codes defined in ISO 639-2/B, replaced by terminological code defined in + // ISO 639-2/T. See https://en.wikipedia.org/wiki/List_of_ISO_639-2_codes. + "alb", "sq", + "arm", "hy", + "baq", "eu", + "bur", "my", + "tib", "bo", + "chi", "zh", + "cze", "cs", + "dut", "nl", + "ger", "de", + "gre", "el", + "fre", "fr", + "geo", "ka", + "ice", "is", + "mac", "mk", + "mao", "mi", + "may", "ms", + "per", "fa", + "rum", "ro", + "scc", "hbs-srp", + "slo", "sk", + "wel", "cy", + // Deprecated 2-letter codes, replaced by modern equivalent (including macrolanguage) + // See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes, "ISO 639:1988" + "id", "ms-ind", + "iw", "he", + "heb", "he", + "ji", "yi", + // Individual macrolanguage codes mapped back to full macrolanguage code. + // See https://en.wikipedia.org/wiki/ISO_639_macrolanguage + "in", "ms-ind", + "ind", "ms-ind", + "nb", "no-nob", + "nob", "no-nob", + "nn", "no-nno", + "nno", "no-nno", + "tw", "ak-twi", + "twi", "ak-twi", + "bs", "hbs-bos", + "bos", "hbs-bos", + "hr", "hbs-hrv", + "hrv", "hbs-hrv", + "sr", "hbs-srp", + "srp", "hbs-srp", + "cmn", "zh-cmn", + "hak", "zh-hak", + "nan", "zh-nan", + "hsn", "zh-hsn" + }; + + // "Grandfathered tags", replaced by modern equivalents (including macrolanguage) + // See https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry. + private static final String[] isoGrandfatheredTagReplacements = + new String[] { + "i-lux", "lb", + "i-hak", "zh-hak", + "i-navajo", "nv", + "no-bok", "no-nob", + "no-nyn", "no-nno", + "zh-guoyu", "zh-cmn", + "zh-hakka", "zh-hak", + "zh-min-nan", "zh-nan", + "zh-xiang", "zh-hsn" + }; + + /** + * Allows the CRC-32 calculation to be done byte by byte instead of bit per bit in the order "most + * significant bit first". + */ + private static final int[] CRC32_BYTES_MSBF = { + 0X00000000, 0X04C11DB7, 0X09823B6E, 0X0D4326D9, 0X130476DC, 0X17C56B6B, 0X1A864DB2, + 0X1E475005, 0X2608EDB8, 0X22C9F00F, 0X2F8AD6D6, 0X2B4BCB61, 0X350C9B64, 0X31CD86D3, + 0X3C8EA00A, 0X384FBDBD, 0X4C11DB70, 0X48D0C6C7, 0X4593E01E, 0X4152FDA9, 0X5F15ADAC, + 0X5BD4B01B, 0X569796C2, 0X52568B75, 0X6A1936C8, 0X6ED82B7F, 0X639B0DA6, 0X675A1011, + 0X791D4014, 0X7DDC5DA3, 0X709F7B7A, 0X745E66CD, 0X9823B6E0, 0X9CE2AB57, 0X91A18D8E, + 0X95609039, 0X8B27C03C, 0X8FE6DD8B, 0X82A5FB52, 0X8664E6E5, 0XBE2B5B58, 0XBAEA46EF, + 0XB7A96036, 0XB3687D81, 0XAD2F2D84, 0XA9EE3033, 0XA4AD16EA, 0XA06C0B5D, 0XD4326D90, + 0XD0F37027, 0XDDB056FE, 0XD9714B49, 0XC7361B4C, 0XC3F706FB, 0XCEB42022, 0XCA753D95, + 0XF23A8028, 0XF6FB9D9F, 0XFBB8BB46, 0XFF79A6F1, 0XE13EF6F4, 0XE5FFEB43, 0XE8BCCD9A, + 0XEC7DD02D, 0X34867077, 0X30476DC0, 0X3D044B19, 0X39C556AE, 0X278206AB, 0X23431B1C, + 0X2E003DC5, 0X2AC12072, 0X128E9DCF, 0X164F8078, 0X1B0CA6A1, 0X1FCDBB16, 0X018AEB13, + 0X054BF6A4, 0X0808D07D, 0X0CC9CDCA, 0X7897AB07, 0X7C56B6B0, 0X71159069, 0X75D48DDE, + 0X6B93DDDB, 0X6F52C06C, 0X6211E6B5, 0X66D0FB02, 0X5E9F46BF, 0X5A5E5B08, 0X571D7DD1, + 0X53DC6066, 0X4D9B3063, 0X495A2DD4, 0X44190B0D, 0X40D816BA, 0XACA5C697, 0XA864DB20, + 0XA527FDF9, 0XA1E6E04E, 0XBFA1B04B, 0XBB60ADFC, 0XB6238B25, 0XB2E29692, 0X8AAD2B2F, + 0X8E6C3698, 0X832F1041, 0X87EE0DF6, 0X99A95DF3, 0X9D684044, 0X902B669D, 0X94EA7B2A, + 0XE0B41DE7, 0XE4750050, 0XE9362689, 0XEDF73B3E, 0XF3B06B3B, 0XF771768C, 0XFA325055, + 0XFEF34DE2, 0XC6BCF05F, 0XC27DEDE8, 0XCF3ECB31, 0XCBFFD686, 0XD5B88683, 0XD1799B34, + 0XDC3ABDED, 0XD8FBA05A, 0X690CE0EE, 0X6DCDFD59, 0X608EDB80, 0X644FC637, 0X7A089632, + 0X7EC98B85, 0X738AAD5C, 0X774BB0EB, 0X4F040D56, 0X4BC510E1, 0X46863638, 0X42472B8F, + 0X5C007B8A, 0X58C1663D, 0X558240E4, 0X51435D53, 0X251D3B9E, 0X21DC2629, 0X2C9F00F0, + 0X285E1D47, 0X36194D42, 0X32D850F5, 0X3F9B762C, 0X3B5A6B9B, 0X0315D626, 0X07D4CB91, + 0X0A97ED48, 0X0E56F0FF, 0X1011A0FA, 0X14D0BD4D, 0X19939B94, 0X1D528623, 0XF12F560E, + 0XF5EE4BB9, 0XF8AD6D60, 0XFC6C70D7, 0XE22B20D2, 0XE6EA3D65, 0XEBA91BBC, 0XEF68060B, + 0XD727BBB6, 0XD3E6A601, 0XDEA580D8, 0XDA649D6F, 0XC423CD6A, 0XC0E2D0DD, 0XCDA1F604, + 0XC960EBB3, 0XBD3E8D7E, 0XB9FF90C9, 0XB4BCB610, 0XB07DABA7, 0XAE3AFBA2, 0XAAFBE615, + 0XA7B8C0CC, 0XA379DD7B, 0X9B3660C6, 0X9FF77D71, 0X92B45BA8, 0X9675461F, 0X8832161A, + 0X8CF30BAD, 0X81B02D74, 0X857130C3, 0X5D8A9099, 0X594B8D2E, 0X5408ABF7, 0X50C9B640, + 0X4E8EE645, 0X4A4FFBF2, 0X470CDD2B, 0X43CDC09C, 0X7B827D21, 0X7F436096, 0X7200464F, + 0X76C15BF8, 0X68860BFD, 0X6C47164A, 0X61043093, 0X65C52D24, 0X119B4BE9, 0X155A565E, + 0X18197087, 0X1CD86D30, 0X029F3D35, 0X065E2082, 0X0B1D065B, 0X0FDC1BEC, 0X3793A651, + 0X3352BBE6, 0X3E119D3F, 0X3AD08088, 0X2497D08D, 0X2056CD3A, 0X2D15EBE3, 0X29D4F654, + 0XC5A92679, 0XC1683BCE, 0XCC2B1D17, 0XC8EA00A0, 0XD6AD50A5, 0XD26C4D12, 0XDF2F6BCB, + 0XDBEE767C, 0XE3A1CBC1, 0XE760D676, 0XEA23F0AF, 0XEEE2ED18, 0XF0A5BD1D, 0XF464A0AA, + 0XF9278673, 0XFDE69BC4, 0X89B8FD09, 0X8D79E0BE, 0X803AC667, 0X84FBDBD0, 0X9ABC8BD5, + 0X9E7D9662, 0X933EB0BB, 0X97FFAD0C, 0XAFB010B1, 0XAB710D06, 0XA6322BDF, 0XA2F33668, + 0XBCB4666D, 0XB8757BDA, 0XB5365D03, 0XB1F740B4 + }; + + /** + * Allows the CRC-8 calculation to be done byte by byte instead of bit per bit in the order "most + * significant bit first". + */ + private static final int[] CRC8_BYTES_MSBF = { + 0x00, 0x07, 0x0E, 0x09, 0x1C, 0x1B, 0x12, 0x15, 0x38, 0x3F, 0x36, 0x31, 0x24, 0x23, 0x2A, + 0x2D, 0x70, 0x77, 0x7E, 0x79, 0x6C, 0x6B, 0x62, 0x65, 0x48, 0x4F, 0x46, 0x41, 0x54, 0x53, + 0x5A, 0x5D, 0xE0, 0xE7, 0xEE, 0xE9, 0xFC, 0xFB, 0xF2, 0xF5, 0xD8, 0xDF, 0xD6, 0xD1, 0xC4, + 0xC3, 0xCA, 0xCD, 0x90, 0x97, 0x9E, 0x99, 0x8C, 0x8B, 0x82, 0x85, 0xA8, 0xAF, 0xA6, 0xA1, + 0xB4, 0xB3, 0xBA, 0xBD, 0xC7, 0xC0, 0xC9, 0xCE, 0xDB, 0xDC, 0xD5, 0xD2, 0xFF, 0xF8, 0xF1, + 0xF6, 0xE3, 0xE4, 0xED, 0xEA, 0xB7, 0xB0, 0xB9, 0xBE, 0xAB, 0xAC, 0xA5, 0xA2, 0x8F, 0x88, + 0x81, 0x86, 0x93, 0x94, 0x9D, 0x9A, 0x27, 0x20, 0x29, 0x2E, 0x3B, 0x3C, 0x35, 0x32, 0x1F, + 0x18, 0x11, 0x16, 0x03, 0x04, 0x0D, 0x0A, 0x57, 0x50, 0x59, 0x5E, 0x4B, 0x4C, 0x45, 0x42, + 0x6F, 0x68, 0x61, 0x66, 0x73, 0x74, 0x7D, 0x7A, 0x89, 0x8E, 0x87, 0x80, 0x95, 0x92, 0x9B, + 0x9C, 0xB1, 0xB6, 0xBF, 0xB8, 0xAD, 0xAA, 0xA3, 0xA4, 0xF9, 0xFE, 0xF7, 0xF0, 0xE5, 0xE2, + 0xEB, 0xEC, 0xC1, 0xC6, 0xCF, 0xC8, 0xDD, 0xDA, 0xD3, 0xD4, 0x69, 0x6E, 0x67, 0x60, 0x75, + 0x72, 0x7B, 0x7C, 0x51, 0x56, 0x5F, 0x58, 0x4D, 0x4A, 0x43, 0x44, 0x19, 0x1E, 0x17, 0x10, + 0x05, 0x02, 0x0B, 0x0C, 0x21, 0x26, 0x2F, 0x28, 0x3D, 0x3A, 0x33, 0x34, 0x4E, 0x49, 0x40, + 0x47, 0x52, 0x55, 0x5C, 0x5B, 0x76, 0x71, 0x78, 0x7F, 0x6A, 0x6D, 0x64, 0x63, 0x3E, 0x39, + 0x30, 0x37, 0x22, 0x25, 0x2C, 0x2B, 0x06, 0x01, 0x08, 0x0F, 0x1A, 0x1D, 0x14, 0x13, 0xAE, + 0xA9, 0xA0, 0xA7, 0xB2, 0xB5, 0xBC, 0xBB, 0x96, 0x91, 0x98, 0x9F, 0x8A, 0x8D, 0x84, 0x83, + 0xDE, 0xD9, 0xD0, 0xD7, 0xC2, 0xC5, 0xCC, 0xCB, 0xE6, 0xE1, 0xE8, 0xEF, 0xFA, 0xFD, 0xF4, + 0xF3 + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/XmlPullParserUtil.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/XmlPullParserUtil.java new file mode 100644 index 0000000000..7b56886dba --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/XmlPullParserUtil.java @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; + +import androidx.annotation.Nullable; +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +/** + * {@link XmlPullParser} utility methods. + */ +public final class XmlPullParserUtil { + + private XmlPullParserUtil() {} + + /** + * Returns whether the current event is an end tag with the specified name. + * + * @param xpp The {@link XmlPullParser} to query. + * @param name The specified name. + * @return Whether the current event is an end tag with the specified name. + * @throws XmlPullParserException If an error occurs querying the parser. + */ + public static boolean isEndTag(XmlPullParser xpp, String name) throws XmlPullParserException { + return isEndTag(xpp) && xpp.getName().equals(name); + } + + /** + * Returns whether the current event is an end tag. + * + * @param xpp The {@link XmlPullParser} to query. + * @return Whether the current event is an end tag. + * @throws XmlPullParserException If an error occurs querying the parser. + */ + public static boolean isEndTag(XmlPullParser xpp) throws XmlPullParserException { + return xpp.getEventType() == XmlPullParser.END_TAG; + } + + /** + * Returns whether the current event is a start tag with the specified name. + * + * @param xpp The {@link XmlPullParser} to query. + * @param name The specified name. + * @return Whether the current event is a start tag with the specified name. + * @throws XmlPullParserException If an error occurs querying the parser. + */ + public static boolean isStartTag(XmlPullParser xpp, String name) throws XmlPullParserException { + return isStartTag(xpp) && xpp.getName().equals(name); + } + + /** + * Returns whether the current event is a start tag. + * + * @param xpp The {@link XmlPullParser} to query. + * @return Whether the current event is a start tag. + * @throws XmlPullParserException If an error occurs querying the parser. + */ + public static boolean isStartTag(XmlPullParser xpp) throws XmlPullParserException { + return xpp.getEventType() == XmlPullParser.START_TAG; + } + + /** + * Returns whether the current event is a start tag with the specified name. If the current event + * has a raw name then its prefix is stripped before matching. + * + * @param xpp The {@link XmlPullParser} to query. + * @param name The specified name. + * @return Whether the current event is a start tag with the specified name. + * @throws XmlPullParserException If an error occurs querying the parser. + */ + public static boolean isStartTagIgnorePrefix(XmlPullParser xpp, String name) + throws XmlPullParserException { + return isStartTag(xpp) && stripPrefix(xpp.getName()).equals(name); + } + + /** + * Returns the value of an attribute of the current start tag. + * + * @param xpp The {@link XmlPullParser} to query. + * @param attributeName The name of the attribute. + * @return The value of the attribute, or null if the current event is not a start tag or if no + * such attribute was found. + */ + public static @Nullable String getAttributeValue(XmlPullParser xpp, String attributeName) { + int attributeCount = xpp.getAttributeCount(); + for (int i = 0; i < attributeCount; i++) { + if (xpp.getAttributeName(i).equals(attributeName)) { + return xpp.getAttributeValue(i); + } + } + return null; + } + + /** + * Returns the value of an attribute of the current start tag. Any raw attribute names in the + * current start tag have their prefixes stripped before matching. + * + * @param xpp The {@link XmlPullParser} to query. + * @param attributeName The name of the attribute. + * @return The value of the attribute, or null if the current event is not a start tag or if no + * such attribute was found. + */ + public static @Nullable String getAttributeValueIgnorePrefix( + XmlPullParser xpp, String attributeName) { + int attributeCount = xpp.getAttributeCount(); + for (int i = 0; i < attributeCount; i++) { + if (stripPrefix(xpp.getAttributeName(i)).equals(attributeName)) { + return xpp.getAttributeValue(i); + } + } + return null; + } + + private static String stripPrefix(String name) { + int prefixSeparatorIndex = name.indexOf(':'); + return prefixSeparatorIndex == -1 ? name : name.substring(prefixSeparatorIndex + 1); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/package-info.java new file mode 100644 index 0000000000..49ee4a4d4d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/util/package-info.java @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.util; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/AvcConfig.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/AvcConfig.java new file mode 100644 index 0000000000..2026a27ff7 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/AvcConfig.java @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.CodecSpecificDataUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil.SpsData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.ArrayList; +import java.util.List; + +/** + * AVC configuration data. + */ +public final class AvcConfig { + + public final List<byte[]> initializationData; + public final int nalUnitLengthFieldLength; + public final int width; + public final int height; + public final float pixelWidthAspectRatio; + + /** + * Parses AVC configuration data. + * + * @param data A {@link ParsableByteArray}, whose position is set to the start of the AVC + * configuration data to parse. + * @return A parsed representation of the HEVC configuration data. + * @throws ParserException If an error occurred parsing the data. + */ + public static AvcConfig parse(ParsableByteArray data) throws ParserException { + try { + data.skipBytes(4); // Skip to the AVCDecoderConfigurationRecord (defined in 14496-15) + int nalUnitLengthFieldLength = (data.readUnsignedByte() & 0x3) + 1; + if (nalUnitLengthFieldLength == 3) { + throw new IllegalStateException(); + } + List<byte[]> initializationData = new ArrayList<>(); + int numSequenceParameterSets = data.readUnsignedByte() & 0x1F; + for (int j = 0; j < numSequenceParameterSets; j++) { + initializationData.add(buildNalUnitForChild(data)); + } + int numPictureParameterSets = data.readUnsignedByte(); + for (int j = 0; j < numPictureParameterSets; j++) { + initializationData.add(buildNalUnitForChild(data)); + } + + int width = Format.NO_VALUE; + int height = Format.NO_VALUE; + float pixelWidthAspectRatio = 1; + if (numSequenceParameterSets > 0) { + byte[] sps = initializationData.get(0); + SpsData spsData = NalUnitUtil.parseSpsNalUnit(initializationData.get(0), + nalUnitLengthFieldLength, sps.length); + width = spsData.width; + height = spsData.height; + pixelWidthAspectRatio = spsData.pixelWidthAspectRatio; + } + return new AvcConfig(initializationData, nalUnitLengthFieldLength, width, height, + pixelWidthAspectRatio); + } catch (ArrayIndexOutOfBoundsException e) { + throw new ParserException("Error parsing AVC config", e); + } + } + + private AvcConfig(List<byte[]> initializationData, int nalUnitLengthFieldLength, + int width, int height, float pixelWidthAspectRatio) { + this.initializationData = initializationData; + this.nalUnitLengthFieldLength = nalUnitLengthFieldLength; + this.width = width; + this.height = height; + this.pixelWidthAspectRatio = pixelWidthAspectRatio; + } + + private static byte[] buildNalUnitForChild(ParsableByteArray data) { + int length = data.readUnsignedShort(); + int offset = data.getPosition(); + data.skipBytes(length); + return CodecSpecificDataUtil.buildNalUnit(data.data, offset, length); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/ColorInfo.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/ColorInfo.java new file mode 100644 index 0000000000..7eed4e3eaf --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/ColorInfo.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.util.Arrays; + +/** + * Stores color info. + */ +public final class ColorInfo implements Parcelable { + + /** + * The color space of the video. Valid values are {@link C#COLOR_SPACE_BT601}, {@link + * C#COLOR_SPACE_BT709}, {@link C#COLOR_SPACE_BT2020} or {@link Format#NO_VALUE} if unknown. + */ + @C.ColorSpace + public final int colorSpace; + + /** + * The color range of the video. Valid values are {@link C#COLOR_RANGE_LIMITED}, {@link + * C#COLOR_RANGE_FULL} or {@link Format#NO_VALUE} if unknown. + */ + @C.ColorRange + public final int colorRange; + + /** + * The color transfer characteristicks of the video. Valid values are {@link + * C#COLOR_TRANSFER_HLG}, {@link C#COLOR_TRANSFER_ST2084}, {@link C#COLOR_TRANSFER_SDR} or {@link + * Format#NO_VALUE} if unknown. + */ + @C.ColorTransfer + public final int colorTransfer; + + /** HdrStaticInfo as defined in CTA-861.3, or null if none specified. */ + @Nullable public final byte[] hdrStaticInfo; + + // Lazily initialized hashcode. + private int hashCode; + + /** + * Constructs the ColorInfo. + * + * @param colorSpace The color space of the video. + * @param colorRange The color range of the video. + * @param colorTransfer The color transfer characteristics of the video. + * @param hdrStaticInfo HdrStaticInfo as defined in CTA-861.3, or null if none specified. + */ + public ColorInfo( + @C.ColorSpace int colorSpace, + @C.ColorRange int colorRange, + @C.ColorTransfer int colorTransfer, + @Nullable byte[] hdrStaticInfo) { + this.colorSpace = colorSpace; + this.colorRange = colorRange; + this.colorTransfer = colorTransfer; + this.hdrStaticInfo = hdrStaticInfo; + } + + @SuppressWarnings("ResourceType") + /* package */ ColorInfo(Parcel in) { + colorSpace = in.readInt(); + colorRange = in.readInt(); + colorTransfer = in.readInt(); + boolean hasHdrStaticInfo = Util.readBoolean(in); + hdrStaticInfo = hasHdrStaticInfo ? in.createByteArray() : null; + } + + // Parcelable implementation. + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + ColorInfo other = (ColorInfo) obj; + return colorSpace == other.colorSpace + && colorRange == other.colorRange + && colorTransfer == other.colorTransfer + && Arrays.equals(hdrStaticInfo, other.hdrStaticInfo); + } + + @Override + public String toString() { + return "ColorInfo(" + colorSpace + ", " + colorRange + ", " + colorTransfer + + ", " + (hdrStaticInfo != null) + ")"; + } + + @Override + public int hashCode() { + if (hashCode == 0) { + int result = 17; + result = 31 * result + colorSpace; + result = 31 * result + colorRange; + result = 31 * result + colorTransfer; + result = 31 * result + Arrays.hashCode(hdrStaticInfo); + hashCode = result; + } + return hashCode; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(colorSpace); + dest.writeInt(colorRange); + dest.writeInt(colorTransfer); + Util.writeBoolean(dest, hdrStaticInfo != null); + if (hdrStaticInfo != null) { + dest.writeByteArray(hdrStaticInfo); + } + } + + public static final Parcelable.Creator<ColorInfo> CREATOR = + new Parcelable.Creator<ColorInfo>() { + @Override + public ColorInfo createFromParcel(Parcel in) { + return new ColorInfo(in); + } + + @Override + public ColorInfo[] newArray(int size) { + return new ColorInfo[size]; + } + }; +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/DolbyVisionConfig.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/DolbyVisionConfig.java new file mode 100644 index 0000000000..bfc1f814d2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/DolbyVisionConfig.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; + +/** Dolby Vision configuration data. */ +public final class DolbyVisionConfig { + + /** + * Parses Dolby Vision configuration data. + * + * @param data A {@link ParsableByteArray}, whose position is set to the start of the Dolby Vision + * configuration data to parse. + * @return The {@link DolbyVisionConfig} corresponding to the configuration, or {@code null} if + * the configuration isn't supported. + */ + @Nullable + public static DolbyVisionConfig parse(ParsableByteArray data) { + data.skipBytes(2); // dv_version_major, dv_version_minor + int profileData = data.readUnsignedByte(); + int dvProfile = (profileData >> 1); + int dvLevel = ((profileData & 0x1) << 5) | ((data.readUnsignedByte() >> 3) & 0x1F); + String codecsPrefix; + if (dvProfile == 4 || dvProfile == 5 || dvProfile == 7) { + codecsPrefix = "dvhe"; + } else if (dvProfile == 8) { + codecsPrefix = "hev1"; + } else if (dvProfile == 9) { + codecsPrefix = "avc3"; + } else { + return null; + } + String codecs = codecsPrefix + ".0" + dvProfile + ".0" + dvLevel; + return new DolbyVisionConfig(dvProfile, dvLevel, codecs); + } + + /** The profile number. */ + public final int profile; + /** The level number. */ + public final int level; + /** The RFC 6381 codecs string. */ + public final String codecs; + + private DolbyVisionConfig(int profile, int level, String codecs) { + this.profile = profile; + this.level = level; + this.codecs = codecs; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/DummySurface.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/DummySurface.java new file mode 100644 index 0000000000..abfb8b0952 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/DummySurface.java @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.EGLSurfaceTexture.SECURE_MODE_NONE; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.EGLSurfaceTexture.SECURE_MODE_PROTECTED_PBUFFER; +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.EGLSurfaceTexture.SECURE_MODE_SURFACELESS_CONTEXT; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.HandlerThread; +import android.os.Message; +import android.view.Surface; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.EGLSurfaceTexture; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.EGLSurfaceTexture.SecureMode; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.GlUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.checkerframework.checker.nullness.qual.MonotonicNonNull; + +/** A dummy {@link Surface}. */ +@TargetApi(17) +public final class DummySurface extends Surface { + + private static final String TAG = "DummySurface"; + + /** + * Whether the surface is secure. + */ + public final boolean secure; + + private static @SecureMode int secureMode; + private static boolean secureModeInitialized; + + private final DummySurfaceThread thread; + private boolean threadReleased; + + /** + * Returns whether the device supports secure dummy surfaces. + * + * @param context Any {@link Context}. + * @return Whether the device supports secure dummy surfaces. + */ + public static synchronized boolean isSecureSupported(Context context) { + if (!secureModeInitialized) { + secureMode = getSecureMode(context); + secureModeInitialized = true; + } + return secureMode != SECURE_MODE_NONE; + } + + /** + * Returns a newly created dummy surface. The surface must be released by calling {@link #release} + * when it's no longer required. + * <p> + * Must only be called if {@link Util#SDK_INT} is 17 or higher. + * + * @param context Any {@link Context}. + * @param secure Whether a secure surface is required. Must only be requested if + * {@link #isSecureSupported(Context)} returns {@code true}. + * @throws IllegalStateException If a secure surface is requested on a device for which + * {@link #isSecureSupported(Context)} returns {@code false}. + */ + public static DummySurface newInstanceV17(Context context, boolean secure) { + assertApiLevel17OrHigher(); + Assertions.checkState(!secure || isSecureSupported(context)); + DummySurfaceThread thread = new DummySurfaceThread(); + return thread.init(secure ? secureMode : SECURE_MODE_NONE); + } + + private DummySurface(DummySurfaceThread thread, SurfaceTexture surfaceTexture, boolean secure) { + super(surfaceTexture); + this.thread = thread; + this.secure = secure; + } + + @Override + public void release() { + super.release(); + // The Surface may be released multiple times (explicitly and by Surface.finalize()). The + // implementation of super.release() has its own deduplication logic. Below we need to + // deduplicate ourselves. Synchronization is required as we don't control the thread on which + // Surface.finalize() is called. + synchronized (thread) { + if (!threadReleased) { + thread.release(); + threadReleased = true; + } + } + } + + private static void assertApiLevel17OrHigher() { + if (Util.SDK_INT < 17) { + throw new UnsupportedOperationException("Unsupported prior to API level 17"); + } + } + + @SecureMode + private static int getSecureMode(Context context) { + if (GlUtil.isProtectedContentExtensionSupported(context)) { + if (GlUtil.isSurfacelessContextExtensionSupported()) { + return SECURE_MODE_SURFACELESS_CONTEXT; + } else { + // If we can't use surfaceless contexts, we use a protected 1 * 1 pixel buffer surface. + // This may require support for EXT_protected_surface, but in practice it works on some + // devices that don't have that extension. See also + // https://github.com/google/ExoPlayer/issues/3558. + return SECURE_MODE_PROTECTED_PBUFFER; + } + } else { + return SECURE_MODE_NONE; + } + } + + private static class DummySurfaceThread extends HandlerThread implements Callback { + + private static final int MSG_INIT = 1; + private static final int MSG_RELEASE = 2; + + private @MonotonicNonNull EGLSurfaceTexture eglSurfaceTexture; + private @MonotonicNonNull Handler handler; + @Nullable private Error initError; + @Nullable private RuntimeException initException; + @Nullable private DummySurface surface; + + public DummySurfaceThread() { + super("dummySurface"); + } + + public DummySurface init(@SecureMode int secureMode) { + start(); + handler = new Handler(getLooper(), /* callback= */ this); + eglSurfaceTexture = new EGLSurfaceTexture(handler); + boolean wasInterrupted = false; + synchronized (this) { + handler.obtainMessage(MSG_INIT, secureMode, 0).sendToTarget(); + while (surface == null && initException == null && initError == null) { + try { + wait(); + } catch (InterruptedException e) { + wasInterrupted = true; + } + } + } + if (wasInterrupted) { + // Restore the interrupted status. + Thread.currentThread().interrupt(); + } + if (initException != null) { + throw initException; + } else if (initError != null) { + throw initError; + } else { + return Assertions.checkNotNull(surface); + } + } + + public void release() { + Assertions.checkNotNull(handler); + handler.sendEmptyMessage(MSG_RELEASE); + } + + @Override + public boolean handleMessage(Message msg) { + switch (msg.what) { + case MSG_INIT: + try { + initInternal(/* secureMode= */ msg.arg1); + } catch (RuntimeException e) { + Log.e(TAG, "Failed to initialize dummy surface", e); + initException = e; + } catch (Error e) { + Log.e(TAG, "Failed to initialize dummy surface", e); + initError = e; + } finally { + synchronized (this) { + notify(); + } + } + return true; + case MSG_RELEASE: + try { + releaseInternal(); + } catch (Throwable e) { + Log.e(TAG, "Failed to release dummy surface", e); + } finally { + quit(); + } + return true; + default: + return true; + } + } + + private void initInternal(@SecureMode int secureMode) { + Assertions.checkNotNull(eglSurfaceTexture); + eglSurfaceTexture.init(secureMode); + this.surface = + new DummySurface( + this, eglSurfaceTexture.getSurfaceTexture(), secureMode != SECURE_MODE_NONE); + } + + private void releaseInternal() { + Assertions.checkNotNull(eglSurfaceTexture); + eglSurfaceTexture.release(); + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/HevcConfig.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/HevcConfig.java new file mode 100644 index 0000000000..844712146a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/HevcConfig.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ParserException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NalUnitUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import java.util.Collections; +import java.util.List; + +/** + * HEVC configuration data. + */ +public final class HevcConfig { + + @Nullable public final List<byte[]> initializationData; + public final int nalUnitLengthFieldLength; + + /** + * Parses HEVC configuration data. + * + * @param data A {@link ParsableByteArray}, whose position is set to the start of the HEVC + * configuration data to parse. + * @return A parsed representation of the HEVC configuration data. + * @throws ParserException If an error occurred parsing the data. + */ + public static HevcConfig parse(ParsableByteArray data) throws ParserException { + try { + data.skipBytes(21); // Skip to the NAL unit length size field. + int lengthSizeMinusOne = data.readUnsignedByte() & 0x03; + + // Calculate the combined size of all VPS/SPS/PPS bitstreams. + int numberOfArrays = data.readUnsignedByte(); + int csdLength = 0; + int csdStartPosition = data.getPosition(); + for (int i = 0; i < numberOfArrays; i++) { + data.skipBytes(1); // completeness (1), nal_unit_type (7) + int numberOfNalUnits = data.readUnsignedShort(); + for (int j = 0; j < numberOfNalUnits; j++) { + int nalUnitLength = data.readUnsignedShort(); + csdLength += 4 + nalUnitLength; // Start code and NAL unit. + data.skipBytes(nalUnitLength); + } + } + + // Concatenate the codec-specific data into a single buffer. + data.setPosition(csdStartPosition); + byte[] buffer = new byte[csdLength]; + int bufferPosition = 0; + for (int i = 0; i < numberOfArrays; i++) { + data.skipBytes(1); // completeness (1), nal_unit_type (7) + int numberOfNalUnits = data.readUnsignedShort(); + for (int j = 0; j < numberOfNalUnits; j++) { + int nalUnitLength = data.readUnsignedShort(); + System.arraycopy(NalUnitUtil.NAL_START_CODE, 0, buffer, bufferPosition, + NalUnitUtil.NAL_START_CODE.length); + bufferPosition += NalUnitUtil.NAL_START_CODE.length; + System + .arraycopy(data.data, data.getPosition(), buffer, bufferPosition, nalUnitLength); + bufferPosition += nalUnitLength; + data.skipBytes(nalUnitLength); + } + } + + List<byte[]> initializationData = csdLength == 0 ? null : Collections.singletonList(buffer); + return new HevcConfig(initializationData, lengthSizeMinusOne + 1); + } catch (ArrayIndexOutOfBoundsException e) { + throw new ParserException("Error parsing HEVC config", e); + } + } + + private HevcConfig(@Nullable List<byte[]> initializationData, int nalUnitLengthFieldLength) { + this.initializationData = initializationData; + this.nalUnitLengthFieldLength = nalUnitLengthFieldLength; + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java new file mode 100644 index 0000000000..1627b70a28 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/MediaCodecVideoRenderer.java @@ -0,0 +1,1873 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.Point; +import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecInfo.CodecProfileLevel; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.SystemClock; +import android.util.Pair; +import android.view.Surface; +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +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.ExoPlayer; +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.PlayerMessage.Target; +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.drm.DrmInitData; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.FrameworkMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecRenderer; +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.mediacodec.MediaCodecUtil.DecoderQueryException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaFormatUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Log; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener.EventDispatcher; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; + +/** + * Decodes and renders video using {@link MediaCodec}. + * + * <p>This renderer accepts the following messages sent via {@link ExoPlayer#createMessage(Target)} + * on the playback thread: + * + * <ul> + * <li>Message with type {@link C#MSG_SET_SURFACE} to set the output surface. The message payload + * should be the target {@link Surface}, or null. + * <li>Message with type {@link C#MSG_SET_SCALING_MODE} to set the video scaling mode. The message + * payload should be one of the integer scaling modes in {@link C.VideoScalingMode}. Note that + * the scaling mode only applies if the {@link Surface} targeted by this renderer is owned by + * a {@link android.view.SurfaceView}. + * </ul> + */ +public class MediaCodecVideoRenderer extends MediaCodecRenderer { + + private static final String TAG = "MediaCodecVideoRenderer"; + private static final String KEY_CROP_LEFT = "crop-left"; + private static final String KEY_CROP_RIGHT = "crop-right"; + private static final String KEY_CROP_BOTTOM = "crop-bottom"; + private static final String KEY_CROP_TOP = "crop-top"; + + // Long edge length in pixels for standard video formats, in decreasing in order. + private static final int[] STANDARD_LONG_EDGE_VIDEO_PX = new int[] { + 1920, 1600, 1440, 1280, 960, 854, 640, 540, 480}; + + // Generally there is zero or one pending output stream offset. We track more offsets to allow for + // pending output streams that have fewer frames than the codec latency. + private static final int MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT = 10; + /** + * Scale factor for the initial maximum input size used to configure the codec in non-adaptive + * playbacks. See {@link #getCodecMaxValues(MediaCodecInfo, Format, Format[])}. + */ + private static final float INITIAL_FORMAT_MAX_INPUT_SIZE_SCALE_FACTOR = 1.5f; + + /** Magic frame render timestamp that indicates the EOS in tunneling mode. */ + private static final long TUNNELING_EOS_PRESENTATION_TIME_US = Long.MAX_VALUE; + + /** A {@link DecoderException} with additional surface information. */ + public static final class VideoDecoderException extends DecoderException { + + /** The {@link System#identityHashCode(Object)} of the surface when the exception occurred. */ + public final int surfaceIdentityHashCode; + + /** Whether the surface was valid when the exception occurred. */ + public final boolean isSurfaceValid; + + public VideoDecoderException( + Throwable cause, @Nullable MediaCodecInfo codecInfo, @Nullable Surface surface) { + super(cause, codecInfo); + surfaceIdentityHashCode = System.identityHashCode(surface); + isSurfaceValid = surface == null || surface.isValid(); + } + } + + private static boolean evaluatedDeviceNeedsSetOutputSurfaceWorkaround; + private static boolean deviceNeedsSetOutputSurfaceWorkaround; + + private final Context context; + private final VideoFrameReleaseTimeHelper frameReleaseTimeHelper; + private final EventDispatcher eventDispatcher; + private final long allowedJoiningTimeMs; + private final int maxDroppedFramesToNotify; + private final boolean deviceNeedsNoPostProcessWorkaround; + private final long[] pendingOutputStreamOffsetsUs; + private final long[] pendingOutputStreamSwitchTimesUs; + + private CodecMaxValues codecMaxValues; + private boolean codecNeedsSetOutputSurfaceWorkaround; + private boolean codecHandlesHdr10PlusOutOfBandMetadata; + + private Surface surface; + private Surface dummySurface; + @C.VideoScalingMode + private int scalingMode; + private boolean renderedFirstFrame; + private long initialPositionUs; + private long joiningDeadlineMs; + private long droppedFrameAccumulationStartTimeMs; + private int droppedFrames; + private int consecutiveDroppedFrameCount; + private int buffersInCodecCount; + private long lastRenderTimeUs; + + private int pendingRotationDegrees; + private float pendingPixelWidthHeightRatio; + @Nullable private MediaFormat currentMediaFormat; + private int currentWidth; + private int currentHeight; + private int currentUnappliedRotationDegrees; + private float currentPixelWidthHeightRatio; + private int reportedWidth; + private int reportedHeight; + private int reportedUnappliedRotationDegrees; + private float reportedPixelWidthHeightRatio; + + private boolean tunneling; + private int tunnelingAudioSessionId; + /* package */ @Nullable OnFrameRenderedListenerV23 tunnelingOnFrameRenderedListener; + + private long lastInputTimeUs; + private long outputStreamOffsetUs; + private int pendingOutputStreamOffsetCount; + @Nullable private VideoFrameMetadataListener frameMetadataListener; + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + */ + public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector) { + this(context, mediaCodecSelector, 0); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + */ + public MediaCodecVideoRenderer(Context context, MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs) { + this( + context, + mediaCodecSelector, + allowedJoiningTimeMs, + /* eventHandler= */ null, + /* eventListener= */ null, + /* maxDroppedFramesToNotify= */ -1); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between + * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. + */ + @SuppressWarnings("deprecation") + public MediaCodecVideoRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify) { + this( + context, + mediaCodecSelector, + allowedJoiningTimeMs, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + eventHandler, + eventListener, + maxDroppedFramesToNotify); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between + * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. + * @deprecated Use {@link #MediaCodecVideoRenderer(Context, MediaCodecSelector, long, boolean, + * Handler, VideoRendererEventListener, int)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + @SuppressWarnings("deprecation") + public MediaCodecVideoRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify) { + this( + context, + mediaCodecSelector, + allowedJoiningTimeMs, + drmSessionManager, + playClearSamplesWithoutKeys, + /* enableDecoderFallback= */ false, + eventHandler, + eventListener, + maxDroppedFramesToNotify); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is slower/less efficient than + * the primary decoder. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between + * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. + */ + @SuppressWarnings("deprecation") + public MediaCodecVideoRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify) { + this( + context, + mediaCodecSelector, + allowedJoiningTimeMs, + /* drmSessionManager= */ null, + /* playClearSamplesWithoutKeys= */ false, + enableDecoderFallback, + eventHandler, + eventListener, + maxDroppedFramesToNotify); + } + + /** + * @param context A context. + * @param mediaCodecSelector A decoder selector. + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + * @param drmSessionManager For use with encrypted content. May be null if support for encrypted + * content is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + * @param enableDecoderFallback Whether to enable fallback to lower-priority decoders if decoder + * initialization fails. This may result in using a decoder that is slower/less efficient than + * the primary decoder. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between + * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. + * @deprecated Use {@link #MediaCodecVideoRenderer(Context, MediaCodecSelector, long, boolean, + * Handler, VideoRendererEventListener, int)} instead, and pass DRM-related parameters to the + * {@link MediaSource} factories. + */ + @Deprecated + public MediaCodecVideoRenderer( + Context context, + MediaCodecSelector mediaCodecSelector, + long allowedJoiningTimeMs, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys, + boolean enableDecoderFallback, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify) { + super( + C.TRACK_TYPE_VIDEO, + mediaCodecSelector, + drmSessionManager, + playClearSamplesWithoutKeys, + enableDecoderFallback, + /* assumedMinimumCodecOperatingRate= */ 30); + this.allowedJoiningTimeMs = allowedJoiningTimeMs; + this.maxDroppedFramesToNotify = maxDroppedFramesToNotify; + this.context = context.getApplicationContext(); + frameReleaseTimeHelper = new VideoFrameReleaseTimeHelper(this.context); + eventDispatcher = new EventDispatcher(eventHandler, eventListener); + deviceNeedsNoPostProcessWorkaround = deviceNeedsNoPostProcessWorkaround(); + pendingOutputStreamOffsetsUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; + pendingOutputStreamSwitchTimesUs = new long[MAX_PENDING_OUTPUT_STREAM_OFFSET_COUNT]; + outputStreamOffsetUs = C.TIME_UNSET; + lastInputTimeUs = C.TIME_UNSET; + joiningDeadlineMs = C.TIME_UNSET; + currentWidth = Format.NO_VALUE; + currentHeight = Format.NO_VALUE; + currentPixelWidthHeightRatio = Format.NO_VALUE; + pendingPixelWidthHeightRatio = Format.NO_VALUE; + scalingMode = C.VIDEO_SCALING_MODE_DEFAULT; + clearReportedVideoSize(); + } + + @Override + @Capabilities + protected int supportsFormat( + MediaCodecSelector mediaCodecSelector, + @Nullable DrmSessionManager<FrameworkMediaCrypto> drmSessionManager, + Format format) + throws DecoderQueryException { + String mimeType = format.sampleMimeType; + if (!MimeTypes.isVideo(mimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + @Nullable DrmInitData drmInitData = format.drmInitData; + // Assume encrypted content requires secure decoders. + boolean requiresSecureDecryption = drmInitData != null; + List<MediaCodecInfo> decoderInfos = + getDecoderInfos( + mediaCodecSelector, + format, + requiresSecureDecryption, + /* requiresTunnelingDecoder= */ false); + if (requiresSecureDecryption && decoderInfos.isEmpty()) { + // No secure decoders are available. Fall back to non-secure decoders. + decoderInfos = + getDecoderInfos( + mediaCodecSelector, + format, + /* requiresSecureDecoder= */ false, + /* requiresTunnelingDecoder= */ false); + } + if (decoderInfos.isEmpty()) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + boolean supportsFormatDrm = + drmInitData == null + || FrameworkMediaCrypto.class.equals(format.exoMediaCryptoType) + || (format.exoMediaCryptoType == null + && supportsFormatDrm(drmSessionManager, drmInitData)); + if (!supportsFormatDrm) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_DRM); + } + // Check capabilities for the first decoder in the list, which takes priority. + MediaCodecInfo decoderInfo = decoderInfos.get(0); + boolean isFormatSupported = decoderInfo.isFormatSupported(format); + @AdaptiveSupport + int adaptiveSupport = + decoderInfo.isSeamlessAdaptationSupported(format) + ? ADAPTIVE_SEAMLESS + : ADAPTIVE_NOT_SEAMLESS; + @TunnelingSupport int tunnelingSupport = TUNNELING_NOT_SUPPORTED; + if (isFormatSupported) { + List<MediaCodecInfo> tunnelingDecoderInfos = + getDecoderInfos( + mediaCodecSelector, + format, + requiresSecureDecryption, + /* requiresTunnelingDecoder= */ true); + if (!tunnelingDecoderInfos.isEmpty()) { + MediaCodecInfo tunnelingDecoderInfo = tunnelingDecoderInfos.get(0); + if (tunnelingDecoderInfo.isFormatSupported(format) + && tunnelingDecoderInfo.isSeamlessAdaptationSupported(format)) { + tunnelingSupport = TUNNELING_SUPPORTED; + } + } + } + @FormatSupport + int formatSupport = isFormatSupported ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES; + return RendererCapabilities.create(formatSupport, adaptiveSupport, tunnelingSupport); + } + + @Override + protected List<MediaCodecInfo> getDecoderInfos( + MediaCodecSelector mediaCodecSelector, Format format, boolean requiresSecureDecoder) + throws DecoderQueryException { + return getDecoderInfos(mediaCodecSelector, format, requiresSecureDecoder, tunneling); + } + + private static List<MediaCodecInfo> getDecoderInfos( + MediaCodecSelector mediaCodecSelector, + Format format, + boolean requiresSecureDecoder, + boolean requiresTunnelingDecoder) + throws DecoderQueryException { + @Nullable String mimeType = format.sampleMimeType; + if (mimeType == null) { + return Collections.emptyList(); + } + List<MediaCodecInfo> decoderInfos = + mediaCodecSelector.getDecoderInfos( + mimeType, requiresSecureDecoder, requiresTunnelingDecoder); + decoderInfos = MediaCodecUtil.getDecoderInfosSortedByFormatSupport(decoderInfos, format); + if (MimeTypes.VIDEO_DOLBY_VISION.equals(mimeType)) { + // Fall back to H.264/AVC or H.265/HEVC for the relevant DV profiles. + @Nullable + Pair<Integer, Integer> codecProfileAndLevel = MediaCodecUtil.getCodecProfileAndLevel(format); + if (codecProfileAndLevel != null) { + int profile = codecProfileAndLevel.first; + if (profile == CodecProfileLevel.DolbyVisionProfileDvheDtr + || profile == CodecProfileLevel.DolbyVisionProfileDvheSt) { + decoderInfos.addAll( + mediaCodecSelector.getDecoderInfos( + MimeTypes.VIDEO_H265, requiresSecureDecoder, requiresTunnelingDecoder)); + } else if (profile == CodecProfileLevel.DolbyVisionProfileDvavSe) { + decoderInfos.addAll( + mediaCodecSelector.getDecoderInfos( + MimeTypes.VIDEO_H264, requiresSecureDecoder, requiresTunnelingDecoder)); + } + } + } + return Collections.unmodifiableList(decoderInfos); + } + + @Override + protected void onEnabled(boolean joining) throws ExoPlaybackException { + super.onEnabled(joining); + int oldTunnelingAudioSessionId = tunnelingAudioSessionId; + tunnelingAudioSessionId = getConfiguration().tunnelingAudioSessionId; + tunneling = tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET; + if (tunnelingAudioSessionId != oldTunnelingAudioSessionId) { + releaseCodec(); + } + eventDispatcher.enabled(decoderCounters); + frameReleaseTimeHelper.enable(); + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + if (outputStreamOffsetUs == C.TIME_UNSET) { + outputStreamOffsetUs = offsetUs; + } else { + if (pendingOutputStreamOffsetCount == pendingOutputStreamOffsetsUs.length) { + Log.w(TAG, "Too many stream changes, so dropping offset: " + + pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1]); + } else { + pendingOutputStreamOffsetCount++; + } + pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1] = offsetUs; + pendingOutputStreamSwitchTimesUs[pendingOutputStreamOffsetCount - 1] = lastInputTimeUs; + } + super.onStreamChanged(formats, offsetUs); + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + super.onPositionReset(positionUs, joining); + clearRenderedFirstFrame(); + initialPositionUs = C.TIME_UNSET; + consecutiveDroppedFrameCount = 0; + lastInputTimeUs = C.TIME_UNSET; + if (pendingOutputStreamOffsetCount != 0) { + outputStreamOffsetUs = pendingOutputStreamOffsetsUs[pendingOutputStreamOffsetCount - 1]; + pendingOutputStreamOffsetCount = 0; + } + if (joining) { + setJoiningDeadlineMs(); + } else { + joiningDeadlineMs = C.TIME_UNSET; + } + } + + @Override + public boolean isReady() { + if (super.isReady() && (renderedFirstFrame || (dummySurface != null && surface == dummySurface) + || getCodec() == null || tunneling)) { + // Ready. If we were joining then we've now joined, so clear the joining deadline. + joiningDeadlineMs = C.TIME_UNSET; + return true; + } else if (joiningDeadlineMs == C.TIME_UNSET) { + // Not joining. + return false; + } else if (SystemClock.elapsedRealtime() < joiningDeadlineMs) { + // Joining and still within the joining deadline. + return true; + } else { + // The joining deadline has been exceeded. Give up and clear the deadline. + joiningDeadlineMs = C.TIME_UNSET; + return false; + } + } + + @Override + protected void onStarted() { + super.onStarted(); + droppedFrames = 0; + droppedFrameAccumulationStartTimeMs = SystemClock.elapsedRealtime(); + lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + } + + @Override + protected void onStopped() { + joiningDeadlineMs = C.TIME_UNSET; + maybeNotifyDroppedFrames(); + super.onStopped(); + } + + @Override + protected void onDisabled() { + lastInputTimeUs = C.TIME_UNSET; + outputStreamOffsetUs = C.TIME_UNSET; + pendingOutputStreamOffsetCount = 0; + currentMediaFormat = null; + clearReportedVideoSize(); + clearRenderedFirstFrame(); + frameReleaseTimeHelper.disable(); + tunnelingOnFrameRenderedListener = null; + try { + super.onDisabled(); + } finally { + eventDispatcher.disabled(decoderCounters); + } + } + + @Override + protected void onReset() { + try { + super.onReset(); + } finally { + if (dummySurface != null) { + if (surface == dummySurface) { + surface = null; + } + dummySurface.release(); + dummySurface = null; + } + } + } + + @Override + public void handleMessage(int messageType, @Nullable Object message) throws ExoPlaybackException { + if (messageType == C.MSG_SET_SURFACE) { + setSurface((Surface) message); + } else if (messageType == C.MSG_SET_SCALING_MODE) { + scalingMode = (Integer) message; + MediaCodec codec = getCodec(); + if (codec != null) { + codec.setVideoScalingMode(scalingMode); + } + } else if (messageType == C.MSG_SET_VIDEO_FRAME_METADATA_LISTENER) { + frameMetadataListener = (VideoFrameMetadataListener) message; + } else { + super.handleMessage(messageType, message); + } + } + + private void setSurface(Surface surface) throws ExoPlaybackException { + if (surface == null) { + // Use a dummy surface if possible. + if (dummySurface != null) { + surface = dummySurface; + } else { + MediaCodecInfo codecInfo = getCodecInfo(); + if (codecInfo != null && shouldUseDummySurface(codecInfo)) { + dummySurface = DummySurface.newInstanceV17(context, codecInfo.secure); + surface = dummySurface; + } + } + } + // We only need to update the codec if the surface has changed. + if (this.surface != surface) { + this.surface = surface; + @State int state = getState(); + MediaCodec codec = getCodec(); + if (codec != null) { + if (Util.SDK_INT >= 23 && surface != null && !codecNeedsSetOutputSurfaceWorkaround) { + setOutputSurfaceV23(codec, surface); + } else { + releaseCodec(); + maybeInitCodec(); + } + } + if (surface != null && surface != dummySurface) { + // If we know the video size, report it again immediately. + maybeRenotifyVideoSizeChanged(); + // We haven't rendered to the new surface yet. + clearRenderedFirstFrame(); + if (state == STATE_STARTED) { + setJoiningDeadlineMs(); + } + } else { + // The surface has been removed. + clearReportedVideoSize(); + clearRenderedFirstFrame(); + } + } else if (surface != null && surface != dummySurface) { + // The surface is set and unchanged. If we know the video size and/or have already rendered to + // the surface, report these again immediately. + maybeRenotifyVideoSizeChanged(); + maybeRenotifyRenderedFirstFrame(); + } + } + + @Override + protected boolean shouldInitCodec(MediaCodecInfo codecInfo) { + return surface != null || shouldUseDummySurface(codecInfo); + } + + @Override + protected boolean getCodecNeedsEosPropagation() { + // Since API 23, onFrameRenderedListener allows for detection of the renderer EOS. + return tunneling && Util.SDK_INT < 23; + } + + @Override + protected void configureCodec( + MediaCodecInfo codecInfo, + MediaCodec codec, + Format format, + @Nullable MediaCrypto crypto, + float codecOperatingRate) { + String codecMimeType = codecInfo.codecMimeType; + codecMaxValues = getCodecMaxValues(codecInfo, format, getStreamFormats()); + MediaFormat mediaFormat = + getMediaFormat( + format, + codecMimeType, + codecMaxValues, + codecOperatingRate, + deviceNeedsNoPostProcessWorkaround, + tunnelingAudioSessionId); + if (surface == null) { + Assertions.checkState(shouldUseDummySurface(codecInfo)); + if (dummySurface == null) { + dummySurface = DummySurface.newInstanceV17(context, codecInfo.secure); + } + surface = dummySurface; + } + codec.configure(mediaFormat, surface, crypto, 0); + if (Util.SDK_INT >= 23 && tunneling) { + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); + } + } + + @Override + protected @KeepCodecResult int canKeepCodec( + MediaCodec codec, MediaCodecInfo codecInfo, Format oldFormat, Format newFormat) { + if (codecInfo.isSeamlessAdaptationSupported( + oldFormat, newFormat, /* isNewFormatComplete= */ true) + && newFormat.width <= codecMaxValues.width + && newFormat.height <= codecMaxValues.height + && getMaxInputSize(codecInfo, newFormat) <= codecMaxValues.inputSize) { + return oldFormat.initializationDataEquals(newFormat) + ? KEEP_CODEC_RESULT_YES_WITHOUT_RECONFIGURATION + : KEEP_CODEC_RESULT_YES_WITH_RECONFIGURATION; + } + return KEEP_CODEC_RESULT_NO; + } + + @CallSuper + @Override + protected void releaseCodec() { + try { + super.releaseCodec(); + } finally { + buffersInCodecCount = 0; + } + } + + @CallSuper + @Override + protected boolean flushOrReleaseCodec() { + try { + return super.flushOrReleaseCodec(); + } finally { + buffersInCodecCount = 0; + } + } + + @Override + protected float getCodecOperatingRateV23( + float operatingRate, Format format, Format[] streamFormats) { + // Use the highest known stream frame-rate up front, to avoid having to reconfigure the codec + // should an adaptive switch to that stream occur. + float maxFrameRate = -1; + for (Format streamFormat : streamFormats) { + float streamFrameRate = streamFormat.frameRate; + if (streamFrameRate != Format.NO_VALUE) { + maxFrameRate = Math.max(maxFrameRate, streamFrameRate); + } + } + return maxFrameRate == -1 ? CODEC_OPERATING_RATE_UNSET : (maxFrameRate * operatingRate); + } + + @Override + protected void onCodecInitialized(String name, long initializedTimestampMs, + long initializationDurationMs) { + eventDispatcher.decoderInitialized(name, initializedTimestampMs, initializationDurationMs); + codecNeedsSetOutputSurfaceWorkaround = codecNeedsSetOutputSurfaceWorkaround(name); + codecHandlesHdr10PlusOutOfBandMetadata = + Assertions.checkNotNull(getCodecInfo()).isHdr10PlusOutOfBandMetadataSupported(); + } + + @Override + protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { + super.onInputFormatChanged(formatHolder); + Format newFormat = formatHolder.format; + eventDispatcher.inputFormatChanged(newFormat); + pendingPixelWidthHeightRatio = newFormat.pixelWidthHeightRatio; + pendingRotationDegrees = newFormat.rotationDegrees; + } + + /** + * Called immediately before an input buffer is queued into the codec. + * + * @param buffer The buffer to be queued. + */ + @CallSuper + @Override + protected void onQueueInputBuffer(DecoderInputBuffer buffer) { + // In tunneling mode the device may do frame rate conversion, so in general we can't keep track + // of the number of buffers in the codec. + if (!tunneling) { + buffersInCodecCount++; + } + lastInputTimeUs = Math.max(buffer.timeUs, lastInputTimeUs); + if (Util.SDK_INT < 23 && tunneling) { + // In tunneled mode before API 23 we don't have a way to know when the buffer is output, so + // treat it as if it were output immediately. + onProcessedTunneledBuffer(buffer.timeUs); + } + } + + @Override + protected void onOutputFormatChanged(MediaCodec codec, MediaFormat outputMediaFormat) { + currentMediaFormat = outputMediaFormat; + boolean hasCrop = + outputMediaFormat.containsKey(KEY_CROP_RIGHT) + && outputMediaFormat.containsKey(KEY_CROP_LEFT) + && outputMediaFormat.containsKey(KEY_CROP_BOTTOM) + && outputMediaFormat.containsKey(KEY_CROP_TOP); + int width = + hasCrop + ? outputMediaFormat.getInteger(KEY_CROP_RIGHT) + - outputMediaFormat.getInteger(KEY_CROP_LEFT) + + 1 + : outputMediaFormat.getInteger(MediaFormat.KEY_WIDTH); + int height = + hasCrop + ? outputMediaFormat.getInteger(KEY_CROP_BOTTOM) + - outputMediaFormat.getInteger(KEY_CROP_TOP) + + 1 + : outputMediaFormat.getInteger(MediaFormat.KEY_HEIGHT); + processOutputFormat(codec, width, height); + } + + @Override + protected void handleInputBufferSupplementalData(DecoderInputBuffer buffer) + throws ExoPlaybackException { + if (!codecHandlesHdr10PlusOutOfBandMetadata) { + return; + } + ByteBuffer data = Assertions.checkNotNull(buffer.supplementalData); + if (data.remaining() >= 7) { + // Check for HDR10+ out-of-band metadata. See User_data_registered_itu_t_t35 in ST 2094-40. + byte ituTT35CountryCode = data.get(); + int ituTT35TerminalProviderCode = data.getShort(); + int ituTT35TerminalProviderOrientedCode = data.getShort(); + byte applicationIdentifier = data.get(); + byte applicationVersion = data.get(); + data.position(0); + if (ituTT35CountryCode == (byte) 0xB5 + && ituTT35TerminalProviderCode == 0x003C + && ituTT35TerminalProviderOrientedCode == 0x0001 + && applicationIdentifier == 4 + && applicationVersion == 0) { + // The metadata size may vary so allocate a new array every time. This is not too + // inefficient because the metadata is only a few tens of bytes. + byte[] hdr10PlusInfo = new byte[data.remaining()]; + data.get(hdr10PlusInfo); + data.position(0); + // If codecHandlesHdr10PlusOutOfBandMetadata is true, this is an API 29 or later build. + setHdr10PlusInfoV29(getCodec(), hdr10PlusInfo); + } + } + } + + @Override + protected boolean processOutputBuffer( + long positionUs, + long elapsedRealtimeUs, + MediaCodec codec, + ByteBuffer buffer, + int bufferIndex, + int bufferFlags, + long bufferPresentationTimeUs, + boolean isDecodeOnlyBuffer, + boolean isLastBuffer, + Format format) + throws ExoPlaybackException { + if (initialPositionUs == C.TIME_UNSET) { + initialPositionUs = positionUs; + } + + long presentationTimeUs = bufferPresentationTimeUs - outputStreamOffsetUs; + + if (isDecodeOnlyBuffer && !isLastBuffer) { + skipOutputBuffer(codec, bufferIndex, presentationTimeUs); + return true; + } + + long earlyUs = bufferPresentationTimeUs - positionUs; + if (surface == dummySurface) { + // Skip frames in sync with playback, so we'll be at the right frame if the mode changes. + if (isBufferLate(earlyUs)) { + skipOutputBuffer(codec, bufferIndex, presentationTimeUs); + return true; + } + return false; + } + + long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000; + long elapsedSinceLastRenderUs = elapsedRealtimeNowUs - lastRenderTimeUs; + boolean isStarted = getState() == STATE_STARTED; + // Don't force output until we joined and the position reached the current stream. + boolean forceRenderOutputBuffer = + joiningDeadlineMs == C.TIME_UNSET + && positionUs >= outputStreamOffsetUs + && (!renderedFirstFrame + || (isStarted && shouldForceRenderOutputBuffer(earlyUs, elapsedSinceLastRenderUs))); + if (forceRenderOutputBuffer) { + long releaseTimeNs = System.nanoTime(); + notifyFrameMetadataListener(presentationTimeUs, releaseTimeNs, format, currentMediaFormat); + if (Util.SDK_INT >= 21) { + renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, releaseTimeNs); + } else { + renderOutputBuffer(codec, bufferIndex, presentationTimeUs); + } + return true; + } + + if (!isStarted || positionUs == initialPositionUs) { + return false; + } + + // Fine-grained adjustment of earlyUs based on the elapsed time since the start of the current + // iteration of the rendering loop. + long elapsedSinceStartOfLoopUs = elapsedRealtimeNowUs - elapsedRealtimeUs; + earlyUs -= elapsedSinceStartOfLoopUs; + + // Compute the buffer's desired release time in nanoseconds. + long systemTimeNs = System.nanoTime(); + long unadjustedFrameReleaseTimeNs = systemTimeNs + (earlyUs * 1000); + + // Apply a timestamp adjustment, if there is one. + long adjustedReleaseTimeNs = frameReleaseTimeHelper.adjustReleaseTime( + bufferPresentationTimeUs, unadjustedFrameReleaseTimeNs); + earlyUs = (adjustedReleaseTimeNs - systemTimeNs) / 1000; + + boolean treatDroppedBuffersAsSkipped = joiningDeadlineMs != C.TIME_UNSET; + if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs, isLastBuffer) + && maybeDropBuffersToKeyframe( + codec, bufferIndex, presentationTimeUs, positionUs, treatDroppedBuffersAsSkipped)) { + return false; + } else if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs, isLastBuffer)) { + if (treatDroppedBuffersAsSkipped) { + skipOutputBuffer(codec, bufferIndex, presentationTimeUs); + } else { + dropOutputBuffer(codec, bufferIndex, presentationTimeUs); + } + return true; + } + + if (Util.SDK_INT >= 21) { + // Let the underlying framework time the release. + if (earlyUs < 50000) { + notifyFrameMetadataListener( + presentationTimeUs, adjustedReleaseTimeNs, format, currentMediaFormat); + renderOutputBufferV21(codec, bufferIndex, presentationTimeUs, adjustedReleaseTimeNs); + return true; + } + } else { + // We need to time the release ourselves. + if (earlyUs < 30000) { + if (earlyUs > 11000) { + // We're a little too early to render the frame. Sleep until the frame can be rendered. + // Note: The 11ms threshold was chosen fairly arbitrarily. + try { + // Subtracting 10000 rather than 11000 ensures the sleep time will be at least 1ms. + Thread.sleep((earlyUs - 10000) / 1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + notifyFrameMetadataListener( + presentationTimeUs, adjustedReleaseTimeNs, format, currentMediaFormat); + renderOutputBuffer(codec, bufferIndex, presentationTimeUs); + return true; + } + } + + // We're either not playing, or it's not time to render the frame yet. + return false; + } + + private void processOutputFormat(MediaCodec codec, int width, int height) { + currentWidth = width; + currentHeight = height; + currentPixelWidthHeightRatio = pendingPixelWidthHeightRatio; + if (Util.SDK_INT >= 21) { + // On API level 21 and above the decoder applies the rotation when rendering to the surface. + // Hence currentUnappliedRotation should always be 0. For 90 and 270 degree rotations, we need + // to flip the width, height and pixel aspect ratio to reflect the rotation that was applied. + if (pendingRotationDegrees == 90 || pendingRotationDegrees == 270) { + int rotatedHeight = currentWidth; + currentWidth = currentHeight; + currentHeight = rotatedHeight; + currentPixelWidthHeightRatio = 1 / currentPixelWidthHeightRatio; + } + } else { + // On API level 20 and below the decoder does not apply the rotation. + currentUnappliedRotationDegrees = pendingRotationDegrees; + } + // Must be applied each time the output MediaFormat changes. + codec.setVideoScalingMode(scalingMode); + } + + private void notifyFrameMetadataListener( + long presentationTimeUs, long releaseTimeNs, Format format, MediaFormat mediaFormat) { + if (frameMetadataListener != null) { + frameMetadataListener.onVideoFrameAboutToBeRendered( + presentationTimeUs, releaseTimeNs, format, mediaFormat); + } + } + + /** + * Returns the offset that should be subtracted from {@code bufferPresentationTimeUs} in {@link + * #processOutputBuffer(long, long, MediaCodec, ByteBuffer, int, int, long, boolean, boolean, + * Format)} to get the playback position with respect to the media. + */ + protected long getOutputStreamOffsetUs() { + return outputStreamOffsetUs; + } + + /** Called when a buffer was processed in tunneling mode. */ + protected void onProcessedTunneledBuffer(long presentationTimeUs) { + @Nullable Format format = updateOutputFormatForTime(presentationTimeUs); + if (format != null) { + processOutputFormat(getCodec(), format.width, format.height); + } + maybeNotifyVideoSizeChanged(); + decoderCounters.renderedOutputBufferCount++; + maybeNotifyRenderedFirstFrame(); + onProcessedOutputBuffer(presentationTimeUs); + } + + /** Called when a output EOS was received in tunneling mode. */ + private void onProcessedTunneledEndOfStream() { + setPendingOutputEndOfStream(); + } + + /** + * Called when an output buffer is successfully processed. + * + * @param presentationTimeUs The timestamp associated with the output buffer. + */ + @CallSuper + @Override + protected void onProcessedOutputBuffer(long presentationTimeUs) { + if (!tunneling) { + buffersInCodecCount--; + } + while (pendingOutputStreamOffsetCount != 0 + && presentationTimeUs >= pendingOutputStreamSwitchTimesUs[0]) { + outputStreamOffsetUs = pendingOutputStreamOffsetsUs[0]; + pendingOutputStreamOffsetCount--; + System.arraycopy( + pendingOutputStreamOffsetsUs, + /* srcPos= */ 1, + pendingOutputStreamOffsetsUs, + /* destPos= */ 0, + pendingOutputStreamOffsetCount); + System.arraycopy( + pendingOutputStreamSwitchTimesUs, + /* srcPos= */ 1, + pendingOutputStreamSwitchTimesUs, + /* destPos= */ 0, + pendingOutputStreamOffsetCount); + clearRenderedFirstFrame(); + } + } + + /** + * Returns whether the buffer being processed should be dropped. + * + * @param earlyUs The time until the buffer should be presented in microseconds. A negative value + * indicates that the buffer is late. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + * @param isLastBuffer Whether the buffer is the last buffer in the current stream. + */ + protected boolean shouldDropOutputBuffer( + long earlyUs, long elapsedRealtimeUs, boolean isLastBuffer) { + return isBufferLate(earlyUs) && !isLastBuffer; + } + + /** + * Returns whether to drop all buffers from the buffer being processed to the keyframe at or after + * the current playback position, if possible. + * + * @param earlyUs The time until the current buffer should be presented in microseconds. A + * negative value indicates that the buffer is late. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + * @param isLastBuffer Whether the buffer is the last buffer in the current stream. + */ + protected boolean shouldDropBuffersToKeyframe( + long earlyUs, long elapsedRealtimeUs, boolean isLastBuffer) { + return isBufferVeryLate(earlyUs) && !isLastBuffer; + } + + /** + * Returns whether to force rendering an output buffer. + * + * @param earlyUs The time until the current buffer should be presented in microseconds. A + * negative value indicates that the buffer is late. + * @param elapsedSinceLastRenderUs The elapsed time since the last output buffer was rendered, in + * microseconds. + * @return Returns whether to force rendering an output buffer. + */ + protected boolean shouldForceRenderOutputBuffer(long earlyUs, long elapsedSinceLastRenderUs) { + // Force render late buffers every 100ms to avoid frozen video effect. + return isBufferLate(earlyUs) && elapsedSinceLastRenderUs > 100000; + } + + /** + * Skips the output buffer with the specified index. + * + * @param codec The codec that owns the output buffer. + * @param index The index of the output buffer to skip. + * @param presentationTimeUs The presentation time of the output buffer, in microseconds. + */ + protected void skipOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + TraceUtil.beginSection("skipVideoBuffer"); + codec.releaseOutputBuffer(index, false); + TraceUtil.endSection(); + decoderCounters.skippedOutputBufferCount++; + } + + /** + * Drops the output buffer with the specified index. + * + * @param codec The codec that owns the output buffer. + * @param index The index of the output buffer to drop. + * @param presentationTimeUs The presentation time of the output buffer, in microseconds. + */ + protected void dropOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + TraceUtil.beginSection("dropVideoBuffer"); + codec.releaseOutputBuffer(index, false); + TraceUtil.endSection(); + updateDroppedBufferCounters(1); + } + + /** + * Drops frames from the current output buffer to the next keyframe at or before the playback + * position. If no such keyframe exists, as the playback position is inside the same group of + * pictures as the buffer being processed, returns {@code false}. Returns {@code true} otherwise. + * + * @param codec The codec that owns the output buffer. + * @param index The index of the output buffer to drop. + * @param presentationTimeUs The presentation time of the output buffer, in microseconds. + * @param positionUs The current playback position, in microseconds. + * @param treatDroppedBuffersAsSkipped Whether dropped buffers should be treated as intentionally + * skipped. + * @return Whether any buffers were dropped. + * @throws ExoPlaybackException If an error occurs flushing the codec. + */ + protected boolean maybeDropBuffersToKeyframe( + MediaCodec codec, + int index, + long presentationTimeUs, + long positionUs, + boolean treatDroppedBuffersAsSkipped) + throws ExoPlaybackException { + int droppedSourceBufferCount = skipSource(positionUs); + if (droppedSourceBufferCount == 0) { + return false; + } + decoderCounters.droppedToKeyframeCount++; + // We dropped some buffers to catch up, so update the decoder counters and flush the codec, + // which releases all pending buffers buffers including the current output buffer. + int totalDroppedBufferCount = buffersInCodecCount + droppedSourceBufferCount; + if (treatDroppedBuffersAsSkipped) { + decoderCounters.skippedOutputBufferCount += totalDroppedBufferCount; + } else { + updateDroppedBufferCounters(totalDroppedBufferCount); + } + flushOrReinitializeCodec(); + return true; + } + + /** + * Updates decoder counters to reflect that {@code droppedBufferCount} additional buffers were + * dropped. + * + * @param droppedBufferCount The number of additional dropped buffers. + */ + protected void updateDroppedBufferCounters(int droppedBufferCount) { + decoderCounters.droppedBufferCount += droppedBufferCount; + droppedFrames += droppedBufferCount; + consecutiveDroppedFrameCount += droppedBufferCount; + decoderCounters.maxConsecutiveDroppedBufferCount = Math.max(consecutiveDroppedFrameCount, + decoderCounters.maxConsecutiveDroppedBufferCount); + if (maxDroppedFramesToNotify > 0 && droppedFrames >= maxDroppedFramesToNotify) { + maybeNotifyDroppedFrames(); + } + } + + /** + * Renders the output buffer with the specified index. This method is only called if the platform + * API version of the device is less than 21. + * + * @param codec The codec that owns the output buffer. + * @param index The index of the output buffer to drop. + * @param presentationTimeUs The presentation time of the output buffer, in microseconds. + */ + protected void renderOutputBuffer(MediaCodec codec, int index, long presentationTimeUs) { + maybeNotifyVideoSizeChanged(); + TraceUtil.beginSection("releaseOutputBuffer"); + codec.releaseOutputBuffer(index, true); + TraceUtil.endSection(); + lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + decoderCounters.renderedOutputBufferCount++; + consecutiveDroppedFrameCount = 0; + maybeNotifyRenderedFirstFrame(); + } + + /** + * Renders the output buffer with the specified index. This method is only called if the platform + * API version of the device is 21 or later. + * + * @param codec The codec that owns the output buffer. + * @param index The index of the output buffer to drop. + * @param presentationTimeUs The presentation time of the output buffer, in microseconds. + * @param releaseTimeNs The wallclock time at which the frame should be displayed, in nanoseconds. + */ + @TargetApi(21) + protected void renderOutputBufferV21( + MediaCodec codec, int index, long presentationTimeUs, long releaseTimeNs) { + maybeNotifyVideoSizeChanged(); + TraceUtil.beginSection("releaseOutputBuffer"); + codec.releaseOutputBuffer(index, releaseTimeNs); + TraceUtil.endSection(); + lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + decoderCounters.renderedOutputBufferCount++; + consecutiveDroppedFrameCount = 0; + maybeNotifyRenderedFirstFrame(); + } + + private boolean shouldUseDummySurface(MediaCodecInfo codecInfo) { + return Util.SDK_INT >= 23 + && !tunneling + && !codecNeedsSetOutputSurfaceWorkaround(codecInfo.name) + && (!codecInfo.secure || DummySurface.isSecureSupported(context)); + } + + private void setJoiningDeadlineMs() { + joiningDeadlineMs = allowedJoiningTimeMs > 0 + ? (SystemClock.elapsedRealtime() + allowedJoiningTimeMs) : C.TIME_UNSET; + } + + private void clearRenderedFirstFrame() { + renderedFirstFrame = false; + // The first frame notification is triggered by renderOutputBuffer or renderOutputBufferV21 for + // non-tunneled playback, onQueueInputBuffer for tunneled playback prior to API level 23, and + // OnFrameRenderedListenerV23.onFrameRenderedListener for tunneled playback on API level 23 and + // above. + if (Util.SDK_INT >= 23 && tunneling) { + MediaCodec codec = getCodec(); + // If codec is null then the listener will be instantiated in configureCodec. + if (codec != null) { + tunnelingOnFrameRenderedListener = new OnFrameRenderedListenerV23(codec); + } + } + } + + /* package */ void maybeNotifyRenderedFirstFrame() { + if (!renderedFirstFrame) { + renderedFirstFrame = true; + eventDispatcher.renderedFirstFrame(surface); + } + } + + private void maybeRenotifyRenderedFirstFrame() { + if (renderedFirstFrame) { + eventDispatcher.renderedFirstFrame(surface); + } + } + + private void clearReportedVideoSize() { + reportedWidth = Format.NO_VALUE; + reportedHeight = Format.NO_VALUE; + reportedPixelWidthHeightRatio = Format.NO_VALUE; + reportedUnappliedRotationDegrees = Format.NO_VALUE; + } + + private void maybeNotifyVideoSizeChanged() { + if ((currentWidth != Format.NO_VALUE || currentHeight != Format.NO_VALUE) + && (reportedWidth != currentWidth || reportedHeight != currentHeight + || reportedUnappliedRotationDegrees != currentUnappliedRotationDegrees + || reportedPixelWidthHeightRatio != currentPixelWidthHeightRatio)) { + eventDispatcher.videoSizeChanged(currentWidth, currentHeight, currentUnappliedRotationDegrees, + currentPixelWidthHeightRatio); + reportedWidth = currentWidth; + reportedHeight = currentHeight; + reportedUnappliedRotationDegrees = currentUnappliedRotationDegrees; + reportedPixelWidthHeightRatio = currentPixelWidthHeightRatio; + } + } + + private void maybeRenotifyVideoSizeChanged() { + if (reportedWidth != Format.NO_VALUE || reportedHeight != Format.NO_VALUE) { + eventDispatcher.videoSizeChanged(reportedWidth, reportedHeight, + reportedUnappliedRotationDegrees, reportedPixelWidthHeightRatio); + } + } + + private void maybeNotifyDroppedFrames() { + if (droppedFrames > 0) { + long now = SystemClock.elapsedRealtime(); + long elapsedMs = now - droppedFrameAccumulationStartTimeMs; + eventDispatcher.droppedFrames(droppedFrames, elapsedMs); + droppedFrames = 0; + droppedFrameAccumulationStartTimeMs = now; + } + } + + private static boolean isBufferLate(long earlyUs) { + // Class a buffer as late if it should have been presented more than 30 ms ago. + return earlyUs < -30000; + } + + private static boolean isBufferVeryLate(long earlyUs) { + // Class a buffer as very late if it should have been presented more than 500 ms ago. + return earlyUs < -500000; + } + + @TargetApi(29) + private static void setHdr10PlusInfoV29(MediaCodec codec, byte[] hdr10PlusInfo) { + Bundle codecParameters = new Bundle(); + codecParameters.putByteArray(MediaCodec.PARAMETER_KEY_HDR10_PLUS_INFO, hdr10PlusInfo); + codec.setParameters(codecParameters); + } + + @TargetApi(23) + private static void setOutputSurfaceV23(MediaCodec codec, Surface surface) { + codec.setOutputSurface(surface); + } + + @TargetApi(21) + private static void configureTunnelingV21(MediaFormat mediaFormat, int tunnelingAudioSessionId) { + mediaFormat.setFeatureEnabled(CodecCapabilities.FEATURE_TunneledPlayback, true); + mediaFormat.setInteger(MediaFormat.KEY_AUDIO_SESSION_ID, tunnelingAudioSessionId); + } + + /** + * Returns the framework {@link MediaFormat} that should be used to configure the decoder. + * + * @param format The {@link Format} of media. + * @param codecMimeType The MIME type handled by the codec. + * @param codecMaxValues Codec max values that should be used when configuring the decoder. + * @param codecOperatingRate The codec operating rate, or {@link #CODEC_OPERATING_RATE_UNSET} if + * no codec operating rate should be set. + * @param deviceNeedsNoPostProcessWorkaround Whether the device is known to do post processing by + * default that isn't compatible with ExoPlayer. + * @param tunnelingAudioSessionId The audio session id to use for tunneling, or {@link + * C#AUDIO_SESSION_ID_UNSET} if tunneling should not be enabled. + * @return The framework {@link MediaFormat} that should be used to configure the decoder. + */ + @SuppressLint("InlinedApi") + protected MediaFormat getMediaFormat( + Format format, + String codecMimeType, + CodecMaxValues codecMaxValues, + float codecOperatingRate, + boolean deviceNeedsNoPostProcessWorkaround, + int tunnelingAudioSessionId) { + MediaFormat mediaFormat = new MediaFormat(); + // Set format parameters that should always be set. + mediaFormat.setString(MediaFormat.KEY_MIME, codecMimeType); + mediaFormat.setInteger(MediaFormat.KEY_WIDTH, format.width); + mediaFormat.setInteger(MediaFormat.KEY_HEIGHT, format.height); + MediaFormatUtil.setCsdBuffers(mediaFormat, format.initializationData); + // Set format parameters that may be unset. + MediaFormatUtil.maybeSetFloat(mediaFormat, MediaFormat.KEY_FRAME_RATE, format.frameRate); + MediaFormatUtil.maybeSetInteger(mediaFormat, MediaFormat.KEY_ROTATION, format.rotationDegrees); + MediaFormatUtil.maybeSetColorInfo(mediaFormat, format.colorInfo); + if (MimeTypes.VIDEO_DOLBY_VISION.equals(format.sampleMimeType)) { + // Some phones require the profile to be set on the codec. + // See https://github.com/google/ExoPlayer/pull/5438. + Pair<Integer, Integer> codecProfileAndLevel = MediaCodecUtil.getCodecProfileAndLevel(format); + if (codecProfileAndLevel != null) { + MediaFormatUtil.maybeSetInteger( + mediaFormat, MediaFormat.KEY_PROFILE, codecProfileAndLevel.first); + } + } + // Set codec max values. + mediaFormat.setInteger(MediaFormat.KEY_MAX_WIDTH, codecMaxValues.width); + mediaFormat.setInteger(MediaFormat.KEY_MAX_HEIGHT, codecMaxValues.height); + MediaFormatUtil.maybeSetInteger( + mediaFormat, MediaFormat.KEY_MAX_INPUT_SIZE, codecMaxValues.inputSize); + // Set codec configuration values. + if (Util.SDK_INT >= 23) { + mediaFormat.setInteger(MediaFormat.KEY_PRIORITY, 0 /* realtime priority */); + if (codecOperatingRate != CODEC_OPERATING_RATE_UNSET) { + mediaFormat.setFloat(MediaFormat.KEY_OPERATING_RATE, codecOperatingRate); + } + } + if (deviceNeedsNoPostProcessWorkaround) { + mediaFormat.setInteger("no-post-process", 1); + mediaFormat.setInteger("auto-frc", 0); + } + if (tunnelingAudioSessionId != C.AUDIO_SESSION_ID_UNSET) { + configureTunnelingV21(mediaFormat, tunnelingAudioSessionId); + } + return mediaFormat; + } + + /** + * Returns {@link CodecMaxValues} suitable for configuring a codec for {@code format} in a way + * that will allow possible adaptation to other compatible formats in {@code streamFormats}. + * + * @param codecInfo Information about the {@link MediaCodec} being configured. + * @param format The {@link Format} for which the codec is being configured. + * @param streamFormats The possible stream formats. + * @return Suitable {@link CodecMaxValues}. + */ + protected CodecMaxValues getCodecMaxValues( + MediaCodecInfo codecInfo, Format format, Format[] streamFormats) { + int maxWidth = format.width; + int maxHeight = format.height; + int maxInputSize = getMaxInputSize(codecInfo, format); + if (streamFormats.length == 1) { + // The single entry in streamFormats must correspond to the format for which the codec is + // being configured. + if (maxInputSize != Format.NO_VALUE) { + int codecMaxInputSize = + getCodecMaxInputSize(codecInfo, format.sampleMimeType, format.width, format.height); + if (codecMaxInputSize != Format.NO_VALUE) { + // Scale up the initial video decoder maximum input size so playlist item transitions with + // small increases in maximum sample size don't require reinitialization. This only makes + // a difference if the exact maximum sample sizes are known from the container. + int scaledMaxInputSize = + (int) (maxInputSize * INITIAL_FORMAT_MAX_INPUT_SIZE_SCALE_FACTOR); + // Avoid exceeding the maximum expected for the codec. + maxInputSize = Math.min(scaledMaxInputSize, codecMaxInputSize); + } + } + return new CodecMaxValues(maxWidth, maxHeight, maxInputSize); + } + boolean haveUnknownDimensions = false; + for (Format streamFormat : streamFormats) { + if (codecInfo.isSeamlessAdaptationSupported( + format, streamFormat, /* isNewFormatComplete= */ false)) { + haveUnknownDimensions |= + (streamFormat.width == Format.NO_VALUE || streamFormat.height == Format.NO_VALUE); + maxWidth = Math.max(maxWidth, streamFormat.width); + maxHeight = Math.max(maxHeight, streamFormat.height); + maxInputSize = Math.max(maxInputSize, getMaxInputSize(codecInfo, streamFormat)); + } + } + if (haveUnknownDimensions) { + Log.w(TAG, "Resolutions unknown. Codec max resolution: " + maxWidth + "x" + maxHeight); + Point codecMaxSize = getCodecMaxSize(codecInfo, format); + if (codecMaxSize != null) { + maxWidth = Math.max(maxWidth, codecMaxSize.x); + maxHeight = Math.max(maxHeight, codecMaxSize.y); + maxInputSize = + Math.max( + maxInputSize, + getCodecMaxInputSize(codecInfo, format.sampleMimeType, maxWidth, maxHeight)); + Log.w(TAG, "Codec max resolution adjusted to: " + maxWidth + "x" + maxHeight); + } + } + return new CodecMaxValues(maxWidth, maxHeight, maxInputSize); + } + + @Override + protected DecoderException createDecoderException( + Throwable cause, @Nullable MediaCodecInfo codecInfo) { + return new VideoDecoderException(cause, codecInfo, surface); + } + + /** + * Returns a maximum video size to use when configuring a codec for {@code format} in a way that + * will allow possible adaptation to other compatible formats that are expected to have the same + * aspect ratio, but whose sizes are unknown. + * + * @param codecInfo Information about the {@link MediaCodec} being configured. + * @param format The {@link Format} for which the codec is being configured. + * @return The maximum video size to use, or null if the size of {@code format} should be used. + */ + private static Point getCodecMaxSize(MediaCodecInfo codecInfo, Format format) { + boolean isVerticalVideo = format.height > format.width; + int formatLongEdgePx = isVerticalVideo ? format.height : format.width; + int formatShortEdgePx = isVerticalVideo ? format.width : format.height; + float aspectRatio = (float) formatShortEdgePx / formatLongEdgePx; + for (int longEdgePx : STANDARD_LONG_EDGE_VIDEO_PX) { + int shortEdgePx = (int) (longEdgePx * aspectRatio); + if (longEdgePx <= formatLongEdgePx || shortEdgePx <= formatShortEdgePx) { + // Don't return a size not larger than the format for which the codec is being configured. + return null; + } else if (Util.SDK_INT >= 21) { + Point alignedSize = codecInfo.alignVideoSizeV21(isVerticalVideo ? shortEdgePx : longEdgePx, + isVerticalVideo ? longEdgePx : shortEdgePx); + float frameRate = format.frameRate; + if (codecInfo.isVideoSizeAndRateSupportedV21(alignedSize.x, alignedSize.y, frameRate)) { + return alignedSize; + } + } else { + try { + // Conservatively assume the codec requires 16px width and height alignment. + longEdgePx = Util.ceilDivide(longEdgePx, 16) * 16; + shortEdgePx = Util.ceilDivide(shortEdgePx, 16) * 16; + if (longEdgePx * shortEdgePx <= MediaCodecUtil.maxH264DecodableFrameSize()) { + return new Point( + isVerticalVideo ? shortEdgePx : longEdgePx, + isVerticalVideo ? longEdgePx : shortEdgePx); + } + } catch (DecoderQueryException e) { + // We tried our best. Give up! + return null; + } + } + } + return null; + } + + /** + * Returns a maximum input buffer size for a given {@link MediaCodec} and {@link Format}. + * + * @param codecInfo Information about the {@link MediaCodec} being configured. + * @param format The format. + * @return A maximum input buffer size in bytes, or {@link Format#NO_VALUE} if a maximum could not + * be determined. + */ + private static int getMaxInputSize(MediaCodecInfo codecInfo, Format format) { + if (format.maxInputSize != Format.NO_VALUE) { + // The format defines an explicit maximum input size. Add the total size of initialization + // data buffers, as they may need to be queued in the same input buffer as the largest sample. + int totalInitializationDataSize = 0; + int initializationDataCount = format.initializationData.size(); + for (int i = 0; i < initializationDataCount; i++) { + totalInitializationDataSize += format.initializationData.get(i).length; + } + return format.maxInputSize + totalInitializationDataSize; + } else { + // Calculated maximum input sizes are overestimates, so it's not necessary to add the size of + // initialization data. + return getCodecMaxInputSize(codecInfo, format.sampleMimeType, format.width, format.height); + } + } + + /** + * Returns a maximum input size for a given codec, MIME type, width and height. + * + * @param codecInfo Information about the {@link MediaCodec} being configured. + * @param sampleMimeType The format mime type. + * @param width The width in pixels. + * @param height The height in pixels. + * @return A maximum input size in bytes, or {@link Format#NO_VALUE} if a maximum could not be + * determined. + */ + private static int getCodecMaxInputSize( + MediaCodecInfo codecInfo, String sampleMimeType, int width, int height) { + if (width == Format.NO_VALUE || height == Format.NO_VALUE) { + // We can't infer a maximum input size without video dimensions. + return Format.NO_VALUE; + } + + // Attempt to infer a maximum input size from the format. + int maxPixels; + int minCompressionRatio; + switch (sampleMimeType) { + case MimeTypes.VIDEO_H263: + case MimeTypes.VIDEO_MP4V: + maxPixels = width * height; + minCompressionRatio = 2; + break; + case MimeTypes.VIDEO_H264: + if ("BRAVIA 4K 2015".equals(Util.MODEL) // Sony Bravia 4K + || ("Amazon".equals(Util.MANUFACTURER) + && ("KFSOWI".equals(Util.MODEL) // Kindle Soho + || ("AFTS".equals(Util.MODEL) && codecInfo.secure)))) { // Fire TV Gen 2 + // Use the default value for cases where platform limitations may prevent buffers of the + // calculated maximum input size from being allocated. + return Format.NO_VALUE; + } + // Round up width/height to an integer number of macroblocks. + maxPixels = Util.ceilDivide(width, 16) * Util.ceilDivide(height, 16) * 16 * 16; + minCompressionRatio = 2; + break; + case MimeTypes.VIDEO_VP8: + // VPX does not specify a ratio so use the values from the platform's SoftVPX.cpp. + maxPixels = width * height; + minCompressionRatio = 2; + break; + case MimeTypes.VIDEO_H265: + case MimeTypes.VIDEO_VP9: + maxPixels = width * height; + minCompressionRatio = 4; + 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); + } + + /** + * Returns whether the device is known to do post processing by default that isn't compatible with + * ExoPlayer. + * + * @return Whether the device is known to do post processing by default that isn't compatible with + * ExoPlayer. + */ + private static boolean deviceNeedsNoPostProcessWorkaround() { + // Nvidia devices prior to M try to adjust the playback rate to better map the frame-rate of + // content to the refresh rate of the display. For example playback of 23.976fps content is + // adjusted to play at 1.001x speed when the output display is 60Hz. Unfortunately the + // implementation causes ExoPlayer's reported playback position to drift out of sync. Captions + // also lose sync [Internal: b/26453592]. Even after M, the devices may apply post processing + // operations that can modify frame output timestamps, which is incompatible with ExoPlayer's + // logic for skipping decode-only frames. + return "NVIDIA".equals(Util.MANUFACTURER); + } + + /* + * TODO: + * + * 1. Validate that Android device certification now ensures correct behavior, and add a + * corresponding SDK_INT upper bound for applying the workaround (probably SDK_INT < 26). + * 2. Determine a complete list of affected devices. + * 3. Some of the devices in this list only fail to support setOutputSurface when switching from + * a SurfaceView provided Surface to a Surface of another type (e.g. TextureView/DummySurface), + * and vice versa. One hypothesis is that setOutputSurface fails when the surfaces have + * different pixel formats. If we can find a way to query the Surface instances to determine + * whether this case applies, then we'll be able to provide a more targeted workaround. + */ + /** + * Returns whether the codec is known to implement {@link MediaCodec#setOutputSurface(Surface)} + * incorrectly. + * + * <p>If true is returned then we fall back to releasing and re-instantiating the codec instead. + * + * @param name The name of the codec. + * @return True if the device is known to implement {@link MediaCodec#setOutputSurface(Surface)} + * incorrectly. + */ + protected boolean codecNeedsSetOutputSurfaceWorkaround(String name) { + if (name.startsWith("OMX.google")) { + // Google OMX decoders are not known to have this issue on any API level. + return false; + } + synchronized (MediaCodecVideoRenderer.class) { + if (!evaluatedDeviceNeedsSetOutputSurfaceWorkaround) { + if ("dangal".equals(Util.DEVICE)) { + // Workaround for MiTV devices: + // https://github.com/google/ExoPlayer/issues/5169, + // https://github.com/google/ExoPlayer/issues/6899. + deviceNeedsSetOutputSurfaceWorkaround = true; + } else if (Util.SDK_INT <= 27 && "HWEML".equals(Util.DEVICE)) { + // Workaround for Huawei P20: + // https://github.com/google/ExoPlayer/issues/4468#issuecomment-459291645. + deviceNeedsSetOutputSurfaceWorkaround = true; + } else if (Util.SDK_INT >= 27) { + // In general, devices running API level 27 or later should be unaffected. Do nothing. + } else { + // Enable the workaround on a per-device basis. Works around: + // https://github.com/google/ExoPlayer/issues/3236, + // https://github.com/google/ExoPlayer/issues/3355, + // https://github.com/google/ExoPlayer/issues/3439, + // https://github.com/google/ExoPlayer/issues/3724, + // https://github.com/google/ExoPlayer/issues/3835, + // https://github.com/google/ExoPlayer/issues/4006, + // https://github.com/google/ExoPlayer/issues/4084, + // https://github.com/google/ExoPlayer/issues/4104, + // https://github.com/google/ExoPlayer/issues/4134, + // https://github.com/google/ExoPlayer/issues/4315, + // https://github.com/google/ExoPlayer/issues/4419, + // https://github.com/google/ExoPlayer/issues/4460, + // https://github.com/google/ExoPlayer/issues/4468, + // https://github.com/google/ExoPlayer/issues/5312, + // https://github.com/google/ExoPlayer/issues/6503. + switch (Util.DEVICE) { + case "1601": + case "1713": + case "1714": + case "A10-70F": + case "A10-70L": + case "A1601": + case "A2016a40": + case "A7000-a": + case "A7000plus": + case "A7010a48": + case "A7020a48": + case "AquaPowerM": + case "ASUS_X00AD_2": + case "Aura_Note_2": + case "BLACK-1X": + case "BRAVIA_ATV2": + case "BRAVIA_ATV3_4K": + case "C1": + case "ComioS1": + case "CP8676_I02": + case "CPH1609": + case "CPY83_I00": + case "cv1": + case "cv3": + case "deb": + case "E5643": + case "ELUGA_A3_Pro": + case "ELUGA_Note": + case "ELUGA_Prim": + case "ELUGA_Ray_X": + case "EverStar_S": + case "F3111": + case "F3113": + case "F3116": + case "F3211": + case "F3213": + case "F3215": + case "F3311": + case "flo": + case "fugu": + case "GiONEE_CBL7513": + case "GiONEE_GBL7319": + case "GIONEE_GBL7360": + case "GIONEE_SWW1609": + case "GIONEE_SWW1627": + case "GIONEE_SWW1631": + case "GIONEE_WBL5708": + case "GIONEE_WBL7365": + case "GIONEE_WBL7519": + case "griffin": + case "htc_e56ml_dtul": + case "hwALE-H": + case "HWBLN-H": + case "HWCAM-H": + case "HWVNS-H": + case "HWWAS-H": + case "i9031": + case "iball8735_9806": + case "Infinix-X572": + case "iris60": + case "itel_S41": + case "j2xlteins": + case "JGZ": + case "K50a40": + case "kate": + case "l5460": + case "le_x6": + case "LS-5017": + case "M5c": + case "manning": + case "marino_f": + case "MEIZU_M5": + case "mh": + case "mido": + case "MX6": + case "namath": + case "nicklaus_f": + case "NX541J": + case "NX573J": + case "OnePlus5T": + case "p212": + case "P681": + case "P85": + case "panell_d": + case "panell_dl": + case "panell_ds": + case "panell_dt": + case "PB2-670M": + case "PGN528": + case "PGN610": + case "PGN611": + case "Phantom6": + case "Pixi4-7_3G": + case "Pixi5-10_4G": + case "PLE": + case "PRO7S": + case "Q350": + case "Q4260": + case "Q427": + case "Q4310": + case "Q5": + case "QM16XE_U": + case "QX1": + case "santoni": + case "Slate_Pro": + case "SVP-DTV15": + case "s905x018": + case "taido_row": + case "TB3-730F": + case "TB3-730X": + case "TB3-850F": + case "TB3-850M": + case "tcl_eu": + case "V1": + case "V23GB": + case "V5": + case "vernee_M5": + case "watson": + case "whyred": + case "woods_f": + case "woods_fn": + case "X3_HK": + case "XE2X": + case "XT1663": + case "Z12_PRO": + case "Z80": + deviceNeedsSetOutputSurfaceWorkaround = true; + break; + default: + // Do nothing. + break; + } + switch (Util.MODEL) { + case "AFTA": + case "AFTN": + case "JSN-L21": + deviceNeedsSetOutputSurfaceWorkaround = true; + break; + default: + // Do nothing. + break; + } + } + evaluatedDeviceNeedsSetOutputSurfaceWorkaround = true; + } + } + return deviceNeedsSetOutputSurfaceWorkaround; + } + + protected Surface getSurface() { + return surface; + } + + protected static final class CodecMaxValues { + + public final int width; + public final int height; + public final int inputSize; + + public CodecMaxValues(int width, int height, int inputSize) { + this.width = width; + this.height = height; + this.inputSize = inputSize; + } + + } + + @TargetApi(23) + private final class OnFrameRenderedListenerV23 + implements MediaCodec.OnFrameRenderedListener, Handler.Callback { + + private static final int HANDLE_FRAME_RENDERED = 0; + + private final Handler handler; + + public OnFrameRenderedListenerV23(MediaCodec codec) { + handler = new Handler(this); + codec.setOnFrameRenderedListener(/* listener= */ this, handler); + } + + @Override + public void onFrameRendered(MediaCodec codec, long presentationTimeUs, long nanoTime) { + // Workaround bug in MediaCodec that causes deadlock if you call directly back into the + // MediaCodec from this listener method. + // Deadlock occurs because MediaCodec calls this listener method holding a lock, + // which may also be required by calls made back into the MediaCodec. + // This was fixed in https://android-review.googlesource.com/1156807. + // + // The workaround queues the event for subsequent processing, where the lock will not be held. + if (Util.SDK_INT < 30) { + Message message = + Message.obtain( + handler, + /* what= */ HANDLE_FRAME_RENDERED, + /* arg1= */ (int) (presentationTimeUs >> 32), + /* arg2= */ (int) presentationTimeUs); + handler.sendMessageAtFrontOfQueue(message); + } else { + handleFrameRendered(presentationTimeUs); + } + } + + @Override + public boolean handleMessage(Message message) { + switch (message.what) { + case HANDLE_FRAME_RENDERED: + handleFrameRendered(Util.toLong(message.arg1, message.arg2)); + return true; + default: + return false; + } + } + + private void handleFrameRendered(long presentationTimeUs) { + if (this != tunnelingOnFrameRenderedListener) { + // Stale event. + return; + } + if (presentationTimeUs == TUNNELING_EOS_PRESENTATION_TIME_US) { + onProcessedTunneledEndOfStream(); + } else { + onProcessedTunneledBuffer(presentationTimeUs); + } + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java new file mode 100644 index 0000000000..fbcd4d959c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/SimpleDecoderVideoRenderer.java @@ -0,0 +1,975 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.os.Handler; +import android.os.SystemClock; +import android.view.Surface; +import androidx.annotation.CallSuper; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +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.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.SimpleDecoder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSession.DrmSessionException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.DrmSessionManager; +import org.mozilla.thirdparty.com.google.android.exoplayer2.drm.ExoMediaCrypto; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimedValueQueue; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TraceUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.VideoRendererEventListener.EventDispatcher; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** Decodes and renders video using a {@link SimpleDecoder}. */ +public abstract class SimpleDecoderVideoRenderer extends BaseRenderer { + + /** Decoder reinitialization states. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + REINITIALIZATION_STATE_NONE, + REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM, + REINITIALIZATION_STATE_WAIT_END_OF_STREAM + }) + private @interface ReinitializationState {} + /** The decoder does not need to be re-initialized. */ + private static final int REINITIALIZATION_STATE_NONE = 0; + /** + * The input format has changed in a way that requires the decoder to be re-initialized, but we + * haven't yet signaled an end of stream to the existing decoder. We need to do so in order to + * ensure that it outputs any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM = 1; + /** + * The input format has changed in a way that requires the decoder to be re-initialized, and we've + * signaled an end of stream to the existing decoder. We're waiting for the decoder to output an + * end of stream signal to indicate that it has output any remaining buffers before we release it. + */ + private static final int REINITIALIZATION_STATE_WAIT_END_OF_STREAM = 2; + + private final long allowedJoiningTimeMs; + private final int maxDroppedFramesToNotify; + private final boolean playClearSamplesWithoutKeys; + private final EventDispatcher eventDispatcher; + private final TimedValueQueue<Format> formatQueue; + private final DecoderInputBuffer flagsOnlyBuffer; + private final DrmSessionManager<ExoMediaCrypto> drmSessionManager; + + private boolean drmResourcesAcquired; + private Format inputFormat; + private Format outputFormat; + private SimpleDecoder< + VideoDecoderInputBuffer, + ? extends VideoDecoderOutputBuffer, + ? extends VideoDecoderException> + decoder; + private VideoDecoderInputBuffer inputBuffer; + private VideoDecoderOutputBuffer outputBuffer; + @Nullable private Surface surface; + @Nullable private VideoDecoderOutputBufferRenderer outputBufferRenderer; + @C.VideoOutputMode private int outputMode; + + @Nullable private DrmSession<ExoMediaCrypto> decoderDrmSession; + @Nullable private DrmSession<ExoMediaCrypto> sourceDrmSession; + + @ReinitializationState private int decoderReinitializationState; + private boolean decoderReceivedBuffers; + + private boolean renderedFirstFrame; + private long initialPositionUs; + private long joiningDeadlineMs; + private boolean waitingForKeys; + private boolean waitingForFirstSampleInFormat; + + private boolean inputStreamEnded; + private boolean outputStreamEnded; + private int reportedWidth; + private int reportedHeight; + + private long droppedFrameAccumulationStartTimeMs; + private int droppedFrames; + private int consecutiveDroppedFrameCount; + private int buffersInCodecCount; + private long lastRenderTimeUs; + private long outputStreamOffsetUs; + + /** Decoder event counters used for debugging purposes. */ + protected DecoderCounters decoderCounters; + + /** + * @param allowedJoiningTimeMs The maximum duration in milliseconds for which this video renderer + * can attempt to seamlessly join an ongoing playback. + * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be + * null if delivery of events is not required. + * @param eventListener A listener of events. May be null if delivery of events is not required. + * @param maxDroppedFramesToNotify The maximum number of frames that can be dropped between + * invocations of {@link VideoRendererEventListener#onDroppedFrames(int, long)}. + * @param drmSessionManager For use with encrypted media. May be null if support for encrypted + * media is not required. + * @param playClearSamplesWithoutKeys Encrypted media may contain clear (un-encrypted) regions. + * For example a media file may start with a short clear region so as to allow playback to + * begin in parallel with key acquisition. This parameter specifies whether the renderer is + * permitted to play clear regions of encrypted media files before {@code drmSessionManager} + * has obtained the keys necessary to decrypt encrypted regions of the media. + */ + protected SimpleDecoderVideoRenderer( + long allowedJoiningTimeMs, + @Nullable Handler eventHandler, + @Nullable VideoRendererEventListener eventListener, + int maxDroppedFramesToNotify, + @Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, + boolean playClearSamplesWithoutKeys) { + super(C.TRACK_TYPE_VIDEO); + this.allowedJoiningTimeMs = allowedJoiningTimeMs; + this.maxDroppedFramesToNotify = maxDroppedFramesToNotify; + this.drmSessionManager = drmSessionManager; + this.playClearSamplesWithoutKeys = playClearSamplesWithoutKeys; + joiningDeadlineMs = C.TIME_UNSET; + clearReportedVideoSize(); + formatQueue = new TimedValueQueue<>(); + flagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance(); + eventDispatcher = new EventDispatcher(eventHandler, eventListener); + decoderReinitializationState = REINITIALIZATION_STATE_NONE; + outputMode = C.VIDEO_OUTPUT_MODE_NONE; + } + + // BaseRenderer implementation. + + @Override + @Capabilities + public final int supportsFormat(Format format) { + return supportsFormatInternal(drmSessionManager, format); + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + if (outputStreamEnded) { + return; + } + + if (inputFormat == null) { + // We don't have a format yet, so try and read one. + FormatHolder formatHolder = getFormatHolder(); + flagsOnlyBuffer.clear(); + int result = readSource(formatHolder, flagsOnlyBuffer, true); + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(formatHolder); + } else if (result == C.RESULT_BUFFER_READ) { + // End of stream read having not read a format. + Assertions.checkState(flagsOnlyBuffer.isEndOfStream()); + inputStreamEnded = true; + outputStreamEnded = true; + return; + } else { + // We still don't have a format and can't make progress without one. + return; + } + } + + // If we don't have a decoder yet, we need to instantiate one. + maybeInitDecoder(); + + if (decoder != null) { + try { + // Rendering loop. + TraceUtil.beginSection("drainAndFeed"); + while (drainOutputBuffer(positionUs, elapsedRealtimeUs)) {} + while (feedInputBuffer()) {} + TraceUtil.endSection(); + } catch (VideoDecoderException e) { + throw createRendererException(e, inputFormat); + } + decoderCounters.ensureUpdated(); + } + } + + @Override + public boolean isEnded() { + return outputStreamEnded; + } + + @Override + public boolean isReady() { + if (waitingForKeys) { + return false; + } + if (inputFormat != null + && (isSourceReady() || outputBuffer != null) + && (renderedFirstFrame || !hasOutput())) { + // Ready. If we were joining then we've now joined, so clear the joining deadline. + joiningDeadlineMs = C.TIME_UNSET; + return true; + } else if (joiningDeadlineMs == C.TIME_UNSET) { + // Not joining. + return false; + } else if (SystemClock.elapsedRealtime() < joiningDeadlineMs) { + // Joining and still within the joining deadline. + return true; + } else { + // The joining deadline has been exceeded. Give up and clear the deadline. + joiningDeadlineMs = C.TIME_UNSET; + return false; + } + } + + // Protected methods. + + @Override + protected void onEnabled(boolean joining) throws ExoPlaybackException { + if (drmSessionManager != null && !drmResourcesAcquired) { + drmResourcesAcquired = true; + drmSessionManager.prepare(); + } + decoderCounters = new DecoderCounters(); + eventDispatcher.enabled(decoderCounters); + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + inputStreamEnded = false; + outputStreamEnded = false; + clearRenderedFirstFrame(); + initialPositionUs = C.TIME_UNSET; + consecutiveDroppedFrameCount = 0; + if (decoder != null) { + flushDecoder(); + } + if (joining) { + setJoiningDeadlineMs(); + } else { + joiningDeadlineMs = C.TIME_UNSET; + } + formatQueue.clear(); + } + + @Override + protected void onStarted() { + droppedFrames = 0; + droppedFrameAccumulationStartTimeMs = SystemClock.elapsedRealtime(); + lastRenderTimeUs = SystemClock.elapsedRealtime() * 1000; + } + + @Override + protected void onStopped() { + joiningDeadlineMs = C.TIME_UNSET; + maybeNotifyDroppedFrames(); + } + + @Override + protected void onDisabled() { + inputFormat = null; + waitingForKeys = false; + clearReportedVideoSize(); + clearRenderedFirstFrame(); + try { + setSourceDrmSession(null); + releaseDecoder(); + } finally { + eventDispatcher.disabled(decoderCounters); + } + } + + @Override + protected void onReset() { + if (drmSessionManager != null && drmResourcesAcquired) { + drmResourcesAcquired = false; + drmSessionManager.release(); + } + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + outputStreamOffsetUs = offsetUs; + super.onStreamChanged(formats, offsetUs); + } + + /** + * Called when a decoder has been created and configured. + * + * <p>The default implementation is a no-op. + * + * @param name The name of the decoder that was initialized. + * @param initializedTimestampMs {@link SystemClock#elapsedRealtime()} when initialization + * finished. + * @param initializationDurationMs The time taken to initialize the decoder, in milliseconds. + */ + @CallSuper + protected void onDecoderInitialized( + String name, long initializedTimestampMs, long initializationDurationMs) { + eventDispatcher.decoderInitialized(name, initializedTimestampMs, initializationDurationMs); + } + + /** + * Flushes the decoder. + * + * @throws ExoPlaybackException If an error occurs reinitializing a decoder. + */ + @CallSuper + protected void flushDecoder() throws ExoPlaybackException { + waitingForKeys = false; + buffersInCodecCount = 0; + if (decoderReinitializationState != REINITIALIZATION_STATE_NONE) { + releaseDecoder(); + maybeInitDecoder(); + } else { + inputBuffer = null; + if (outputBuffer != null) { + outputBuffer.release(); + outputBuffer = null; + } + decoder.flush(); + decoderReceivedBuffers = false; + } + } + + /** Releases the decoder. */ + @CallSuper + protected void releaseDecoder() { + inputBuffer = null; + outputBuffer = null; + decoderReinitializationState = REINITIALIZATION_STATE_NONE; + decoderReceivedBuffers = false; + buffersInCodecCount = 0; + if (decoder != null) { + decoder.release(); + decoder = null; + decoderCounters.decoderReleaseCount++; + } + setDecoderDrmSession(null); + } + + /** + * Called when a new format is read from the upstream source. + * + * @param formatHolder A {@link FormatHolder} that holds the new {@link Format}. + * @throws ExoPlaybackException If an error occurs (re-)initializing the decoder. + */ + @CallSuper + @SuppressWarnings("unchecked") + protected void onInputFormatChanged(FormatHolder formatHolder) throws ExoPlaybackException { + waitingForFirstSampleInFormat = true; + Format newFormat = Assertions.checkNotNull(formatHolder.format); + if (formatHolder.includesDrmSession) { + setSourceDrmSession((DrmSession<ExoMediaCrypto>) formatHolder.drmSession); + } else { + sourceDrmSession = + getUpdatedSourceDrmSession(inputFormat, newFormat, drmSessionManager, sourceDrmSession); + } + inputFormat = newFormat; + + if (sourceDrmSession != decoderDrmSession) { + if (decoderReceivedBuffers) { + // Signal end of stream and wait for any final output buffers before re-initialization. + decoderReinitializationState = REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM; + } else { + // There aren't any final output buffers, so release the decoder immediately. + releaseDecoder(); + maybeInitDecoder(); + } + } + + eventDispatcher.inputFormatChanged(inputFormat); + } + + /** + * Called immediately before an input buffer is queued into the decoder. + * + * <p>The default implementation is a no-op. + * + * @param buffer The buffer that will be queued. + */ + protected void onQueueInputBuffer(VideoDecoderInputBuffer buffer) { + // Do nothing. + } + + /** + * Called when an output buffer is successfully processed. + * + * @param presentationTimeUs The timestamp associated with the output buffer. + */ + @CallSuper + protected void onProcessedOutputBuffer(long presentationTimeUs) { + buffersInCodecCount--; + } + + /** + * Returns whether the buffer being processed should be dropped. + * + * @param earlyUs The time until the buffer should be presented in microseconds. A negative value + * indicates that the buffer is late. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + */ + protected boolean shouldDropOutputBuffer(long earlyUs, long elapsedRealtimeUs) { + return isBufferLate(earlyUs); + } + + /** + * Returns whether to drop all buffers from the buffer being processed to the keyframe at or after + * the current playback position, if possible. + * + * @param earlyUs The time until the current buffer should be presented in microseconds. A + * negative value indicates that the buffer is late. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + */ + protected boolean shouldDropBuffersToKeyframe(long earlyUs, long elapsedRealtimeUs) { + return isBufferVeryLate(earlyUs); + } + + /** + * Returns whether to force rendering an output buffer. + * + * @param earlyUs The time until the current buffer should be presented in microseconds. A + * negative value indicates that the buffer is late. + * @param elapsedSinceLastRenderUs The elapsed time since the last output buffer was rendered, in + * microseconds. + * @return Returns whether to force rendering an output buffer. + */ + protected boolean shouldForceRenderOutputBuffer(long earlyUs, long elapsedSinceLastRenderUs) { + return isBufferLate(earlyUs) && elapsedSinceLastRenderUs > 100000; + } + + /** + * Skips the specified output buffer and releases it. + * + * @param outputBuffer The output buffer to skip. + */ + protected void skipOutputBuffer(VideoDecoderOutputBuffer outputBuffer) { + decoderCounters.skippedOutputBufferCount++; + outputBuffer.release(); + } + + /** + * Drops the specified output buffer and releases it. + * + * @param outputBuffer The output buffer to drop. + */ + protected void dropOutputBuffer(VideoDecoderOutputBuffer outputBuffer) { + updateDroppedBufferCounters(1); + outputBuffer.release(); + } + + /** + * Drops frames from the current output buffer to the next keyframe at or before the playback + * position. If no such keyframe exists, as the playback position is inside the same group of + * pictures as the buffer being processed, returns {@code false}. Returns {@code true} otherwise. + * + * @param positionUs The current playback position, in microseconds. + * @return Whether any buffers were dropped. + * @throws ExoPlaybackException If an error occurs flushing the decoder. + */ + protected boolean maybeDropBuffersToKeyframe(long positionUs) throws ExoPlaybackException { + int droppedSourceBufferCount = skipSource(positionUs); + if (droppedSourceBufferCount == 0) { + return false; + } + decoderCounters.droppedToKeyframeCount++; + // We dropped some buffers to catch up, so update the decoder counters and flush the decoder, + // which releases all pending buffers buffers including the current output buffer. + updateDroppedBufferCounters(buffersInCodecCount + droppedSourceBufferCount); + flushDecoder(); + return true; + } + + /** + * Updates decoder counters to reflect that {@code droppedBufferCount} additional buffers were + * dropped. + * + * @param droppedBufferCount The number of additional dropped buffers. + */ + protected void updateDroppedBufferCounters(int droppedBufferCount) { + decoderCounters.droppedBufferCount += droppedBufferCount; + droppedFrames += droppedBufferCount; + consecutiveDroppedFrameCount += droppedBufferCount; + decoderCounters.maxConsecutiveDroppedBufferCount = + Math.max(consecutiveDroppedFrameCount, decoderCounters.maxConsecutiveDroppedBufferCount); + if (maxDroppedFramesToNotify > 0 && droppedFrames >= maxDroppedFramesToNotify) { + maybeNotifyDroppedFrames(); + } + } + + /** + * Returns the {@link Capabilities} for the given {@link Format}. + * + * @param drmSessionManager The renderer's {@link DrmSessionManager}. + * @param format The format, which has a video {@link Format#sampleMimeType}. + * @return The {@link Capabilities} for this {@link Format}. + * @see RendererCapabilities#supportsFormat(Format) + */ + @Capabilities + protected abstract int supportsFormatInternal( + @Nullable DrmSessionManager<ExoMediaCrypto> drmSessionManager, Format format); + + /** + * Creates a decoder for the given format. + * + * @param format The format for which a decoder is required. + * @param mediaCrypto The {@link ExoMediaCrypto} object required for decoding encrypted content. + * May be null and can be ignored if decoder does not handle encrypted content. + * @return The decoder. + * @throws VideoDecoderException If an error occurred creating a suitable decoder. + */ + protected abstract SimpleDecoder< + VideoDecoderInputBuffer, + ? extends VideoDecoderOutputBuffer, + ? extends VideoDecoderException> + createDecoder(Format format, @Nullable ExoMediaCrypto mediaCrypto) + throws VideoDecoderException; + + /** + * Renders the specified output buffer. + * + * <p>The implementation of this method takes ownership of the output buffer and is responsible + * for calling {@link VideoDecoderOutputBuffer#release()} either immediately or in the future. + * + * @param outputBuffer {@link VideoDecoderOutputBuffer} to render. + * @param presentationTimeUs Presentation time in microseconds. + * @param outputFormat Output {@link Format}. + * @throws VideoDecoderException If an error occurs when rendering the output buffer. + */ + protected void renderOutputBuffer( + VideoDecoderOutputBuffer outputBuffer, long presentationTimeUs, Format outputFormat) + throws VideoDecoderException { + lastRenderTimeUs = C.msToUs(SystemClock.elapsedRealtime() * 1000); + int bufferMode = outputBuffer.mode; + boolean renderSurface = bufferMode == C.VIDEO_OUTPUT_MODE_SURFACE_YUV && surface != null; + boolean renderYuv = bufferMode == C.VIDEO_OUTPUT_MODE_YUV && outputBufferRenderer != null; + if (!renderYuv && !renderSurface) { + dropOutputBuffer(outputBuffer); + } else { + maybeNotifyVideoSizeChanged(outputBuffer.width, outputBuffer.height); + if (renderYuv) { + outputBufferRenderer.setOutputBuffer(outputBuffer); + } else { + renderOutputBufferToSurface(outputBuffer, surface); + } + consecutiveDroppedFrameCount = 0; + decoderCounters.renderedOutputBufferCount++; + maybeNotifyRenderedFirstFrame(); + } + } + + /** + * Renders the specified output buffer to the passed surface. + * + * <p>The implementation of this method takes ownership of the output buffer and is responsible + * for calling {@link VideoDecoderOutputBuffer#release()} either immediately or in the future. + * + * @param outputBuffer {@link VideoDecoderOutputBuffer} to render. + * @param surface Output {@link Surface}. + * @throws VideoDecoderException If an error occurs when rendering the output buffer. + */ + protected abstract void renderOutputBufferToSurface( + VideoDecoderOutputBuffer outputBuffer, Surface surface) throws VideoDecoderException; + + /** + * Sets output surface. + * + * @param surface Surface. + */ + protected final void setOutputSurface(@Nullable Surface surface) { + if (this.surface != surface) { + // The output has changed. + this.surface = surface; + if (surface != null) { + outputBufferRenderer = null; + outputMode = C.VIDEO_OUTPUT_MODE_SURFACE_YUV; + if (decoder != null) { + setDecoderOutputMode(outputMode); + } + onOutputChanged(); + } else { + // The output has been removed. We leave the outputMode of the underlying decoder unchanged + // in anticipation that a subsequent output will likely be of the same type. + outputMode = C.VIDEO_OUTPUT_MODE_NONE; + onOutputRemoved(); + } + } else if (surface != null) { + // The output is unchanged and non-null. + onOutputReset(); + } + } + + /** + * Sets output buffer renderer. + * + * @param outputBufferRenderer Output buffer renderer. + */ + protected final void setOutputBufferRenderer( + @Nullable VideoDecoderOutputBufferRenderer outputBufferRenderer) { + if (this.outputBufferRenderer != outputBufferRenderer) { + // The output has changed. + this.outputBufferRenderer = outputBufferRenderer; + if (outputBufferRenderer != null) { + surface = null; + outputMode = C.VIDEO_OUTPUT_MODE_YUV; + if (decoder != null) { + setDecoderOutputMode(outputMode); + } + onOutputChanged(); + } else { + // The output has been removed. We leave the outputMode of the underlying decoder unchanged + // in anticipation that a subsequent output will likely be of the same type. + outputMode = C.VIDEO_OUTPUT_MODE_NONE; + onOutputRemoved(); + } + } else if (outputBufferRenderer != null) { + // The output is unchanged and non-null. + onOutputReset(); + } + } + + /** + * Sets output mode of the decoder. + * + * @param outputMode Output mode. + */ + protected abstract void setDecoderOutputMode(@C.VideoOutputMode int outputMode); + + // Internal methods. + + private void setSourceDrmSession(@Nullable DrmSession<ExoMediaCrypto> session) { + DrmSession.replaceSession(sourceDrmSession, session); + sourceDrmSession = session; + } + + private void setDecoderDrmSession(@Nullable DrmSession<ExoMediaCrypto> session) { + DrmSession.replaceSession(decoderDrmSession, session); + decoderDrmSession = session; + } + + private void maybeInitDecoder() throws ExoPlaybackException { + if (decoder != null) { + return; + } + + setDecoderDrmSession(sourceDrmSession); + + ExoMediaCrypto mediaCrypto = null; + if (decoderDrmSession != null) { + mediaCrypto = decoderDrmSession.getMediaCrypto(); + if (mediaCrypto == null) { + DrmSessionException drmError = decoderDrmSession.getError(); + if (drmError != null) { + // Continue for now. We may be able to avoid failure if the session recovers, or if a new + // input format causes the session to be replaced before it's used. + } else { + // The drm session isn't open yet. + return; + } + } + } + + try { + long decoderInitializingTimestamp = SystemClock.elapsedRealtime(); + decoder = createDecoder(inputFormat, mediaCrypto); + setDecoderOutputMode(outputMode); + long decoderInitializedTimestamp = SystemClock.elapsedRealtime(); + onDecoderInitialized( + decoder.getName(), + decoderInitializedTimestamp, + decoderInitializedTimestamp - decoderInitializingTimestamp); + decoderCounters.decoderInitCount++; + } catch (VideoDecoderException e) { + throw createRendererException(e, inputFormat); + } + } + + private boolean feedInputBuffer() throws VideoDecoderException, ExoPlaybackException { + if (decoder == null + || decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM + || inputStreamEnded) { + // We need to reinitialize the decoder or the input stream has ended. + return false; + } + + if (inputBuffer == null) { + inputBuffer = decoder.dequeueInputBuffer(); + if (inputBuffer == null) { + return false; + } + } + + if (decoderReinitializationState == REINITIALIZATION_STATE_SIGNAL_END_OF_STREAM) { + inputBuffer.setFlags(C.BUFFER_FLAG_END_OF_STREAM); + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + decoderReinitializationState = REINITIALIZATION_STATE_WAIT_END_OF_STREAM; + return false; + } + + int result; + FormatHolder formatHolder = getFormatHolder(); + if (waitingForKeys) { + // We've already read an encrypted sample into buffer, and are waiting for keys. + result = C.RESULT_BUFFER_READ; + } else { + result = readSource(formatHolder, inputBuffer, false); + } + + if (result == C.RESULT_NOTHING_READ) { + return false; + } + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(formatHolder); + return true; + } + if (inputBuffer.isEndOfStream()) { + inputStreamEnded = true; + decoder.queueInputBuffer(inputBuffer); + inputBuffer = null; + return false; + } + boolean bufferEncrypted = inputBuffer.isEncrypted(); + waitingForKeys = shouldWaitForKeys(bufferEncrypted); + if (waitingForKeys) { + return false; + } + if (waitingForFirstSampleInFormat) { + formatQueue.add(inputBuffer.timeUs, inputFormat); + waitingForFirstSampleInFormat = false; + } + inputBuffer.flip(); + inputBuffer.colorInfo = inputFormat.colorInfo; + onQueueInputBuffer(inputBuffer); + decoder.queueInputBuffer(inputBuffer); + buffersInCodecCount++; + decoderReceivedBuffers = true; + decoderCounters.inputBufferCount++; + inputBuffer = null; + return true; + } + + /** + * Attempts to dequeue an output buffer from the decoder and, if successful, passes it to {@link + * #processOutputBuffer(long, long)}. + * + * @param positionUs The player's current position. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + * @return Whether it may be possible to drain more output data. + * @throws ExoPlaybackException If an error occurs draining the output buffer. + */ + private boolean drainOutputBuffer(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException, VideoDecoderException { + if (outputBuffer == null) { + outputBuffer = decoder.dequeueOutputBuffer(); + if (outputBuffer == null) { + return false; + } + decoderCounters.skippedOutputBufferCount += outputBuffer.skippedOutputBufferCount; + buffersInCodecCount -= outputBuffer.skippedOutputBufferCount; + } + + if (outputBuffer.isEndOfStream()) { + if (decoderReinitializationState == REINITIALIZATION_STATE_WAIT_END_OF_STREAM) { + // We're waiting to re-initialize the decoder, and have now processed all final buffers. + releaseDecoder(); + maybeInitDecoder(); + } else { + outputBuffer.release(); + outputBuffer = null; + outputStreamEnded = true; + } + return false; + } + + boolean processedOutputBuffer = processOutputBuffer(positionUs, elapsedRealtimeUs); + if (processedOutputBuffer) { + onProcessedOutputBuffer(outputBuffer.timeUs); + outputBuffer = null; + } + return processedOutputBuffer; + } + + /** + * Processes {@link #outputBuffer} by rendering it, skipping it or doing nothing, and returns + * whether it may be possible to process another output buffer. + * + * @param positionUs The player's current position. + * @param elapsedRealtimeUs {@link android.os.SystemClock#elapsedRealtime()} in microseconds, + * measured at the start of the current iteration of the rendering loop. + * @return Whether it may be possible to drain another output buffer. + * @throws ExoPlaybackException If an error occurs processing the output buffer. + */ + private boolean processOutputBuffer(long positionUs, long elapsedRealtimeUs) + throws ExoPlaybackException, VideoDecoderException { + if (initialPositionUs == C.TIME_UNSET) { + initialPositionUs = positionUs; + } + + long earlyUs = outputBuffer.timeUs - positionUs; + if (!hasOutput()) { + // Skip frames in sync with playback, so we'll be at the right frame if the mode changes. + if (isBufferLate(earlyUs)) { + skipOutputBuffer(outputBuffer); + return true; + } + return false; + } + + long presentationTimeUs = outputBuffer.timeUs - outputStreamOffsetUs; + Format format = formatQueue.pollFloor(presentationTimeUs); + if (format != null) { + outputFormat = format; + } + + long elapsedRealtimeNowUs = SystemClock.elapsedRealtime() * 1000; + boolean isStarted = getState() == STATE_STARTED; + if (!renderedFirstFrame + || (isStarted + && shouldForceRenderOutputBuffer(earlyUs, elapsedRealtimeNowUs - lastRenderTimeUs))) { + renderOutputBuffer(outputBuffer, presentationTimeUs, outputFormat); + return true; + } + + if (!isStarted || positionUs == initialPositionUs) { + return false; + } + + if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs) + && maybeDropBuffersToKeyframe(positionUs)) { + return false; + } else if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs)) { + dropOutputBuffer(outputBuffer); + return true; + } + + if (earlyUs < 30000) { + renderOutputBuffer(outputBuffer, presentationTimeUs, outputFormat); + return true; + } + + return false; + } + + private boolean hasOutput() { + return outputMode != C.VIDEO_OUTPUT_MODE_NONE; + } + + private void onOutputChanged() { + // If we know the video size, report it again immediately. + maybeRenotifyVideoSizeChanged(); + // We haven't rendered to the new output yet. + clearRenderedFirstFrame(); + if (getState() == STATE_STARTED) { + setJoiningDeadlineMs(); + } + } + + private void onOutputRemoved() { + clearReportedVideoSize(); + clearRenderedFirstFrame(); + } + + private void onOutputReset() { + // The output is unchanged and non-null. If we know the video size and/or have already + // rendered to the output, report these again immediately. + maybeRenotifyVideoSizeChanged(); + maybeRenotifyRenderedFirstFrame(); + } + + private boolean shouldWaitForKeys(boolean bufferEncrypted) throws ExoPlaybackException { + if (decoderDrmSession == null + || (!bufferEncrypted + && (playClearSamplesWithoutKeys || decoderDrmSession.playClearSamplesWithoutKeys()))) { + return false; + } + @DrmSession.State int drmSessionState = decoderDrmSession.getState(); + if (drmSessionState == DrmSession.STATE_ERROR) { + throw createRendererException(decoderDrmSession.getError(), inputFormat); + } + return drmSessionState != DrmSession.STATE_OPENED_WITH_KEYS; + } + + private void setJoiningDeadlineMs() { + joiningDeadlineMs = + allowedJoiningTimeMs > 0 + ? (SystemClock.elapsedRealtime() + allowedJoiningTimeMs) + : C.TIME_UNSET; + } + + private void clearRenderedFirstFrame() { + renderedFirstFrame = false; + } + + private void maybeNotifyRenderedFirstFrame() { + if (!renderedFirstFrame) { + renderedFirstFrame = true; + eventDispatcher.renderedFirstFrame(surface); + } + } + + private void maybeRenotifyRenderedFirstFrame() { + if (renderedFirstFrame) { + eventDispatcher.renderedFirstFrame(surface); + } + } + + private void clearReportedVideoSize() { + reportedWidth = Format.NO_VALUE; + reportedHeight = Format.NO_VALUE; + } + + private void maybeNotifyVideoSizeChanged(int width, int height) { + if (reportedWidth != width || reportedHeight != height) { + reportedWidth = width; + reportedHeight = height; + eventDispatcher.videoSizeChanged( + width, height, /* unappliedRotationDegrees= */ 0, /* pixelWidthHeightRatio= */ 1); + } + } + + private void maybeRenotifyVideoSizeChanged() { + if (reportedWidth != Format.NO_VALUE || reportedHeight != Format.NO_VALUE) { + eventDispatcher.videoSizeChanged( + reportedWidth, + reportedHeight, + /* unappliedRotationDegrees= */ 0, + /* pixelWidthHeightRatio= */ 1); + } + } + + private void maybeNotifyDroppedFrames() { + if (droppedFrames > 0) { + long now = SystemClock.elapsedRealtime(); + long elapsedMs = now - droppedFrameAccumulationStartTimeMs; + eventDispatcher.droppedFrames(droppedFrames, elapsedMs); + droppedFrames = 0; + droppedFrameAccumulationStartTimeMs = now; + } + } + + private static boolean isBufferLate(long earlyUs) { + // Class a buffer as late if it should have been presented more than 30 ms ago. + return earlyUs < -30000; + } + + private static boolean isBufferVeryLate(long earlyUs) { + // Class a buffer as very late if it should have been presented more than 500 ms ago. + return earlyUs < -500000; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderException.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderException.java new file mode 100644 index 0000000000..dfffbe049b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderException.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +/** Thrown when a video decoder error occurs. */ +public class VideoDecoderException extends Exception { + + /** + * Creates an instance with the given message. + * + * @param message The detail message for this exception. + */ + public VideoDecoderException(String message) { + super(message); + } + + /** + * Creates an instance with the given message and cause. + * + * @param message The detail message for this exception. + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). + * A <tt>null</tt> value is permitted, and indicates that the cause is nonexistent or unknown. + */ + public VideoDecoderException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java new file mode 100644 index 0000000000..69249dd426 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderGLSurfaceView.java @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.content.Context; +import android.opengl.GLSurfaceView; +import android.util.AttributeSet; +import androidx.annotation.Nullable; + +/** + * GLSurfaceView for rendering video output. To render video in this view, call {@link + * #getVideoDecoderOutputBufferRenderer()} to get a {@link VideoDecoderOutputBufferRenderer} that + * will render video decoder output buffers in this view. + * + * <p>This view is intended for use only with extension renderers. For other use cases a {@link + * android.view.SurfaceView} or {@link android.view.TextureView} should be used instead. + */ +public class VideoDecoderGLSurfaceView extends GLSurfaceView { + + private final VideoDecoderRenderer renderer; + + /** @param context A {@link Context}. */ + public VideoDecoderGLSurfaceView(Context context) { + this(context, /* attrs= */ null); + } + + /** + * @param context A {@link Context}. + * @param attrs Custom attributes. + */ + public VideoDecoderGLSurfaceView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + renderer = new VideoDecoderRenderer(this); + setPreserveEGLContextOnPause(true); + setEGLContextClientVersion(2); + setRenderer(renderer); + setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY); + } + + /** Returns the {@link VideoDecoderOutputBufferRenderer} that will render frames in this view. */ + public VideoDecoderOutputBufferRenderer getVideoDecoderOutputBufferRenderer() { + return renderer; + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java new file mode 100644 index 0000000000..d911ac3a5a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderInputBuffer.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; + +/** Input buffer to a video decoder. */ +public class VideoDecoderInputBuffer extends DecoderInputBuffer { + + @Nullable public ColorInfo colorInfo; + + public VideoDecoderInputBuffer() { + super(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT); + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java new file mode 100644 index 0000000000..b09e8b759a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderOutputBuffer.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.OutputBuffer; +import java.nio.ByteBuffer; + +/** Video decoder output buffer containing video frame data. */ +public class VideoDecoderOutputBuffer extends OutputBuffer { + + /** Buffer owner. */ + public interface Owner { + + /** + * Releases the buffer. + * + * @param outputBuffer Output buffer. + */ + void releaseOutputBuffer(VideoDecoderOutputBuffer outputBuffer); + } + + // LINT.IfChange + public static final int COLORSPACE_UNKNOWN = 0; + public static final int COLORSPACE_BT601 = 1; + public static final int COLORSPACE_BT709 = 2; + public static final int COLORSPACE_BT2020 = 3; + // LINT.ThenChange( + // ../../../../../../../../../../extensions/av1/src/main/jni/gav1_jni.cc, + // ../../../../../../../../../../extensions/vp9/src/main/jni/vpx_jni.cc + // ) + + /** Decoder private data. */ + public int decoderPrivate; + + /** Output mode. */ + @C.VideoOutputMode public int mode; + /** RGB buffer for RGB mode. */ + @Nullable public ByteBuffer data; + + public int width; + public int height; + @Nullable public ColorInfo colorInfo; + + /** YUV planes for YUV mode. */ + @Nullable public ByteBuffer[] yuvPlanes; + + @Nullable public int[] yuvStrides; + public int colorspace; + + /** + * Supplemental data related to the output frame, if {@link #hasSupplementalData()} returns true. + * If present, the buffer is populated with supplemental data from position 0 to its limit. + */ + @Nullable public ByteBuffer supplementalData; + + private final Owner owner; + + /** + * Creates VideoDecoderOutputBuffer. + * + * @param owner Buffer owner. + */ + public VideoDecoderOutputBuffer(Owner owner) { + this.owner = owner; + } + + @Override + public void release() { + owner.releaseOutputBuffer(this); + } + + /** + * Initializes the buffer. + * + * @param timeUs The presentation timestamp for the buffer, in microseconds. + * @param mode The output mode. One of {@link C#VIDEO_OUTPUT_MODE_NONE}, {@link + * C#VIDEO_OUTPUT_MODE_YUV} and {@link C#VIDEO_OUTPUT_MODE_SURFACE_YUV}. + * @param supplementalData Supplemental data associated with the frame, or {@code null} if not + * present. It is safe to reuse the provided buffer after this method returns. + */ + public void init( + long timeUs, @C.VideoOutputMode int mode, @Nullable ByteBuffer supplementalData) { + this.timeUs = timeUs; + this.mode = mode; + if (supplementalData != null && supplementalData.hasRemaining()) { + addFlag(C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA); + int size = supplementalData.limit(); + if (this.supplementalData == null || this.supplementalData.capacity() < size) { + this.supplementalData = ByteBuffer.allocate(size); + } else { + this.supplementalData.clear(); + } + this.supplementalData.put(supplementalData); + this.supplementalData.flip(); + supplementalData.position(0); + } else { + this.supplementalData = null; + } + } + + /** + * Resizes the buffer based on the given stride. Called via JNI after decoding completes. + * + * @return Whether the buffer was resized successfully. + */ + public boolean initForYuvFrame(int width, int height, int yStride, int uvStride, int colorspace) { + this.width = width; + this.height = height; + this.colorspace = colorspace; + int uvHeight = (int) (((long) height + 1) / 2); + if (!isSafeToMultiply(yStride, height) || !isSafeToMultiply(uvStride, uvHeight)) { + return false; + } + int yLength = yStride * height; + int uvLength = uvStride * uvHeight; + int minimumYuvSize = yLength + (uvLength * 2); + if (!isSafeToMultiply(uvLength, 2) || minimumYuvSize < yLength) { + return false; + } + + // Initialize data. + if (data == null || data.capacity() < minimumYuvSize) { + data = ByteBuffer.allocateDirect(minimumYuvSize); + } else { + data.position(0); + data.limit(minimumYuvSize); + } + + if (yuvPlanes == null) { + yuvPlanes = new ByteBuffer[3]; + } + + ByteBuffer data = this.data; + ByteBuffer[] yuvPlanes = this.yuvPlanes; + + // Rewrapping has to be done on every frame since the stride might have changed. + yuvPlanes[0] = data.slice(); + yuvPlanes[0].limit(yLength); + data.position(yLength); + yuvPlanes[1] = data.slice(); + yuvPlanes[1].limit(uvLength); + data.position(yLength + uvLength); + yuvPlanes[2] = data.slice(); + yuvPlanes[2].limit(uvLength); + if (yuvStrides == null) { + yuvStrides = new int[3]; + } + yuvStrides[0] = yStride; + yuvStrides[1] = uvStride; + yuvStrides[2] = uvStride; + return true; + } + + /** + * Configures the buffer for the given frame dimensions when passing actual frame data via {@link + * #decoderPrivate}. Called via JNI after decoding completes. + */ + public void initForPrivateFrame(int width, int height) { + this.width = width; + this.height = height; + } + + /** + * Ensures that the result of multiplying individual numbers can fit into the size limit of an + * integer. + */ + private static boolean isSafeToMultiply(int a, int b) { + return a >= 0 && b >= 0 && !(b > 0 && a >= Integer.MAX_VALUE / b); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderOutputBufferRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderOutputBufferRenderer.java new file mode 100644 index 0000000000..f4058ea40f --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderOutputBufferRenderer.java @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +/** Renders the {@link VideoDecoderOutputBuffer}. */ +public interface VideoDecoderOutputBufferRenderer { + + /** + * Sets the output buffer to be rendered. The renderer is responsible for releasing the buffer. + * + * @param outputBuffer The output buffer to be rendered. + */ + void setOutputBuffer(VideoDecoderOutputBuffer outputBuffer); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderRenderer.java new file mode 100644 index 0000000000..1e302e4aaa --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoDecoderRenderer.java @@ -0,0 +1,241 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.opengl.GLES20; +import android.opengl.GLSurfaceView; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.GlUtil; +import java.nio.FloatBuffer; +import java.util.concurrent.atomic.AtomicReference; +import javax.microedition.khronos.egl.EGLConfig; +import javax.microedition.khronos.opengles.GL10; + +/** + * GLSurfaceView.Renderer implementation that can render YUV Frames returned by a video decoder + * after decoding. It does the YUV to RGB color conversion in the Fragment Shader. + */ +/* package */ class VideoDecoderRenderer + implements GLSurfaceView.Renderer, VideoDecoderOutputBufferRenderer { + + private static final float[] kColorConversion601 = { + 1.164f, 1.164f, 1.164f, + 0.0f, -0.392f, 2.017f, + 1.596f, -0.813f, 0.0f, + }; + + private static final float[] kColorConversion709 = { + 1.164f, 1.164f, 1.164f, + 0.0f, -0.213f, 2.112f, + 1.793f, -0.533f, 0.0f, + }; + + private static final float[] kColorConversion2020 = { + 1.168f, 1.168f, 1.168f, + 0.0f, -0.188f, 2.148f, + 1.683f, -0.652f, 0.0f, + }; + + private static final String VERTEX_SHADER = + "varying vec2 interp_tc_y;\n" + + "varying vec2 interp_tc_u;\n" + + "varying vec2 interp_tc_v;\n" + + "attribute vec4 in_pos;\n" + + "attribute vec2 in_tc_y;\n" + + "attribute vec2 in_tc_u;\n" + + "attribute vec2 in_tc_v;\n" + + "void main() {\n" + + " gl_Position = in_pos;\n" + + " interp_tc_y = in_tc_y;\n" + + " interp_tc_u = in_tc_u;\n" + + " interp_tc_v = in_tc_v;\n" + + "}\n"; + private static final String[] TEXTURE_UNIFORMS = {"y_tex", "u_tex", "v_tex"}; + private static final String FRAGMENT_SHADER = + "precision mediump float;\n" + + "varying vec2 interp_tc_y;\n" + + "varying vec2 interp_tc_u;\n" + + "varying vec2 interp_tc_v;\n" + + "uniform sampler2D y_tex;\n" + + "uniform sampler2D u_tex;\n" + + "uniform sampler2D v_tex;\n" + + "uniform mat3 mColorConversion;\n" + + "void main() {\n" + + " vec3 yuv;\n" + + " yuv.x = texture2D(y_tex, interp_tc_y).r - 0.0625;\n" + + " yuv.y = texture2D(u_tex, interp_tc_u).r - 0.5;\n" + + " yuv.z = texture2D(v_tex, interp_tc_v).r - 0.5;\n" + + " gl_FragColor = vec4(mColorConversion * yuv, 1.0);\n" + + "}\n"; + + private static final FloatBuffer TEXTURE_VERTICES = + GlUtil.createBuffer(new float[] {-1.0f, 1.0f, -1.0f, -1.0f, 1.0f, 1.0f, 1.0f, -1.0f}); + private final GLSurfaceView surfaceView; + private final int[] yuvTextures = new int[3]; + private final AtomicReference<VideoDecoderOutputBuffer> pendingOutputBufferReference; + + // Kept in field rather than a local variable in order not to get garbage collected before + // glDrawArrays uses it. + private FloatBuffer[] textureCoords; + + private int program; + private int[] texLocations; + private int colorMatrixLocation; + private int[] previousWidths; + private int[] previousStrides; + + @Nullable + private VideoDecoderOutputBuffer renderedOutputBuffer; // Accessed only from the GL thread. + + public VideoDecoderRenderer(GLSurfaceView surfaceView) { + this.surfaceView = surfaceView; + pendingOutputBufferReference = new AtomicReference<>(); + textureCoords = new FloatBuffer[3]; + texLocations = new int[3]; + previousWidths = new int[3]; + previousStrides = new int[3]; + for (int i = 0; i < 3; i++) { + previousWidths[i] = previousStrides[i] = -1; + } + } + + @Override + public void onSurfaceCreated(GL10 unused, EGLConfig config) { + program = GlUtil.compileProgram(VERTEX_SHADER, FRAGMENT_SHADER); + GLES20.glUseProgram(program); + int posLocation = GLES20.glGetAttribLocation(program, "in_pos"); + GLES20.glEnableVertexAttribArray(posLocation); + GLES20.glVertexAttribPointer(posLocation, 2, GLES20.GL_FLOAT, false, 0, TEXTURE_VERTICES); + texLocations[0] = GLES20.glGetAttribLocation(program, "in_tc_y"); + GLES20.glEnableVertexAttribArray(texLocations[0]); + texLocations[1] = GLES20.glGetAttribLocation(program, "in_tc_u"); + GLES20.glEnableVertexAttribArray(texLocations[1]); + texLocations[2] = GLES20.glGetAttribLocation(program, "in_tc_v"); + GLES20.glEnableVertexAttribArray(texLocations[2]); + GlUtil.checkGlError(); + colorMatrixLocation = GLES20.glGetUniformLocation(program, "mColorConversion"); + GlUtil.checkGlError(); + setupTextures(); + GlUtil.checkGlError(); + } + + @Override + public void onSurfaceChanged(GL10 unused, int width, int height) { + GLES20.glViewport(0, 0, width, height); + } + + @Override + public void onDrawFrame(GL10 unused) { + VideoDecoderOutputBuffer pendingOutputBuffer = pendingOutputBufferReference.getAndSet(null); + if (pendingOutputBuffer == null && renderedOutputBuffer == null) { + // There is no output buffer to render at the moment. + return; + } + if (pendingOutputBuffer != null) { + if (renderedOutputBuffer != null) { + renderedOutputBuffer.release(); + } + renderedOutputBuffer = pendingOutputBuffer; + } + VideoDecoderOutputBuffer outputBuffer = renderedOutputBuffer; + // Set color matrix. Assume BT709 if the color space is unknown. + float[] colorConversion = kColorConversion709; + switch (outputBuffer.colorspace) { + case VideoDecoderOutputBuffer.COLORSPACE_BT601: + colorConversion = kColorConversion601; + break; + case VideoDecoderOutputBuffer.COLORSPACE_BT2020: + colorConversion = kColorConversion2020; + break; + case VideoDecoderOutputBuffer.COLORSPACE_BT709: + default: + break; // Do nothing + } + GLES20.glUniformMatrix3fv(colorMatrixLocation, 1, false, colorConversion, 0); + + for (int i = 0; i < 3; i++) { + int h = (i == 0) ? outputBuffer.height : (outputBuffer.height + 1) / 2; + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + i); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, yuvTextures[i]); + GLES20.glPixelStorei(GLES20.GL_UNPACK_ALIGNMENT, 1); + GLES20.glTexImage2D( + GLES20.GL_TEXTURE_2D, + 0, + GLES20.GL_LUMINANCE, + outputBuffer.yuvStrides[i], + h, + 0, + GLES20.GL_LUMINANCE, + GLES20.GL_UNSIGNED_BYTE, + outputBuffer.yuvPlanes[i]); + } + + int[] widths = new int[3]; + widths[0] = outputBuffer.width; + // TODO: Handle streams where chroma channels are not stored at half width and height + // compared to luma channel. See [Internal: b/142097774]. + // U and V planes are being stored at half width compared to Y. + widths[1] = widths[2] = (widths[0] + 1) / 2; + for (int i = 0; i < 3; i++) { + // Set cropping of stride if either width or stride has changed. + if (previousWidths[i] != widths[i] || previousStrides[i] != outputBuffer.yuvStrides[i]) { + Assertions.checkState(outputBuffer.yuvStrides[i] != 0); + float widthRatio = (float) widths[i] / outputBuffer.yuvStrides[i]; + // These buffers are consumed during each call to glDrawArrays. They need to be member + // variables rather than local variables in order not to get garbage collected. + textureCoords[i] = + GlUtil.createBuffer( + new float[] {0.0f, 0.0f, 0.0f, 1.0f, widthRatio, 0.0f, widthRatio, 1.0f}); + GLES20.glVertexAttribPointer( + texLocations[i], 2, GLES20.GL_FLOAT, false, 0, textureCoords[i]); + previousWidths[i] = widths[i]; + previousStrides[i] = outputBuffer.yuvStrides[i]; + } + } + + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + GlUtil.checkGlError(); + } + + @Override + public void setOutputBuffer(VideoDecoderOutputBuffer outputBuffer) { + VideoDecoderOutputBuffer oldPendingOutputBuffer = + pendingOutputBufferReference.getAndSet(outputBuffer); + if (oldPendingOutputBuffer != null) { + // The old pending output buffer will never be used for rendering, so release it now. + oldPendingOutputBuffer.release(); + } + surfaceView.requestRender(); + } + + private void setupTextures() { + GLES20.glGenTextures(3, yuvTextures, 0); + for (int i = 0; i < 3; i++) { + GLES20.glUniform1i(GLES20.glGetUniformLocation(program, TEXTURE_UNIFORMS[i]), i); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0 + i); + GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, yuvTextures[i]); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameterf(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameterf( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameterf( + GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + } + GlUtil.checkGlError(); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoFrameMetadataListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoFrameMetadataListener.java new file mode 100644 index 0000000000..46e05def5c --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoFrameMetadataListener.java @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.media.MediaFormat; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; + +/** A listener for metadata corresponding to video frame being rendered. */ +public interface VideoFrameMetadataListener { + /** + * Called when the video frame about to be rendered. This method is called on the playback thread. + * + * @param presentationTimeUs The presentation time of the output buffer, in microseconds. + * @param releaseTimeNs The wallclock time at which the frame should be displayed, in nanoseconds. + * If the platform API version of the device is less than 21, then this is the best effort. + * @param format The format associated with the frame. + * @param mediaFormat The framework media format associated with the frame, or {@code null} if not + * known or not applicable (e.g., because the frame was not output by a {@link + * android.media.MediaCodec MediaCodec}). + */ + void onVideoFrameAboutToBeRendered( + long presentationTimeUs, + long releaseTimeNs, + Format format, + @Nullable MediaFormat mediaFormat); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java new file mode 100644 index 0000000000..c13cd4b1cb --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoFrameReleaseTimeHelper.java @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import android.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.view.Choreographer; +import android.view.Choreographer.FrameCallback; +import android.view.Display; +import android.view.WindowManager; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +/** + * Makes a best effort to adjust frame release timestamps for a smoother visual result. + */ +public final class VideoFrameReleaseTimeHelper { + + private static final long CHOREOGRAPHER_SAMPLE_DELAY_MILLIS = 500; + private static final long MAX_ALLOWED_DRIFT_NS = 20000000; + + private static final long VSYNC_OFFSET_PERCENTAGE = 80; + private static final int MIN_FRAMES_FOR_ADJUSTMENT = 6; + + private final WindowManager windowManager; + private final VSyncSampler vsyncSampler; + private final DefaultDisplayListener displayListener; + + private long vsyncDurationNs; + private long vsyncOffsetNs; + + private long lastFramePresentationTimeUs; + private long adjustedLastFrameTimeNs; + private long pendingAdjustedFrameTimeNs; + + private boolean haveSync; + private long syncUnadjustedReleaseTimeNs; + private long syncFramePresentationTimeNs; + private long frameCount; + + /** + * Constructs an instance that smooths frame release timestamps but does not align them with + * the default display's vsync signal. + */ + public VideoFrameReleaseTimeHelper() { + this(null); + } + + /** + * Constructs an instance that smooths frame release timestamps and aligns them with the default + * display's vsync signal. + * + * @param context A context from which information about the default display can be retrieved. + */ + public VideoFrameReleaseTimeHelper(@Nullable Context context) { + if (context != null) { + context = context.getApplicationContext(); + windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } else { + windowManager = null; + } + if (windowManager != null) { + displayListener = Util.SDK_INT >= 17 ? maybeBuildDefaultDisplayListenerV17(context) : null; + vsyncSampler = VSyncSampler.getInstance(); + } else { + displayListener = null; + vsyncSampler = null; + } + vsyncDurationNs = C.TIME_UNSET; + vsyncOffsetNs = C.TIME_UNSET; + } + + /** + * Enables the helper. Must be called from the playback thread. + */ + public void enable() { + haveSync = false; + if (windowManager != null) { + vsyncSampler.addObserver(); + if (displayListener != null) { + displayListener.register(); + } + updateDefaultDisplayRefreshRateParams(); + } + } + + /** + * Disables the helper. Must be called from the playback thread. + */ + public void disable() { + if (windowManager != null) { + if (displayListener != null) { + displayListener.unregister(); + } + vsyncSampler.removeObserver(); + } + } + + /** + * Adjusts a frame release timestamp. Must be called from the playback thread. + * + * @param framePresentationTimeUs The frame's presentation time, in microseconds. + * @param unadjustedReleaseTimeNs The frame's unadjusted release time, in nanoseconds and in + * the same time base as {@link System#nanoTime()}. + * @return The adjusted frame release timestamp, in nanoseconds and in the same time base as + * {@link System#nanoTime()}. + */ + public long adjustReleaseTime(long framePresentationTimeUs, long unadjustedReleaseTimeNs) { + long framePresentationTimeNs = framePresentationTimeUs * 1000; + + // Until we know better, the adjustment will be a no-op. + long adjustedFrameTimeNs = framePresentationTimeNs; + long adjustedReleaseTimeNs = unadjustedReleaseTimeNs; + + if (haveSync) { + // See if we've advanced to the next frame. + if (framePresentationTimeUs != lastFramePresentationTimeUs) { + frameCount++; + adjustedLastFrameTimeNs = pendingAdjustedFrameTimeNs; + } + if (frameCount >= MIN_FRAMES_FOR_ADJUSTMENT) { + // We're synced and have waited the required number of frames to apply an adjustment. + // Calculate the average frame time across all the frames we've seen since the last sync. + // This will typically give us a frame rate at a finer granularity than the frame times + // themselves (which often only have millisecond granularity). + long averageFrameDurationNs = (framePresentationTimeNs - syncFramePresentationTimeNs) + / frameCount; + // Project the adjusted frame time forward using the average. + long candidateAdjustedFrameTimeNs = adjustedLastFrameTimeNs + averageFrameDurationNs; + + if (isDriftTooLarge(candidateAdjustedFrameTimeNs, unadjustedReleaseTimeNs)) { + haveSync = false; + } else { + adjustedFrameTimeNs = candidateAdjustedFrameTimeNs; + adjustedReleaseTimeNs = syncUnadjustedReleaseTimeNs + adjustedFrameTimeNs + - syncFramePresentationTimeNs; + } + } else { + // We're synced but haven't waited the required number of frames to apply an adjustment. + // Check drift anyway. + if (isDriftTooLarge(framePresentationTimeNs, unadjustedReleaseTimeNs)) { + haveSync = false; + } + } + } + + // If we need to sync, do so now. + if (!haveSync) { + syncFramePresentationTimeNs = framePresentationTimeNs; + syncUnadjustedReleaseTimeNs = unadjustedReleaseTimeNs; + frameCount = 0; + haveSync = true; + } + + lastFramePresentationTimeUs = framePresentationTimeUs; + pendingAdjustedFrameTimeNs = adjustedFrameTimeNs; + + if (vsyncSampler == null || vsyncDurationNs == C.TIME_UNSET) { + return adjustedReleaseTimeNs; + } + long sampledVsyncTimeNs = vsyncSampler.sampledVsyncTimeNs; + if (sampledVsyncTimeNs == C.TIME_UNSET) { + return adjustedReleaseTimeNs; + } + + // Find the timestamp of the closest vsync. This is the vsync that we're targeting. + long snappedTimeNs = closestVsync(adjustedReleaseTimeNs, sampledVsyncTimeNs, vsyncDurationNs); + // Apply an offset so that we release before the target vsync, but after the previous one. + return snappedTimeNs - vsyncOffsetNs; + } + + @TargetApi(17) + private DefaultDisplayListener maybeBuildDefaultDisplayListenerV17(Context context) { + DisplayManager manager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + return manager == null ? null : new DefaultDisplayListener(manager); + } + + private void updateDefaultDisplayRefreshRateParams() { + // Note: If we fail to update the parameters, we leave them set to their previous values. + Display defaultDisplay = windowManager.getDefaultDisplay(); + if (defaultDisplay != null) { + double defaultDisplayRefreshRate = defaultDisplay.getRefreshRate(); + vsyncDurationNs = (long) (C.NANOS_PER_SECOND / defaultDisplayRefreshRate); + vsyncOffsetNs = (vsyncDurationNs * VSYNC_OFFSET_PERCENTAGE) / 100; + } + } + + private boolean isDriftTooLarge(long frameTimeNs, long releaseTimeNs) { + long elapsedFrameTimeNs = frameTimeNs - syncFramePresentationTimeNs; + long elapsedReleaseTimeNs = releaseTimeNs - syncUnadjustedReleaseTimeNs; + return Math.abs(elapsedReleaseTimeNs - elapsedFrameTimeNs) > MAX_ALLOWED_DRIFT_NS; + } + + private static long closestVsync(long releaseTime, long sampledVsyncTime, long vsyncDuration) { + long vsyncCount = (releaseTime - sampledVsyncTime) / vsyncDuration; + long snappedTimeNs = sampledVsyncTime + (vsyncDuration * vsyncCount); + long snappedBeforeNs; + long snappedAfterNs; + if (releaseTime <= snappedTimeNs) { + snappedBeforeNs = snappedTimeNs - vsyncDuration; + snappedAfterNs = snappedTimeNs; + } else { + snappedBeforeNs = snappedTimeNs; + snappedAfterNs = snappedTimeNs + vsyncDuration; + } + long snappedAfterDiff = snappedAfterNs - releaseTime; + long snappedBeforeDiff = releaseTime - snappedBeforeNs; + return snappedAfterDiff < snappedBeforeDiff ? snappedAfterNs : snappedBeforeNs; + } + + @TargetApi(17) + private final class DefaultDisplayListener implements DisplayManager.DisplayListener { + + private final DisplayManager displayManager; + + public DefaultDisplayListener(DisplayManager displayManager) { + this.displayManager = displayManager; + } + + public void register() { + displayManager.registerDisplayListener(this, null); + } + + public void unregister() { + displayManager.unregisterDisplayListener(this); + } + + @Override + public void onDisplayAdded(int displayId) { + // Do nothing. + } + + @Override + public void onDisplayRemoved(int displayId) { + // Do nothing. + } + + @Override + public void onDisplayChanged(int displayId) { + if (displayId == Display.DEFAULT_DISPLAY) { + updateDefaultDisplayRefreshRateParams(); + } + } + + } + + /** + * Samples display vsync timestamps. A single instance using a single {@link Choreographer} is + * shared by all {@link VideoFrameReleaseTimeHelper} instances. This is done to avoid a resource + * leak in the platform on API levels prior to 23. See [Internal: b/12455729]. + */ + private static final class VSyncSampler implements FrameCallback, Handler.Callback { + + public volatile long sampledVsyncTimeNs; + + private static final int CREATE_CHOREOGRAPHER = 0; + private static final int MSG_ADD_OBSERVER = 1; + private static final int MSG_REMOVE_OBSERVER = 2; + + private static final VSyncSampler INSTANCE = new VSyncSampler(); + + private final Handler handler; + private final HandlerThread choreographerOwnerThread; + private Choreographer choreographer; + private int observerCount; + + public static VSyncSampler getInstance() { + return INSTANCE; + } + + private VSyncSampler() { + sampledVsyncTimeNs = C.TIME_UNSET; + choreographerOwnerThread = new HandlerThread("ChoreographerOwner:Handler"); + choreographerOwnerThread.start(); + handler = Util.createHandler(choreographerOwnerThread.getLooper(), /* callback= */ this); + handler.sendEmptyMessage(CREATE_CHOREOGRAPHER); + } + + /** + * Notifies the sampler that a {@link VideoFrameReleaseTimeHelper} is observing + * {@link #sampledVsyncTimeNs}, and hence that the value should be periodically updated. + */ + public void addObserver() { + handler.sendEmptyMessage(MSG_ADD_OBSERVER); + } + + /** + * Notifies the sampler that a {@link VideoFrameReleaseTimeHelper} is no longer observing + * {@link #sampledVsyncTimeNs}. + */ + public void removeObserver() { + handler.sendEmptyMessage(MSG_REMOVE_OBSERVER); + } + + @Override + public void doFrame(long vsyncTimeNs) { + sampledVsyncTimeNs = vsyncTimeNs; + choreographer.postFrameCallbackDelayed(this, CHOREOGRAPHER_SAMPLE_DELAY_MILLIS); + } + + @Override + public boolean handleMessage(Message message) { + switch (message.what) { + case CREATE_CHOREOGRAPHER: { + createChoreographerInstanceInternal(); + return true; + } + case MSG_ADD_OBSERVER: { + addObserverInternal(); + return true; + } + case MSG_REMOVE_OBSERVER: { + removeObserverInternal(); + return true; + } + default: { + return false; + } + } + } + + private void createChoreographerInstanceInternal() { + choreographer = Choreographer.getInstance(); + } + + private void addObserverInternal() { + observerCount++; + if (observerCount == 1) { + choreographer.postFrameCallback(this); + } + } + + private void removeObserverInternal() { + observerCount--; + if (observerCount == 0) { + choreographer.removeFrameCallback(this); + sampledVsyncTimeNs = C.TIME_UNSET; + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoListener.java new file mode 100644 index 0000000000..a469366b78 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoListener.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +/** A listener for metadata corresponding to video being rendered. */ +public interface VideoListener { + + /** + * Called each time there's a change in the size of the video being rendered. + * + * @param width The video width in pixels. + * @param height The video height in pixels. + * @param unappliedRotationDegrees For videos that require a rotation, this is the clockwise + * rotation in degrees that the application should apply for the video for it to be rendered + * in the correct orientation. This value will always be zero on API levels 21 and above, + * since the renderer will apply all necessary rotations internally. On earlier API levels + * this is not possible. Applications that use {@link android.view.TextureView} can apply the + * rotation by calling {@link android.view.TextureView#setTransform}. Applications that do not + * expect to encounter rotated videos can safely ignore this parameter. + * @param pixelWidthHeightRatio The width to height ratio of each pixel. For the normal case of + * square pixels this will be equal to 1.0. Different values are indicative of anamorphic + * content. + */ + default void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {} + + /** + * Called each time there's a change in the size of the surface onto which the video is being + * rendered. + * + * @param width The surface width in pixels. May be {@link + * com.google.android.exoplayer2.C#LENGTH_UNSET} if unknown, or 0 if the video is not rendered + * onto a surface. + * @param height The surface height in pixels. May be {@link + * com.google.android.exoplayer2.C#LENGTH_UNSET} if unknown, or 0 if the video is not rendered + * onto a surface. + */ + default void onSurfaceSizeChanged(int width, int height) {} + + /** + * Called when a frame is rendered for the first time since setting the surface, and when a frame + * is rendered for the first time since a video track was selected. + */ + default void onRenderedFirstFrame() {} +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoRendererEventListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoRendererEventListener.java new file mode 100644 index 0000000000..6509a353b2 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/VideoRendererEventListener.java @@ -0,0 +1,198 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import static org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util.castNonNull; + +import android.os.Handler; +import android.os.SystemClock; +import android.view.Surface; +import android.view.TextureView; +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Renderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderCounters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; + +/** + * Listener of video {@link Renderer} events. All methods have no-op default implementations to + * allow selective overrides. + */ +public interface VideoRendererEventListener { + + /** + * Called when the renderer is enabled. + * + * @param counters {@link DecoderCounters} that will be updated by the renderer for as long as it + * remains enabled. + */ + default void onVideoEnabled(DecoderCounters counters) {} + + /** + * Called when a decoder is created. + * + * @param decoderName The decoder that was created. + * @param initializedTimestampMs {@link SystemClock#elapsedRealtime()} when initialization + * finished. + * @param initializationDurationMs The time taken to initialize the decoder in milliseconds. + */ + default void onVideoDecoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) {} + + /** + * Called when the format of the media being consumed by the renderer changes. + * + * @param format The new format. + */ + default void onVideoInputFormatChanged(Format format) {} + + /** + * Called to report the number of frames dropped by the renderer. Dropped frames are reported + * whenever the renderer is stopped having dropped frames, and optionally, whenever the count + * reaches a specified threshold whilst the renderer is started. + * + * @param count The number of dropped frames. + * @param elapsedMs The duration in milliseconds over which the frames were dropped. This duration + * is timed from when the renderer was started or from when dropped frames were last reported + * (whichever was more recent), and not from when the first of the reported drops occurred. + */ + default void onDroppedFrames(int count, long elapsedMs) {} + + /** + * Called before a frame is rendered for the first time since setting the surface, and each time + * there's a change in the size, rotation or pixel aspect ratio of the video being rendered. + * + * @param width The video width in pixels. + * @param height The video height in pixels. + * @param unappliedRotationDegrees For videos that require a rotation, this is the clockwise + * rotation in degrees that the application should apply for the video for it to be rendered + * in the correct orientation. This value will always be zero on API levels 21 and above, + * since the renderer will apply all necessary rotations internally. On earlier API levels + * this is not possible. Applications that use {@link TextureView} can apply the rotation by + * calling {@link TextureView#setTransform}. Applications that do not expect to encounter + * rotated videos can safely ignore this parameter. + * @param pixelWidthHeightRatio The width to height ratio of each pixel. For the normal case of + * square pixels this will be equal to 1.0. Different values are indicative of anamorphic + * content. + */ + default void onVideoSizeChanged( + int width, int height, int unappliedRotationDegrees, float pixelWidthHeightRatio) {} + + /** + * Called when a frame is rendered for the first time since setting the surface, and when a frame + * is rendered for the first time since the renderer was reset. + * + * @param surface The {@link Surface} to which a first frame has been rendered, or {@code null} if + * the renderer renders to something that isn't a {@link Surface}. + */ + default void onRenderedFirstFrame(@Nullable Surface surface) {} + + /** + * Called when the renderer is disabled. + * + * @param counters {@link DecoderCounters} that were updated by the renderer. + */ + default void onVideoDisabled(DecoderCounters counters) {} + + /** + * Dispatches events to a {@link VideoRendererEventListener}. + */ + final class EventDispatcher { + + @Nullable private final Handler handler; + @Nullable private final VideoRendererEventListener listener; + + /** + * @param handler A handler for dispatching events, or null if creating a dummy instance. + * @param listener The listener to which events should be dispatched, or null if creating a + * dummy instance. + */ + public EventDispatcher(@Nullable Handler handler, + @Nullable VideoRendererEventListener listener) { + this.handler = listener != null ? Assertions.checkNotNull(handler) : null; + this.listener = listener; + } + + /** Invokes {@link VideoRendererEventListener#onVideoEnabled(DecoderCounters)}. */ + public void enabled(DecoderCounters decoderCounters) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onVideoEnabled(decoderCounters)); + } + } + + /** Invokes {@link VideoRendererEventListener#onVideoDecoderInitialized(String, long, long)}. */ + public void decoderInitialized( + String decoderName, long initializedTimestampMs, long initializationDurationMs) { + if (handler != null) { + handler.post( + () -> + castNonNull(listener) + .onVideoDecoderInitialized( + decoderName, initializedTimestampMs, initializationDurationMs)); + } + } + + /** Invokes {@link VideoRendererEventListener#onVideoInputFormatChanged(Format)}. */ + public void inputFormatChanged(Format format) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onVideoInputFormatChanged(format)); + } + } + + /** Invokes {@link VideoRendererEventListener#onDroppedFrames(int, long)}. */ + public void droppedFrames(int droppedFrameCount, long elapsedMs) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onDroppedFrames(droppedFrameCount, elapsedMs)); + } + } + + /** Invokes {@link VideoRendererEventListener#onVideoSizeChanged(int, int, int, float)}. */ + public void videoSizeChanged( + int width, + int height, + final int unappliedRotationDegrees, + final float pixelWidthHeightRatio) { + if (handler != null) { + handler.post( + () -> + castNonNull(listener) + .onVideoSizeChanged( + width, height, unappliedRotationDegrees, pixelWidthHeightRatio)); + } + } + + /** Invokes {@link VideoRendererEventListener#onRenderedFirstFrame(Surface)}. */ + public void renderedFirstFrame(@Nullable Surface surface) { + if (handler != null) { + handler.post(() -> castNonNull(listener).onRenderedFirstFrame(surface)); + } + } + + /** Invokes {@link VideoRendererEventListener#onVideoDisabled(DecoderCounters)}. */ + public void disabled(DecoderCounters counters) { + counters.ensureUpdated(); + if (handler != null) { + handler.post( + () -> { + counters.ensureUpdated(); + castNonNull(listener).onVideoDisabled(counters); + }); + } + } + + } + +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/package-info.java new file mode 100644 index 0000000000..7053c14d16 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.video; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/CameraMotionListener.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/CameraMotionListener.java new file mode 100644 index 0000000000..87bd94c5bc --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/CameraMotionListener.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical; + +/** Listens camera motion. */ +public interface CameraMotionListener { + + /** + * Called when a new camera motion is read. This method is called on the playback thread. + * + * @param timeUs The presentation time of the data. + * @param rotation Angle axis orientation in radians representing the rotation from camera + * coordinate system to world coordinate system. + */ + void onCameraMotion(long timeUs, float[] rotation); + + /** Called when the camera motion track position is reset or the track is disabled. */ + void onCameraMotionReset(); +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java new file mode 100644 index 0000000000..378363aca0 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/CameraMotionRenderer.java @@ -0,0 +1,134 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical; + +import androidx.annotation.Nullable; +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.Renderer; +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.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import java.nio.ByteBuffer; + +/** A {@link Renderer} that parses the camera motion track. */ +public class CameraMotionRenderer extends BaseRenderer { + + // The amount of time to read samples ahead of the current time. + private static final int SAMPLE_WINDOW_DURATION_US = 100000; + + private final DecoderInputBuffer buffer; + private final ParsableByteArray scratch; + + private long offsetUs; + @Nullable private CameraMotionListener listener; + private long lastTimestampUs; + + public CameraMotionRenderer() { + super(C.TRACK_TYPE_CAMERA_MOTION); + buffer = new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + scratch = new ParsableByteArray(); + } + + @Override + @Capabilities + public int supportsFormat(Format format) { + return MimeTypes.APPLICATION_CAMERA_MOTION.equals(format.sampleMimeType) + ? RendererCapabilities.create(FORMAT_HANDLED) + : RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + + @Override + public void handleMessage(int messageType, @Nullable Object message) throws ExoPlaybackException { + if (messageType == C.MSG_SET_CAMERA_MOTION_LISTENER) { + listener = (CameraMotionListener) message; + } else { + super.handleMessage(messageType, message); + } + } + + @Override + protected void onStreamChanged(Format[] formats, long offsetUs) throws ExoPlaybackException { + this.offsetUs = offsetUs; + } + + @Override + protected void onPositionReset(long positionUs, boolean joining) throws ExoPlaybackException { + resetListener(); + } + + @Override + protected void onDisabled() { + resetListener(); + } + + @Override + public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException { + // Keep reading available samples as long as the sample time is not too far into the future. + while (!hasReadStreamToEnd() && lastTimestampUs < positionUs + SAMPLE_WINDOW_DURATION_US) { + buffer.clear(); + FormatHolder formatHolder = getFormatHolder(); + int result = readSource(formatHolder, buffer, /* formatRequired= */ false); + if (result != C.RESULT_BUFFER_READ || buffer.isEndOfStream()) { + return; + } + + buffer.flip(); + lastTimestampUs = buffer.timeUs; + if (listener != null) { + float[] rotation = parseMetadata(Util.castNonNull(buffer.data)); + if (rotation != null) { + Util.castNonNull(listener).onCameraMotion(lastTimestampUs - offsetUs, rotation); + } + } + } + } + + @Override + public boolean isEnded() { + return hasReadStreamToEnd(); + } + + @Override + public boolean isReady() { + return true; + } + + private @Nullable float[] parseMetadata(ByteBuffer data) { + if (data.remaining() != 16) { + return null; + } + scratch.reset(data.array(), data.limit()); + scratch.setPosition(data.arrayOffset() + 4); // skip reserved bytes too. + float[] result = new float[3]; + for (int i = 0; i < 3; i++) { + result[i] = Float.intBitsToFloat(scratch.readLittleEndianInt()); + } + return result; + } + + private void resetListener() { + lastTimestampUs = 0; + if (listener != null) { + listener.onCameraMotionReset(); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/FrameRotationQueue.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/FrameRotationQueue.java new file mode 100644 index 0000000000..450058fb6a --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/FrameRotationQueue.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical; + +import android.opengl.Matrix; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.TimedValueQueue; + +/** + * This class serves multiple purposes: + * + * <ul> + * <li>Queues the rotation metadata extracted from camera motion track. + * <li>Converts the metadata to rotation matrices in OpenGl coordinate system. + * <li>Recenters the rotations to componsate the yaw of the initial rotation. + * </ul> + */ +public final class FrameRotationQueue { + private final float[] recenterMatrix; + private final float[] rotationMatrix; + private final TimedValueQueue<float[]> rotations; + private boolean recenterMatrixComputed; + + public FrameRotationQueue() { + recenterMatrix = new float[16]; + rotationMatrix = new float[16]; + rotations = new TimedValueQueue<>(); + } + + /** + * Sets a rotation for a given timestamp. + * + * @param timestampUs Timestamp of the rotation. + * @param angleAxis Angle axis orientation in radians representing the rotation from camera + * coordinate system to world coordinate system. + */ + public void setRotation(long timestampUs, float[] angleAxis) { + rotations.add(timestampUs, angleAxis); + } + + /** Removes all of the rotations and forces rotations to be recentered. */ + public void reset() { + rotations.clear(); + recenterMatrixComputed = false; + } + + /** + * Copies the rotation matrix with the greatest timestamp which is less than or equal to the given + * timestamp to {@code matrix}. Removes all older rotations and the returned one from the queue. + * Does nothing if there is no such rotation. + * + * @param matrix The rotation matrix. + * @param timestampUs The time in microseconds to query the rotation. + * @return Whether a rotation matrix is copied to {@code matrix}. + */ + public boolean pollRotationMatrix(float[] matrix, long timestampUs) { + float[] rotation = rotations.pollFloor(timestampUs); + if (rotation == null) { + return false; + } + // TODO [Internal: b/113315546]: Slerp between the floor and ceil rotation. + getRotationMatrixFromAngleAxis(rotationMatrix, rotation); + if (!recenterMatrixComputed) { + computeRecenterMatrix(recenterMatrix, rotationMatrix); + recenterMatrixComputed = true; + } + Matrix.multiplyMM(matrix, 0, recenterMatrix, 0, rotationMatrix, 0); + return true; + } + + /** + * Computes a recentering matrix from the given angle-axis rotation only accounting for yaw. Roll + * and tilt will not be compensated. + * + * @param recenterMatrix The recenter matrix. + * @param rotationMatrix The rotation matrix. + */ + public static void computeRecenterMatrix(float[] recenterMatrix, float[] rotationMatrix) { + // The re-centering matrix is computed as follows: + // recenter.row(2) = temp.col(2).transpose(); + // recenter.row(0) = recenter.row(1).cross(recenter.row(2)).normalized(); + // recenter.row(2) = recenter.row(0).cross(recenter.row(1)).normalized(); + // | temp[10] 0 -temp[8] 0| + // | 0 1 0 0| + // recenter = | temp[8] 0 temp[10] 0| + // | 0 0 0 1| + Matrix.setIdentityM(recenterMatrix, 0); + float normRowSqr = + rotationMatrix[10] * rotationMatrix[10] + rotationMatrix[8] * rotationMatrix[8]; + float normRow = (float) Math.sqrt(normRowSqr); + recenterMatrix[0] = rotationMatrix[10] / normRow; + recenterMatrix[2] = rotationMatrix[8] / normRow; + recenterMatrix[8] = -rotationMatrix[8] / normRow; + recenterMatrix[10] = rotationMatrix[10] / normRow; + } + + private static void getRotationMatrixFromAngleAxis(float[] matrix, float[] angleAxis) { + // Convert coordinates to OpenGL coordinates. + // CAMM motion metadata: +x right, +y down, and +z forward. + // OpenGL: +x right, +y up, -z forwards + float x = angleAxis[0]; + float y = -angleAxis[1]; + float z = -angleAxis[2]; + float angleRad = Matrix.length(x, y, z); + if (angleRad != 0) { + float angleDeg = (float) Math.toDegrees(angleRad); + Matrix.setRotateM(matrix, 0, angleDeg, x / angleRad, y / angleRad, z / angleRad); + } else { + Matrix.setIdentityM(matrix, 0); + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/Projection.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/Projection.java new file mode 100644 index 0000000000..e3d614cab3 --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/Projection.java @@ -0,0 +1,236 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical; + +import androidx.annotation.IntDef; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C.StereoMode; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Assertions; +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** The projection mesh used with 360/VR videos. */ +public final class Projection { + + /** Enforces allowed (sub) mesh draw modes. */ + @Documented + @Retention(RetentionPolicy.SOURCE) + @IntDef({DRAW_MODE_TRIANGLES, DRAW_MODE_TRIANGLES_STRIP, DRAW_MODE_TRIANGLES_FAN}) + public @interface DrawMode {} + /** Triangle draw mode. */ + public static final int DRAW_MODE_TRIANGLES = 0; + /** Triangle strip draw mode. */ + public static final int DRAW_MODE_TRIANGLES_STRIP = 1; + /** Triangle fan draw mode. */ + public static final int DRAW_MODE_TRIANGLES_FAN = 2; + + /** Number of position coordinates per vertex. */ + public static final int TEXTURE_COORDS_PER_VERTEX = 2; + /** Number of texture coordinates per vertex. */ + public static final int POSITION_COORDS_PER_VERTEX = 3; + + /** + * Generates a complete sphere equirectangular projection. + * + * @param stereoMode A {@link C.StereoMode} value. + */ + public static Projection createEquirectangular(@C.StereoMode int stereoMode) { + return createEquirectangular( + /* radius= */ 50, // Should be large enough that there are no stereo artifacts. + /* latitudes= */ 36, // Should be large enough to prevent videos looking wavy. + /* longitudes= */ 72, // Should be large enough to prevent videos looking wavy. + /* verticalFovDegrees= */ 180, + /* horizontalFovDegrees= */ 360, + stereoMode); + } + + /** + * Generates an equirectangular projection. + * + * @param radius Size of the sphere. Must be > 0. + * @param latitudes Number of rows that make up the sphere. Must be >= 1. + * @param longitudes Number of columns that make up the sphere. Must be >= 1. + * @param verticalFovDegrees Total latitudinal degrees that are covered by the sphere. Must be in + * (0, 180]. + * @param horizontalFovDegrees Total longitudinal degrees that are covered by the sphere.Must be + * in (0, 360]. + * @param stereoMode A {@link C.StereoMode} value. + * @return an equirectangular projection. + */ + public static Projection createEquirectangular( + float radius, + int latitudes, + int longitudes, + float verticalFovDegrees, + float horizontalFovDegrees, + @C.StereoMode int stereoMode) { + Assertions.checkArgument(radius > 0); + Assertions.checkArgument(latitudes >= 1); + Assertions.checkArgument(longitudes >= 1); + Assertions.checkArgument(verticalFovDegrees > 0 && verticalFovDegrees <= 180); + Assertions.checkArgument(horizontalFovDegrees > 0 && horizontalFovDegrees <= 360); + + // Compute angular size in radians of each UV quad. + float verticalFovRads = (float) Math.toRadians(verticalFovDegrees); + float horizontalFovRads = (float) Math.toRadians(horizontalFovDegrees); + float quadHeightRads = verticalFovRads / latitudes; + float quadWidthRads = horizontalFovRads / longitudes; + + // Each latitude strip has 2 * (longitudes quads + extra edge) vertices + 2 degenerate vertices. + int vertexCount = (2 * (longitudes + 1) + 2) * latitudes; + // Buffer to return. + float[] vertexData = new float[vertexCount * POSITION_COORDS_PER_VERTEX]; + float[] textureData = new float[vertexCount * TEXTURE_COORDS_PER_VERTEX]; + + // Generate the data for the sphere which is a set of triangle strips representing each + // latitude band. + int vOffset = 0; // Offset into the vertexData array. + int tOffset = 0; // Offset into the textureData array. + // (i, j) represents a quad in the equirectangular sphere. + for (int j = 0; j < latitudes; ++j) { // For each horizontal triangle strip. + // Each latitude band lies between the two phi values. Each vertical edge on a band lies on + // a theta value. + float phiLow = quadHeightRads * j - verticalFovRads / 2; + float phiHigh = quadHeightRads * (j + 1) - verticalFovRads / 2; + + for (int i = 0; i < longitudes + 1; ++i) { // For each vertical edge in the band. + for (int k = 0; k < 2; ++k) { // For low and high points on an edge. + // For each point, determine it's position in polar coordinates. + float phi = k == 0 ? phiLow : phiHigh; + float theta = quadWidthRads * i + (float) Math.PI - horizontalFovRads / 2; + + // Set vertex position data as Cartesian coordinates. + vertexData[vOffset++] = -(float) (radius * Math.sin(theta) * Math.cos(phi)); + vertexData[vOffset++] = (float) (radius * Math.sin(phi)); + vertexData[vOffset++] = (float) (radius * Math.cos(theta) * Math.cos(phi)); + + textureData[tOffset++] = i * quadWidthRads / horizontalFovRads; + textureData[tOffset++] = (j + k) * quadHeightRads / verticalFovRads; + + // Break up the triangle strip with degenerate vertices by copying first and last points. + if ((i == 0 && k == 0) || (i == longitudes && k == 1)) { + System.arraycopy( + vertexData, + vOffset - POSITION_COORDS_PER_VERTEX, + vertexData, + vOffset, + POSITION_COORDS_PER_VERTEX); + vOffset += POSITION_COORDS_PER_VERTEX; + System.arraycopy( + textureData, + tOffset - TEXTURE_COORDS_PER_VERTEX, + textureData, + tOffset, + TEXTURE_COORDS_PER_VERTEX); + tOffset += TEXTURE_COORDS_PER_VERTEX; + } + } + // Move on to the next vertical edge in the triangle strip. + } + // Move on to the next triangle strip. + } + SubMesh subMesh = + new SubMesh(SubMesh.VIDEO_TEXTURE_ID, vertexData, textureData, DRAW_MODE_TRIANGLES_STRIP); + return new Projection(new Mesh(subMesh), stereoMode); + } + + /** The Mesh corresponding to the left eye. */ + public final Mesh leftMesh; + /** + * The Mesh corresponding to the right eye. If {@code singleMesh} is true then this mesh is + * identical to {@link #leftMesh}. + */ + public final Mesh rightMesh; + /** The stereo mode. */ + public final @StereoMode int stereoMode; + /** Whether the left and right mesh are identical. */ + public final boolean singleMesh; + + /** + * Creates a Projection with single mesh. + * + * @param mesh the Mesh for both eyes. + * @param stereoMode A {@link StereoMode} value. + */ + public Projection(Mesh mesh, int stereoMode) { + this(mesh, mesh, stereoMode); + } + + /** + * Creates a Projection with dual mesh. Use {@link #Projection(Mesh, int)} if there is single mesh + * for both eyes. + * + * @param leftMesh the Mesh corresponding to the left eye. + * @param rightMesh the Mesh corresponding to the right eye. + * @param stereoMode A {@link C.StereoMode} value. + */ + public Projection(Mesh leftMesh, Mesh rightMesh, int stereoMode) { + this.leftMesh = leftMesh; + this.rightMesh = rightMesh; + this.stereoMode = stereoMode; + this.singleMesh = leftMesh == rightMesh; + } + + /** The sub mesh associated with the {@link Mesh}. */ + public static final class SubMesh { + /** Texture ID for video frames. */ + public static final int VIDEO_TEXTURE_ID = 0; + + /** Texture ID. */ + public final int textureId; + /** The drawing mode. One of {@link DrawMode}. */ + public final @DrawMode int mode; + /** The SubMesh vertices. */ + public final float[] vertices; + /** The SubMesh texture coordinates. */ + public final float[] textureCoords; + + public SubMesh(int textureId, float[] vertices, float[] textureCoords, @DrawMode int mode) { + this.textureId = textureId; + Assertions.checkArgument( + vertices.length * (long) TEXTURE_COORDS_PER_VERTEX + == textureCoords.length * (long) POSITION_COORDS_PER_VERTEX); + this.vertices = vertices; + this.textureCoords = textureCoords; + this.mode = mode; + } + + /** Returns the SubMesh vertex count. */ + public int getVertexCount() { + return vertices.length / POSITION_COORDS_PER_VERTEX; + } + } + + /** A Mesh associated with the projection scene. */ + public static final class Mesh { + private final SubMesh[] subMeshes; + + public Mesh(SubMesh... subMeshes) { + this.subMeshes = subMeshes; + } + + /** Returns the number of sub meshes. */ + public int getSubMeshCount() { + return subMeshes.length; + } + + /** Returns the SubMesh for the given index. */ + public SubMesh getSubMesh(int index) { + return subMeshes[index]; + } + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java new file mode 100644 index 0000000000..cff4b2845d --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/ProjectionDecoder.java @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical; + +import androidx.annotation.Nullable; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableBitArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.ParsableByteArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical.Projection.Mesh; +import org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical.Projection.SubMesh; +import java.util.ArrayList; +import java.util.zip.Inflater; + +/** + * A decoder for the projection mesh. + * + * <p>The mesh boxes parsed are described at <a + * href="https://github.com/google/spatial-media/blob/master/docs/spherical-video-v2-rfc.md"> + * Spherical Video V2 RFC</a>. + * + * <p>The decoder does not perform CRC checks at the moment. + */ +public final class ProjectionDecoder { + + private static final int TYPE_YTMP = 0x79746d70; + private static final int TYPE_MSHP = 0x6d736870; + private static final int TYPE_RAW = 0x72617720; + private static final int TYPE_DFL8 = 0x64666c38; + private static final int TYPE_MESH = 0x6d657368; + private static final int TYPE_PROJ = 0x70726f6a; + + // Sanity limits to prevent a bad file from creating an OOM situation. We don't expect a mesh to + // exceed these limits. + private static final int MAX_COORDINATE_COUNT = 10000; + private static final int MAX_VERTEX_COUNT = 32 * 1000; + private static final int MAX_TRIANGLE_INDICES = 128 * 1000; + + private ProjectionDecoder() {} + + /* + * Decodes the projection data. + * + * @param projectionData The projection data. + * @param stereoMode A {@link C.StereoMode} value. + * @return The projection or null if the data can't be decoded. + */ + public static @Nullable Projection decode(byte[] projectionData, @C.StereoMode int stereoMode) { + ParsableByteArray input = new ParsableByteArray(projectionData); + // MP4 containers include the proj box but webm containers do not. + // Both containers use mshp. + ArrayList<Mesh> meshes = null; + try { + meshes = isProj(input) ? parseProj(input) : parseMshp(input); + } catch (ArrayIndexOutOfBoundsException ignored) { + // Do nothing. + } + if (meshes == null) { + return null; + } else { + switch (meshes.size()) { + case 1: + return new Projection(meshes.get(0), stereoMode); + case 2: + return new Projection(meshes.get(0), meshes.get(1), stereoMode); + case 0: + default: + return null; + } + } + } + + /** Returns true if the input contains a proj box. Indicates MP4 container. */ + private static boolean isProj(ParsableByteArray input) { + input.skipBytes(4); // size + int type = input.readInt(); + input.setPosition(0); + return type == TYPE_PROJ; + } + + private static @Nullable ArrayList<Mesh> parseProj(ParsableByteArray input) { + input.skipBytes(8); // size and type. + int position = input.getPosition(); + int limit = input.limit(); + while (position < limit) { + int childEnd = position + input.readInt(); + if (childEnd <= position || childEnd > limit) { + return null; + } + int childAtomType = input.readInt(); + // Some early files named the atom ytmp rather than mshp. + if (childAtomType == TYPE_YTMP || childAtomType == TYPE_MSHP) { + input.setLimit(childEnd); + return parseMshp(input); + } + position = childEnd; + input.setPosition(position); + } + return null; + } + + private static @Nullable ArrayList<Mesh> parseMshp(ParsableByteArray input) { + int version = input.readUnsignedByte(); + if (version != 0) { + return null; + } + input.skipBytes(7); // flags + crc. + int encoding = input.readInt(); + if (encoding == TYPE_DFL8) { + ParsableByteArray output = new ParsableByteArray(); + Inflater inflater = new Inflater(true); + try { + if (!Util.inflate(input, output, inflater)) { + return null; + } + } finally { + inflater.end(); + } + input = output; + } else if (encoding != TYPE_RAW) { + return null; + } + return parseRawMshpData(input); + } + + /** Parses MSHP data after the encoding_four_cc field. */ + private static @Nullable ArrayList<Mesh> parseRawMshpData(ParsableByteArray input) { + ArrayList<Mesh> meshes = new ArrayList<>(); + int position = input.getPosition(); + int limit = input.limit(); + while (position < limit) { + int childEnd = position + input.readInt(); + if (childEnd <= position || childEnd > limit) { + return null; + } + int childAtomType = input.readInt(); + if (childAtomType == TYPE_MESH) { + Mesh mesh = parseMesh(input); + if (mesh == null) { + return null; + } + meshes.add(mesh); + } + position = childEnd; + input.setPosition(position); + } + return meshes; + } + + private static @Nullable Mesh parseMesh(ParsableByteArray input) { + // Read the coordinates. + int coordinateCount = input.readInt(); + if (coordinateCount > MAX_COORDINATE_COUNT) { + return null; + } + float[] coordinates = new float[coordinateCount]; + for (int coordinate = 0; coordinate < coordinateCount; coordinate++) { + coordinates[coordinate] = input.readFloat(); + } + // Read the vertices. + int vertexCount = input.readInt(); + if (vertexCount > MAX_VERTEX_COUNT) { + return null; + } + + final double log2 = Math.log(2.0); + int coordinateCountSizeBits = (int) Math.ceil(Math.log(2.0 * coordinateCount) / log2); + + ParsableBitArray bitInput = new ParsableBitArray(input.data); + bitInput.setPosition(input.getPosition() * 8); + float[] vertices = new float[vertexCount * 5]; + int[] coordinateIndices = new int[5]; + int vertexIndex = 0; + for (int vertex = 0; vertex < vertexCount; vertex++) { + for (int i = 0; i < 5; i++) { + int coordinateIndex = + coordinateIndices[i] + decodeZigZag(bitInput.readBits(coordinateCountSizeBits)); + if (coordinateIndex >= coordinateCount || coordinateIndex < 0) { + return null; + } + vertices[vertexIndex++] = coordinates[coordinateIndex]; + coordinateIndices[i] = coordinateIndex; + } + } + + // Pad to next byte boundary + bitInput.setPosition(((bitInput.getPosition() + 7) & ~7)); + + int subMeshCount = bitInput.readBits(32); + SubMesh[] subMeshes = new SubMesh[subMeshCount]; + for (int i = 0; i < subMeshCount; i++) { + int textureId = bitInput.readBits(8); + int drawMode = bitInput.readBits(8); + int triangleIndexCount = bitInput.readBits(32); + if (triangleIndexCount > MAX_TRIANGLE_INDICES) { + return null; + } + int vertexCountSizeBits = (int) Math.ceil(Math.log(2.0 * vertexCount) / log2); + int index = 0; + float[] triangleVertices = new float[triangleIndexCount * 3]; + float[] textureCoords = new float[triangleIndexCount * 2]; + for (int counter = 0; counter < triangleIndexCount; counter++) { + index += decodeZigZag(bitInput.readBits(vertexCountSizeBits)); + if (index < 0 || index >= vertexCount) { + return null; + } + triangleVertices[counter * 3] = vertices[index * 5]; + triangleVertices[counter * 3 + 1] = vertices[index * 5 + 1]; + triangleVertices[counter * 3 + 2] = vertices[index * 5 + 2]; + textureCoords[counter * 2] = vertices[index * 5 + 3]; + textureCoords[counter * 2 + 1] = vertices[index * 5 + 4]; + } + subMeshes[i] = new SubMesh(textureId, triangleVertices, textureCoords, drawMode); + } + return new Mesh(subMeshes); + } + + /** + * Decodes Zigzag encoding as described in + * https://developers.google.com/protocol-buffers/docs/encoding#signed-integers + */ + private static int decodeZigZag(int n) { + return (n >> 1) ^ -(n & 1); + } +} diff --git a/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/package-info.java b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/package-info.java new file mode 100644 index 0000000000..7ab7fced0b --- /dev/null +++ b/mobile/android/exoplayer2/src/main/java/org/mozilla/thirdparty/com/google/android/exoplayer2/video/spherical/package-info.java @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@NonNullApi +package org.mozilla.thirdparty.com.google.android.exoplayer2.video.spherical; + +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.NonNullApi; diff --git a/mobile/android/fonts/CharisSILCompact-B.ttf b/mobile/android/fonts/CharisSILCompact-B.ttf Binary files differnew file mode 100644 index 0000000000..eade6d0240 --- /dev/null +++ b/mobile/android/fonts/CharisSILCompact-B.ttf diff --git a/mobile/android/fonts/CharisSILCompact-BI.ttf b/mobile/android/fonts/CharisSILCompact-BI.ttf Binary files differnew file mode 100644 index 0000000000..b7ef2b2acf --- /dev/null +++ b/mobile/android/fonts/CharisSILCompact-BI.ttf diff --git a/mobile/android/fonts/CharisSILCompact-I.ttf b/mobile/android/fonts/CharisSILCompact-I.ttf Binary files differnew file mode 100644 index 0000000000..6148b6542f --- /dev/null +++ b/mobile/android/fonts/CharisSILCompact-I.ttf diff --git a/mobile/android/fonts/CharisSILCompact-R.ttf b/mobile/android/fonts/CharisSILCompact-R.ttf Binary files differnew file mode 100644 index 0000000000..167a6901c7 --- /dev/null +++ b/mobile/android/fonts/CharisSILCompact-R.ttf diff --git a/mobile/android/fonts/moz.build b/mobile/android/fonts/moz.build new file mode 100644 index 0000000000..b0a20cf188 --- /dev/null +++ b/mobile/android/fonts/moz.build @@ -0,0 +1,16 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +if not CONFIG["MOZ_ANDROID_EXCLUDE_FONTS"]: + RESOURCE_FILES.fonts += [ + "CharisSILCompact-B.ttf", + "CharisSILCompact-BI.ttf", + "CharisSILCompact-I.ttf", + "CharisSILCompact-R.ttf", + ] diff --git a/mobile/android/geckoview/api.txt b/mobile/android/geckoview/api.txt new file mode 100644 index 0000000000..86869c0abe --- /dev/null +++ b/mobile/android/geckoview/api.txt @@ -0,0 +1,2979 @@ +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.DragEvent; +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.Comparable; +import java.lang.Double; +import java.lang.Exception; +import java.lang.Float; +import java.lang.FunctionalInterface; +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.Thread; +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.CrashHandler; +import org.mozilla.geckoview.ExperimentDelegate; +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.TranslationsController; +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 EMAIL = 512; + 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 = 766; + 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 boolean getCookieBannerGlobalRulesEnabled(); + method public boolean getCookieBannerGlobalRulesSubFramesEnabled(); + method public int getCookieBannerMode(); + method public int getCookieBannerModePrivateBrowsing(); + method public int getCookieBehavior(); + method public int getCookieBehaviorPrivateMode(); + method public boolean getCookiePurging(); + method @NonNull public Boolean getEmailTrackerBlockingPrivateBrowsingEnabled(); + method public int getEnhancedTrackingProtectionLevel(); + method @NonNull public String[] getQueryParameterStrippingAllowList(); + method public boolean getQueryParameterStrippingEnabled(); + method public boolean getQueryParameterStrippingPrivateBrowsingEnabled(); + method @NonNull public String[] getQueryParameterStrippingStripList(); + 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 setCookieBannerGlobalRulesEnabled(boolean); + method @NonNull public ContentBlocking.Settings setCookieBannerGlobalRulesSubFramesEnabled(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 setEmailTrackerBlockingPrivateBrowsing(boolean); + method @NonNull public ContentBlocking.Settings setEnhancedTrackingProtectionLevel(int); + method @NonNull public ContentBlocking.Settings setQueryParameterStrippingAllowList(@NonNull String...); + method @NonNull public ContentBlocking.Settings setQueryParameterStrippingEnabled(boolean); + method @NonNull public ContentBlocking.Settings setQueryParameterStrippingPrivateBrowsingEnabled(boolean); + method @NonNull public ContentBlocking.Settings setQueryParameterStrippingStripList(@NonNull String...); + 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 cookieBannerGlobalRulesEnabled(boolean); + method @NonNull public ContentBlocking.Settings.Builder cookieBannerGlobalRulesSubFramesEnabled(boolean); + 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 emailTrackerBlockingPrivateMode(boolean); + method @NonNull public ContentBlocking.Settings.Builder enhancedTrackingProtectionLevel(int); + method @NonNull public ContentBlocking.Settings.Builder queryParameterStrippingAllowList(@NonNull String...); + method @NonNull public ContentBlocking.Settings.Builder queryParameterStrippingEnabled(boolean); + method @NonNull public ContentBlocking.Settings.Builder queryParameterStrippingPrivateBrowsingEnabled(boolean); + method @NonNull public ContentBlocking.Settings.Builder queryParameterStrippingStripList(@NonNull String...); + 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_EMAILTRACKING_CONTENT = 4194304; + 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_EMAILTRACKING_LEVEL_1_CONTENT = 8388608; + field public static final int LOADED_EMAILTRACKING_LEVEL_2_CONTENT = 256; + 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 { + } + + @FunctionalInterface public class CrashHandler implements Thread.UncaughtExceptionHandler { + ctor public CrashHandler(@Nullable Class<? extends android.app.Service>); + ctor public CrashHandler(@Nullable Context, @Nullable Class<? extends android.app.Service>); + ctor public CrashHandler(Thread, Class<? extends android.app.Service>); + ctor public CrashHandler(@Nullable Thread, Context, Class<? extends android.app.Service>); + method @AnyThread @NonNull public static CrashHandler createDefaultCrashHandler(@NonNull Context); + method @AnyThread @Nullable public Context getAppContext(); + method @AnyThread @Nullable public String getAppPackageName(); + method @AnyThread @NonNull public byte[] getCrashDump(@Nullable Thread, @Nullable Throwable); + method @AnyThread @NonNull public Bundle getCrashExtras(@NonNull Thread, @NonNull Throwable); + method @AnyThread @NonNull public static String getExceptionStackTrace(@NonNull Throwable); + method @AnyThread @NonNull public static Throwable getRootException(@NonNull Throwable); + method @AnyThread @NonNull public String getServerUrl(@NonNull Bundle); + method @AnyThread public boolean launchCrashReporter(@NonNull String, @NonNull String); + method @AnyThread public static void logException(@NonNull Thread, @NonNull Throwable); + method @AnyThread public boolean reportException(@NonNull Thread, @NonNull Throwable); + method @AnyThread public static void terminateProcess(); + method @AnyThread public void unregister(); + } + + 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 interface ExperimentDelegate { + method @AnyThread @NonNull default public GeckoResult<JSONObject> onGetExperimentFeature(@NonNull String); + method @AnyThread @NonNull default public GeckoResult<Void> onRecordExperimentExposureEvent(@NonNull String, @NonNull String); + method @AnyThread @NonNull default public GeckoResult<Void> onRecordExposureEvent(@NonNull String); + method @AnyThread @NonNull default public GeckoResult<Void> onRecordMalformedConfigurationEvent(@NonNull String, @NonNull String); + } + + public static class ExperimentDelegate.ExperimentException extends Exception { + ctor public ExperimentException(int); + field public static final int ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED = -4; + field public static final int ERROR_EXPERIMENT_SLUG_NOT_FOUND = -3; + field public static final int ERROR_FEATURE_NOT_FOUND = -2; + field public static final int ERROR_UNKNOWN = -1; + field public final int code; + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface ExperimentDelegate.ExperimentException.Codes { + } + + 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_CRASH_REMOTE_TYPE = "remoteType"; + 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 @AnyThread @Nullable public ExperimentDelegate getExperimentDelegate(); + method @Nullable public Integer getExtensionsProcessCrashThreshold(); + method @Nullable public Long getExtensionsProcessCrashTimeframe(); + method @Nullable public Boolean getExtensionsProcessEnabled(); + method public boolean getExtensionsWebAPIEnabled(); + 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 getGlobalPrivacyControl(); + method public boolean getGlobalPrivacyControlPrivateMode(); + method public boolean getInputAutoZoomEnabled(); + method public boolean getJavaScriptEnabled(); + method @NonNull public int getLargeKeepaliveFactor(); + 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 getTranslationsOfferPopup(); + method @NonNull public String getTrustedRecursiveResolverUri(); + method public int getTrustedRecusiveResolverMode(); + 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 setExtensionsProcessCrashThreshold(@NonNull Integer); + method @NonNull public GeckoRuntimeSettings setExtensionsProcessCrashTimeframe(@NonNull Long); + method @NonNull public GeckoRuntimeSettings setExtensionsProcessEnabled(boolean); + method @NonNull public GeckoRuntimeSettings setExtensionsWebAPIEnabled(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 setGlobalPrivacyControl(boolean); + method @NonNull public GeckoRuntimeSettings setInputAutoZoomEnabled(boolean); + method @NonNull public GeckoRuntimeSettings setJavaScriptEnabled(boolean); + method @NonNull public GeckoRuntimeSettings setLargeKeepaliveFactor(int); + 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 setTranslationsOfferPopup(boolean); + method @NonNull public GeckoRuntimeSettings setTrustedRecursiveResolverMode(int); + method @NonNull public GeckoRuntimeSettings setTrustedRecursiveResolverUri(@NonNull String); + 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; + field public static final int TRR_MODE_DISABLED = 5; + field public static final int TRR_MODE_FIRST = 2; + field public static final int TRR_MODE_OFF = 0; + field public static final int TRR_MODE_ONLY = 3; + } + + @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 @AnyThread @NonNull public GeckoRuntimeSettings.Builder experimentDelegate(@Nullable ExperimentDelegate); + method @NonNull public GeckoRuntimeSettings.Builder extensionsProcessCrashThreshold(@NonNull Integer); + method @NonNull public GeckoRuntimeSettings.Builder extensionsProcessCrashTimeframe(@NonNull Long); + method @NonNull public GeckoRuntimeSettings.Builder extensionsProcessEnabled(boolean); + method @NonNull public GeckoRuntimeSettings.Builder extensionsWebAPIEnabled(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 globalPrivacyControlEnabled(boolean); + method @NonNull public GeckoRuntimeSettings.Builder inputAutoZoomEnabled(boolean); + method @NonNull public GeckoRuntimeSettings.Builder javaScriptEnabled(boolean); + method @NonNull public GeckoRuntimeSettings.Builder largeKeepaliveFactor(int); + 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 translationsOfferPopup(boolean); + method @NonNull public GeckoRuntimeSettings.Builder trustedRecursiveResolverMode(int); + method @NonNull public GeckoRuntimeSettings.Builder trustedRecursiveResolverUri(@NonNull String); + 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 { + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoRuntimeSettings.TrustedRecursiveResolverMode { + } + + 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 @NonNull public GeckoResult<Boolean> didPrintPageContent(); + 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 @Nullable public ExperimentDelegate getExperimentDelegate(); + 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 @Nullable public TranslationsController.SessionTranslation getSessionTranslation(); + method @AnyThread @NonNull public GeckoSessionSettings getSettings(); + method @UiThread public void getSurfaceBounds(@NonNull Rect); + method @AnyThread @NonNull public SessionTextInput getTextInput(); + method @AnyThread @Nullable public TranslationsController.SessionTranslation.Delegate getTranslationsSessionDelegate(); + 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 @NonNull public GeckoResult<String> pollForAnalysisCompleted(@NonNull String); + 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 @NonNull public GeckoResult<String> reportBackInStock(@NonNull String); + method @AnyThread @NonNull public GeckoResult<GeckoSession.ReviewAnalysis> requestAnalysis(@NonNull String); + method @AnyThread @NonNull public GeckoResult<GeckoSession.AnalysisStatusResponse> requestAnalysisStatus(@NonNull String); + method @AnyThread @NonNull public GeckoResult<String> requestCreateAnalysis(@NonNull String); + method @AnyThread @NonNull public GeckoResult<List<GeckoSession.Recommendation>> requestRecommendations(@NonNull String); + method @AnyThread public void restoreState(@NonNull GeckoSession.SessionState); + method @AnyThread @NonNull public GeckoResult<InputStream> saveAsPdf(); + method @AnyThread @NonNull public GeckoResult<Boolean> sendClickAttributionEvent(@NonNull String); + method @AnyThread @NonNull public GeckoResult<Boolean> sendImpressionAttributionEvent(@NonNull String); + method @AnyThread @NonNull public GeckoResult<Boolean> sendPlacementAttributionEvent(@NonNull String); + 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 setExperimentDelegate(@Nullable ExperimentDelegate); + 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 setTranslationsSessionDelegate(@Nullable TranslationsController.SessionTranslation.Delegate); + 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; + } + + @AnyThread public static class GeckoSession.AnalysisStatusResponse { + ctor protected AnalysisStatusResponse(@NonNull GeckoSession.AnalysisStatusResponse.Builder); + field @NonNull public final Double progress; + field @NonNull public final String status; + } + + public static class GeckoSession.AnalysisStatusResponse.Builder { + ctor public Builder(@NonNull String); + method @AnyThread @NonNull public GeckoSession.AnalysisStatusResponse build(); + method @AnyThread @NonNull public GeckoSession.AnalysisStatusResponse.Builder progress(@NonNull Double); + method @AnyThread @NonNull public GeckoSession.AnalysisStatusResponse.Builder status(@NonNull String); + } + + @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 @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 onProductUrl(@NonNull GeckoSession); + 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_NO_PRINT_DELEGATE = -6; + 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> onSelectIdentityCredentialAccount(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt); + method @Nullable @UiThread default public GeckoResult<GeckoSession.PromptDelegate.PromptResponse> onSelectIdentityCredentialProvider(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt); + 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> onShowPrivacyPolicyIdentityCredential(@NonNull GeckoSession, @NonNull GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt); + 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 final class GeckoSession.PromptDelegate.IdentityCredential { + ctor public IdentityCredential(); + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt extends GeckoSession.PromptDelegate.BasePrompt { + ctor public AccountSelectorPrompt(@NonNull String, @NonNull GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Account[], @NonNull GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Provider, GeckoSession.PromptDelegate.BasePrompt.Observer); + method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(@NonNull int); + field @NonNull public final GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Account[] accounts; + field @NonNull public final GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Provider provider; + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Account { + ctor public Account(int, @NonNull String, @NonNull String, @Nullable String); + field @NonNull public final String email; + field @Nullable public final String icon; + field public final int id; + field @NonNull public final String name; + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Provider { + ctor public Provider(@NonNull String, @NonNull String, @Nullable String); + field @NonNull public final String domain; + field @Nullable public final String icon; + field @NonNull public final String name; + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.ProviderAccounts { + ctor public ProviderAccounts(int, @Nullable GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Provider, @NonNull GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Account[]); + field @NonNull public final GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Account[] accounts; + field public final int id; + field @Nullable public final GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt.Provider provider; + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt extends GeckoSession.PromptDelegate.BasePrompt { + ctor protected PrivacyPolicyPrompt(@NonNull String, @NonNull String, @NonNull String, @NonNull String, @NonNull String, @Nullable String, @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer); + method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(boolean); + field @NonNull public final String host; + field @Nullable public final String icon; + field @NonNull public final String privacyPolicyUrl; + field @NonNull public final String providerDomain; + field @NonNull public final String termsOfServiceUrl; + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt extends GeckoSession.PromptDelegate.BasePrompt { + ctor protected ProviderSelectorPrompt(@NonNull String, @NonNull GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt.Provider[], @NonNull GeckoSession.PromptDelegate.BasePrompt.Observer); + method @NonNull @UiThread public GeckoSession.PromptDelegate.PromptResponse confirm(int); + field @NonNull public final GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt.Provider[] providers; + } + + public static class GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt.Provider { + ctor public Provider(int, @NonNull String, @Nullable String, @NonNull String); + field @NonNull public final String domain; + field @Nullable public final String icon; + field public final int id; + field @NonNull public final String name; + } + + 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; + } + + @AnyThread public static class GeckoSession.Recommendation { + ctor protected Recommendation(@NonNull GeckoSession.Recommendation.Builder); + field @NonNull public final Double adjustedRating; + field @NonNull public final String aid; + field @NonNull public final String analysisUrl; + field @NonNull public final String currency; + field @NonNull public final String grade; + field @NonNull public final String imageUrl; + field @NonNull public final String name; + field @NonNull public final String price; + field @NonNull public final Boolean sponsored; + field @NonNull public final String url; + } + + public static class GeckoSession.Recommendation.Builder { + ctor public Builder(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder adjustedRating(@NonNull Double); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder aid(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder analysisUrl(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation build(); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder currency(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder grade(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder imageUrl(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder name(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder price(@NonNull String); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder sponsored(@NonNull Boolean); + method @AnyThread @NonNull public GeckoSession.Recommendation.Builder url(@NonNull String); + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface GeckoSession.RestartReason { + } + + @AnyThread public static class GeckoSession.ReviewAnalysis { + ctor protected ReviewAnalysis(@NonNull GeckoSession.ReviewAnalysis.Builder); + field @Nullable public final Double adjustedRating; + field @Nullable public final String analysisURL; + field public final boolean deletedProduct; + field public final boolean deletedProductReported; + field @Nullable public final String grade; + field @Nullable public final GeckoSession.ReviewAnalysis.Highlight highlights; + field public final long lastAnalysisTime; + field public final boolean needsAnalysis; + field public final boolean notEnoughReviews; + field public final boolean pageNotSupported; + field @Nullable public final String productId; + } + + public static class GeckoSession.ReviewAnalysis.Builder { + ctor public Builder(@Nullable String); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder adjustedRating(@NonNull Double); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder analysisUrl(@Nullable String); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis build(); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder deletedProduct(@NonNull Boolean); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder deletedProductReported(@NonNull Boolean); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder grade(@Nullable String); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder highlights(@Nullable GeckoSession.ReviewAnalysis.Highlight); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder lastAnalysisTime(long); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder needsAnalysis(@NonNull Boolean); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder notEnoughReviews(@NonNull Boolean); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder pageNotSupported(@NonNull Boolean); + method @AnyThread @NonNull public GeckoSession.ReviewAnalysis.Builder productId(@Nullable String); + } + + public static class GeckoSession.ReviewAnalysis.Highlight { + ctor protected Highlight(); + field @Nullable public final String[] appearance; + field @Nullable public final String[] competitiveness; + field @Nullable public final String[] price; + field @Nullable public final String[] quality; + field @Nullable public final String[] shipping; + } + + 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 boolean onDragEvent(@NonNull DragEvent); + 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 TranslationsController { + ctor public TranslationsController(); + } + + public static class TranslationsController.Language implements Comparable<TranslationsController.Language> { + ctor public Language(@NonNull String, @Nullable String); + method @AnyThread public int compareTo(@Nullable TranslationsController.Language); + field @NonNull public final String code; + field @Nullable public final String localizedDisplayName; + } + + public static class TranslationsController.RuntimeTranslation { + ctor public RuntimeTranslation(); + method @AnyThread @NonNull public static GeckoResult<Long> checkPairDownloadSize(@NonNull String, @NonNull String); + method @AnyThread @NonNull public static GeckoResult<Long> checkPairDownloadSize(@NonNull TranslationsController.SessionTranslation.TranslationPair); + method @AnyThread @NonNull public static GeckoResult<String> getLanguageSetting(@NonNull String); + method @AnyThread @NonNull public static GeckoResult<Map<String,String>> getLanguageSettings(); + method @AnyThread @NonNull public static GeckoResult<List<String>> getNeverTranslateSiteList(); + method @AnyThread @NonNull public static GeckoResult<Boolean> isTranslationsEngineSupported(); + method @AnyThread @NonNull public static GeckoResult<List<TranslationsController.RuntimeTranslation.LanguageModel>> listModelDownloadStates(); + method @AnyThread @NonNull public static GeckoResult<TranslationsController.RuntimeTranslation.TranslationSupport> listSupportedLanguages(); + method @AnyThread @NonNull public static GeckoResult<Void> manageLanguageModel(@NonNull TranslationsController.RuntimeTranslation.ModelManagementOptions); + method @AnyThread @NonNull public static GeckoResult<List<String>> preferredLanguages(); + method @AnyThread @NonNull public static GeckoResult<Void> setLanguageSettings(@NonNull String, @NonNull String); + method @AnyThread @NonNull public static GeckoResult<Void> setNeverTranslateSpecifiedSite(@NonNull Boolean, @NonNull String); + field public static final String ALL = "all"; + field public static final String ALWAYS = "always"; + field public static final String CACHE = "cache"; + field public static final String DELETE = "delete"; + field public static final String DOWNLOAD = "download"; + field public static final String LANGUAGE = "language"; + field public static final String NEVER = "never"; + field public static final String OFFER = "offer"; + } + + public static class TranslationsController.RuntimeTranslation.LanguageModel { + ctor public LanguageModel(@Nullable TranslationsController.Language, Boolean, long); + field @NonNull public final Boolean isDownloaded; + field @Nullable public final TranslationsController.Language language; + field public final long size; + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface TranslationsController.RuntimeTranslation.LanguageSetting { + } + + @AnyThread public static class TranslationsController.RuntimeTranslation.ModelManagementOptions { + ctor protected ModelManagementOptions(@NonNull TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder); + field @Nullable public final String language; + field @NonNull public final String operation; + field @NonNull public final String operationLevel; + } + + @AnyThread public static class TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder { + ctor public Builder(); + method @AnyThread @NonNull public TranslationsController.RuntimeTranslation.ModelManagementOptions build(); + method @NonNull public TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder languageToManage(@NonNull String); + method @NonNull public TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder operation(@NonNull String); + method @NonNull public TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder operationLevel(@NonNull String); + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface TranslationsController.RuntimeTranslation.ModelOperation { + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface TranslationsController.RuntimeTranslation.OperationLevel { + } + + public static class TranslationsController.RuntimeTranslation.TranslationSupport { + ctor public TranslationSupport(@Nullable List<TranslationsController.Language>, @Nullable List<TranslationsController.Language>); + field @Nullable public final List<TranslationsController.Language> fromLanguages; + field @Nullable public final List<TranslationsController.Language> toLanguages; + } + + public static class TranslationsController.SessionTranslation { + ctor public SessionTranslation(GeckoSession); + method @AnyThread @NonNull public TranslationsController.SessionTranslation.Handler getHandler(); + method @AnyThread @NonNull public GeckoResult<Boolean> getNeverTranslateSiteSetting(); + method @AnyThread @NonNull public GeckoResult<Void> restoreOriginalPage(); + method @AnyThread @NonNull public GeckoResult<Void> setNeverTranslateSiteSetting(@NonNull Boolean); + method @AnyThread @NonNull public GeckoResult<Void> translate(@NonNull String, @NonNull String, @Nullable TranslationsController.SessionTranslation.TranslationOptions); + method @AnyThread @NonNull public GeckoResult<Void> translate(@NonNull TranslationsController.SessionTranslation.TranslationPair, @Nullable TranslationsController.SessionTranslation.TranslationOptions); + } + + @AnyThread public static interface TranslationsController.SessionTranslation.Delegate { + method default public void onExpectedTranslate(@NonNull GeckoSession); + method default public void onOfferTranslate(@NonNull GeckoSession); + method default public void onTranslationStateChange(@NonNull GeckoSession, @Nullable TranslationsController.SessionTranslation.TranslationState); + } + + public static class TranslationsController.SessionTranslation.DetectedLanguages { + ctor public DetectedLanguages(@Nullable String, @NonNull Boolean, @Nullable String); + field @Nullable public final String docLangTag; + field @NonNull public final Boolean isDocLangTagSupported; + field @Nullable public final String userLangTag; + } + + @AnyThread public static class TranslationsController.SessionTranslation.TranslationOptions { + ctor protected TranslationOptions(@NonNull TranslationsController.SessionTranslation.TranslationOptions.Builder); + field @NonNull public final boolean downloadModel; + } + + @AnyThread public static class TranslationsController.SessionTranslation.TranslationOptions.Builder { + ctor public Builder(); + method @AnyThread @NonNull public TranslationsController.SessionTranslation.TranslationOptions build(); + method @NonNull public TranslationsController.SessionTranslation.TranslationOptions.Builder downloadModel(@NonNull boolean); + } + + public static class TranslationsController.SessionTranslation.TranslationPair { + ctor public TranslationPair(@Nullable String, @Nullable String); + field @Nullable public final String fromLanguage; + field @Nullable public final String toLanguage; + } + + public static class TranslationsController.SessionTranslation.TranslationState { + ctor public TranslationState(@Nullable TranslationsController.SessionTranslation.TranslationPair, @Nullable String, @Nullable TranslationsController.SessionTranslation.DetectedLanguages, @NonNull Boolean); + field @Nullable public final TranslationsController.SessionTranslation.DetectedLanguages detectedLanguages; + field @Nullable public final String error; + field @NonNull public final Boolean isEngineReady; + field @Nullable public final TranslationsController.SessionTranslation.TranslationPair requestedTranslationPair; + } + + public static class TranslationsController.TranslationsException extends Exception { + ctor public TranslationsException(int); + field public static final int ERROR_COULD_NOT_LOAD_LANGUAGES = -5; + field public static final int ERROR_COULD_NOT_RESTORE = -4; + field public static final int ERROR_COULD_NOT_TRANSLATE = -3; + field public static final int ERROR_ENGINE_NOT_SUPPORTED = -2; + field public static final int ERROR_LANGUAGE_NOT_SUPPORTED = -6; + field public static final int ERROR_MODEL_COULD_NOT_DELETE = -8; + field public static final int ERROR_MODEL_COULD_NOT_DOWNLOAD = -9; + field public static final int ERROR_MODEL_COULD_NOT_RETRIEVE = -7; + field public static final int ERROR_MODEL_DOWNLOAD_REQUIRED = -11; + field public static final int ERROR_MODEL_LANGUAGE_REQUIRED = -10; + field public static final int ERROR_UNKNOWN = -1; + field public final int code; + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface TranslationsController.TranslationsException.Code { + } + + 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 APP_VERSION = 32; + field public static final int BLOCKLIST = 4; + field public static final int SIGNATURE = 16; + 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; + field @Nullable public final String extensionName; + } + + @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_BLOCKLISTED = -10; + field public static final int ERROR_CORRUPT_FILE = -3; + field public static final int ERROR_FILE_ACCESS = -4; + field public static final int ERROR_INCOMPATIBLE = -11; + 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_UNSUPPORTED_ADDON_TYPE = -12; + 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 @Nullable public final String amoListingUrl; + field public final double averageRating; + 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 @Nullable public final String downloadUrl; + field public final boolean enabled; + field @Nullable public final String fullDescription; + field @Nullable public final String homepageUrl; + field @NonNull public final Image icon; + field @Nullable public final String incognito; + 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 reviewCount; + field @Nullable public final String reviewUrl; + field public final int signedState; + field public final boolean temporary; + field @Nullable public final String updateDate; + 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 public void disableExtensionProcessSpawning(); + method @AnyThread @NonNull public GeckoResult<WebExtension> enable(@NonNull WebExtension, int); + method @AnyThread public void enableExtensionProcessSpawning(); + 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, @Nullable String); + 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 setExtensionProcessDelegate(@Nullable WebExtensionController.ExtensionProcessDelegate); + 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); + field public static final String INSTALLATION_METHOD_FROM_FILE = "install-from-file"; + field public static final String INSTALLATION_METHOD_MANAGER = "manager"; + } + + 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 onInstallationFailed(@Nullable WebExtension, @NonNull WebExtension.InstallException); + method @UiThread default public void onInstalled(@NonNull WebExtension); + method @UiThread default public void onInstalling(@NonNull WebExtension); + method @UiThread default public void onReady(@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 { + } + + public static interface WebExtensionController.ExtensionProcessDelegate { + method @UiThread default public void onDisabledProcessSpawning(); + } + + @Retention(value=RetentionPolicy.SOURCE) public static interface WebExtensionController.InstallationMethod { + } + + @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..32224dd5a2 --- /dev/null +++ b/mobile/android/geckoview/build.gradle @@ -0,0 +1,569 @@ +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:\" + ${mozconfig.defines.MOZILLA_UAVERSION} + \") 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:\" + ${mozconfig.defines.MOZILLA_UAVERSION} + \") 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_17 + targetCompatibility JavaVersion.VERSION_17 + } + + 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 "${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" + } + } + } + } + + buildFeatures { + buildConfig true + aidl = true + } + + namespace 'org.mozilla.geckoview' +} + +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_17 + } + + 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-test:1.7.1" + 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 += files(android.getBootClasspath()) + 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) { + archiveClassifier = '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() +println("GeckoView version = " + version) +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 Binary files differnew file mode 100644 index 0000000000..dbed714c56 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32-light.png 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 Binary files differnew file mode 100644 index 0000000000..89863ccec7 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/beasts-32.png 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 Binary files differnew file mode 100644 index 0000000000..aea2c19784 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/expected.png 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 Binary files differnew file mode 100644 index 0000000000..90687de26d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-19.png 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 Binary files differnew file mode 100644 index 0000000000..90687de26d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/actions/button/geo-38.png 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 Binary files differnew file mode 100644 index 0000000000..19ce0d7f0f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-missing-id.xpi 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 Binary files differnew file mode 100644 index 0000000000..fd395d13df --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.xpi diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.zip b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.zip Binary files differnew file mode 100644 index 0000000000..fd395d13df --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify-unsigned.zip diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi Binary files differnew file mode 100644 index 0000000000..1ed97f1047 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify.xpi 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 Binary files differnew file mode 100644 index 0000000000..90687de26d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/icons/border-48.png 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..4e3daf6708 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/borderify/manifest.json @@ -0,0 +1,23 @@ +{ + "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"] + } + ], + "options_ui": { + "page": "dummy.html" + } +} 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-incompatible.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy-incompatible.xpi Binary files differnew file mode 100644 index 0000000000..93c5dbd3b2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy-incompatible.xpi diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi Binary files differnew file mode 100644 index 0000000000..0e0f549ceb --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/dummy.xpi 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/langpack_signed.xpi b/mobile/android/geckoview/src/androidTest/assets/web_extensions/langpack_signed.xpi Binary files differnew file mode 100644 index 0000000000..f60d00348e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/langpack_signed.xpi 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 Binary files differnew file mode 100644 index 0000000000..90687de26d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/messaging/icons/border-48.png 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.sys.mjs b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.sys.mjs new file mode 100644 index 0000000000..6dd3e8eed4 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportChild.sys.mjs @@ -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/. */ + +import { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs"; + +export 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); + }); + } + case "WaitForContentTransformsReceived": { + return this.contentWindow.docShell.browserChild.contentTransformsReceived(); + } + } + return null; + } +} + +const { debug } = TestSupportChild.initLogging("GeckoViewTestSupport"); diff --git a/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.sys.mjs b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.sys.mjs new file mode 100644 index 0000000000..0684ef0967 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/TestSupportProcessChild.sys.mjs @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const ProcessTools = Cc["@mozilla.org/processtools-service;1"].getService( + Ci.nsIProcessToolsService +); + +export 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..181764859a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/background.js @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +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); + }, + WaitForContentTransformsReceived({ tab }) { + return browser.test.waitForContentTransformsReceived(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); + }, + TriggerTranslationsOffer({ tab }) { + return browser.test.triggerTranslationsOffer(tab.id); + }, + TriggerLanguageStateChange({ tab, languageState }) { + return browser.test.triggerLanguageStateChange(tab.id, languageState); + }, +}; + +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..fea5add0de --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/manifest.json @@ -0,0 +1,42 @@ +{ + "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>"], + "match_about_blank": true, + "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..1868d25c84 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-api.js @@ -0,0 +1,256 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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: { + esModuleURI: + "resource://android/assets/web_extensions/test-support/TestSupportChild.sys.mjs", + }, + allFrames: true, + }); + ChromeUtils.registerProcessActor("TestSupportProcess", { + child: { + esModuleURI: + "resource://android/assets/web_extensions/test-support/TestSupportProcessChild.sys.mjs", + }, + }); + } + + 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 waitForContentTransformsReceived(tabId) { + return getActorForTab(tabId).sendQuery( + "WaitForContentTransformsReceived" + ); + }, + + 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", + }); + }, + + async triggerTranslationsOffer(tabId) { + const browser = context.extension.tabManager.get(tabId).browser; + const { CustomEvent } = browser.ownerGlobal; + return browser.dispatchEvent( + new CustomEvent("TranslationsParent:OfferTranslation", { + bubbles: true, + }) + ); + }, + + async triggerLanguageStateChange(tabId, languageState) { + const browser = context.extension.tabManager.get(tabId).browser; + const { CustomEvent } = browser.ownerGlobal; + return browser.dispatchEvent( + new CustomEvent("TranslationsParent:LanguageState", { + bubbles: true, + detail: languageState, + }) + ); + }, + }, + }; + } +}; 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..94e4b3bd9b --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-schema.json @@ -0,0 +1,308 @@ +[ + { + "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": "waitForContentTransformsReceived", + "type": "function", + "async": true, + "description": "If we want to test screen coordinates, we need to wait for the updated data which is what this function allows us to do", + "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" + } + ] + }, + + { + "name": "triggerTranslationsOffer", + "type": "function", + "async": true, + "description": "Simulates offering a translation.", + "parameters": [ + { + "type": "number", + "name": "tabId" + } + ] + }, + + { + "name": "triggerLanguageStateChange", + "type": "function", + "async": true, + "description": "Simulates expecting a translation.", + "parameters": [ + { + "type": "number", + "name": "tabId" + }, + { + "name": "languageState", + "type": "object", + "properties": {}, + "additionalProperties": { "type": "any" } + } + ] + } + ] + } +] 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..18e047ca1a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/web_extensions/test-support/test-support.js @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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; + +function connectNativePort() { + if (nativePort) { + return; + } + + 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(), + }); + } +} + +function disconnectNativePort() { + backgroundPort?.disconnect(); + nativePort?.disconnect(); + backgroundPort = null; + nativePort = null; +} + +window.addEventListener("pageshow", connectNativePort); +window.addEventListener("pagehide", disconnectNativePort); + +// If loading error page, pageshow mightn't fired. +connectNativePort(); 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 Binary files differnew file mode 100644 index 0000000000..9fafa32f93 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/audio/owl.mp3 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/dnd.html b/mobile/android/geckoview/src/androidTest/assets/www/dnd.html new file mode 100644 index 0000000000..0dc36b4f9a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/dnd.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<html> + <head> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <script> + document.addEventListener("DOMContentLoaded", () => + document + .querySelector("#drop") + .addEventListener("dragover", e => e.preventDefault()) + ); + </script> + </head> + <body> + <img + id="drag" + draggable="true" + src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2PwmvDoPwAFoAK895OuoAAAAABJRU5ErkJggg==" + width="200" + height="100" + /> + <br /> + <div id="drop" style="border: 1px solid red; width: 200px; height: 100px"> + drop + </div> + <textarea id="textarea" style="width: 200px; height: 100px"></textarea> + </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/fedcm_accounts_endpoint.json b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_accounts_endpoint.json new file mode 100644 index 0000000000..5a8f6eeb30 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_accounts_endpoint.json @@ -0,0 +1,12 @@ +{ + "accounts": [ + { + "id": "$RANDOM_ID", + "given_name": "", + "name": " ", + "email": "demo", + "picture": "http://localhost:4245/assets/www/images/test.gif", + "approved_clients": ["fedcm_rp.html"] + } + ] +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idp_manifest.json b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idp_manifest.json new file mode 100644 index 0000000000..bc66100e6a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idp_manifest.json @@ -0,0 +1,18 @@ +{ + "accounts_endpoint": "fedcm_accounts_endpoint.json", + "client_metadata_endpoint": "fedcm_idp_metadata.json", + "id_assertion_endpoint": "fedcm_idtokens_endpoint.json", + "id_token_endpoint": "fedcm_idtokens_endpoint.json", + "revocation_endpoint": "revocation_endpoint", + "branding": { + "background_color": "0x6200ee", + "color": "0xffffff", + "icons": [ + { + "url": "http://localhost:4245/assets/www/images/test.gif", + "size": 256 + } + ], + "name": "Demo IDP" + } +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idp_metadata.json b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idp_metadata.json new file mode 100644 index 0000000000..db9b9deaf8 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idp_metadata.json @@ -0,0 +1,4 @@ +{ + "privacy_policy_url": "privacy_policy", + "terms_of_service_url": "terms_of_service" +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idtokens_endpoint.json b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idtokens_endpoint.json new file mode 100644 index 0000000000..ba6edfe281 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_idtokens_endpoint.json @@ -0,0 +1,3 @@ +{ + "token": "token" +} diff --git a/mobile/android/geckoview/src/androidTest/assets/www/fedcm_rp.html b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_rp.html new file mode 100644 index 0000000000..4d1fddee7f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/fedcm_rp.html @@ -0,0 +1,8 @@ +<html> + <head> + <meta charset="utf-8" /> + <title>Hello, world!</title> + <link rel="manifest" href="manifest.webmanifest" /> + </head> + <body></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 Binary files differnew file mode 100755 index 0000000000..0f429e1a90 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/helloPDFWorld.pdf 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..c708687a3e --- /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="https://example.org/tests/junit/simple_redirect.sjs?https://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 Binary files differnew file mode 100644 index 0000000000..ba3b541c31 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/images/test.gif 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..4ef3626119 --- /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%"> + <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/orange.pdf b/mobile/android/geckoview/src/androidTest/assets/www/orange.pdf Binary files differnew file mode 100755 index 0000000000..684582176a --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/orange.pdf 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/pull-to-refresh-subframe.html b/mobile/android/geckoview/src/androidTest/assets/www/pull-to-refresh-subframe.html new file mode 100644 index 0000000000..d1a421c0a3 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/pull-to-refresh-subframe.html @@ -0,0 +1,82 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta + name="viewport" + content="height=device-height,width=device-width,initial-scale=1.0" + /> + <style type="text/css"> + html, + body { + width: 100%; + height: 100%; + margin: 0; + padding: 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); + } + + .container { + width: 100%; + height: 25%; + overflow: scroll; + } + + .subframe { + width: 100%; + height: 100vh; + } + + #one > .subframe { + background-color: red; + } + + #two > .subframe { + background-color: green; + } + + #three > .subframe { + background-color: blue; + } + + #four > .subframe { + background-color: yellow; + } + </style> + </head> + <body> + <div id="one" class="container"> + <div class="subframe"></div> + </div> + <div id="two" class="container"> + <div class="subframe"></div> + </div> + <div id="three" class="container"> + <div class="subframe"></div> + </div> + <div id="four" class="container"> + <div class="subframe"></div> + </div> + </body> + <script> + document + .getElementById("three") + .scrollTo({ top: 200, behavior: "instant" }); + + document.getElementById("four").addEventListener("touchstart", e => { + console.log("not preventing default"); + }); + + document.getElementById("two").addEventListener("touchstart", e => { + console.log("preventing default"); + e.preventDefault(); + }); + </script> +</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..c8f0fe9e95 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/scroll-handoff.html @@ -0,0 +1,40 @@ +<!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: 300vh"></div> + </div> + </body> + <script> + document + .getElementById("scroll") + .scrollTo({ top: 50, behavior: "instant" }); + </script> +</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..87a4d6039e --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/selectionAction_frame_xorigin.html @@ -0,0 +1,47 @@ +<html> + <head> + <meta charset="utf-8" /> + <script> + window.addEventListener("message", e => { + switch (e.data.type) { + case "focus": + window.focus(); + break; + + case "select": { + <!-- On fission, there's no way to wait for parent position change. --> + <!-- So we use requestAnimationFrame as a workaround. --> + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + 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 Binary files differnew file mode 100644 index 0000000000..4dcf129d65 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/tracemonkey.pdf 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/translations-tester-en.html b/mobile/android/geckoview/src/androidTest/assets/www/translations-tester-en.html new file mode 100644 index 0000000000..3e5f8e6303 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/translations-tester-en.html @@ -0,0 +1,62 @@ +<!DOCTYPE html> +<html lang="en"> + <!-- See toolkit for original test.--> + <head> + <meta charset="utf-8" /> + <title>Translations Test</title> + <style> + div { + margin: 10px auto; + width: 300px; + } + p { + margin: 47px 0; + font-size: 21px; + line-height: 2; + } + </style> + </head> + <body> + <div> + <!-- The following is an excerpt from The Wondeful Wizard of Oz, which is in the public domain --> + <h1>"The Wonderful Wizard of Oz" by L. Frank Baum</h1> + <p> + The little girl, seeing she had lost one of her pretty shoes, grew + angry, and said to the Witch, “Give me back my shoe!” + </p> + <p> + “I will not,” retorted the Witch, “for it is now my shoe, and not + yours.” + </p> + <p> + “You are a wicked creature!” cried Dorothy. “You have no right to take + my shoe from me.” + </p> + <p> + “I shall keep it, just the same,” said the Witch, laughing at her, “and + someday I shall get the other one from you, too.” + </p> + <p> + This made Dorothy so very angry that she picked up the bucket of water + that stood near and dashed it over the Witch, wetting her from head to + foot. + </p> + <p> + Instantly the wicked woman gave a loud cry of fear, and then, as Dorothy + looked at her in wonder, the Witch began to shrink and fall away. + </p> + <p> + “See what you have done!” she screamed. “In a minute I shall melt away.” + </p> + <p> + “I’m very sorry, indeed,” said Dorothy, who was truly frightened to see + the Witch actually melting away like brown sugar before her very eyes. + </p> + <p> + “Didn’t you know water would be the end of me?” asked the Witch, in a + wailing, despairing voice. + </p> + <p>“Of course not,” answered Dorothy. “How should I?”</p> + </div> + </body> +</html> diff --git a/mobile/android/geckoview/src/androidTest/assets/www/translations-tester-es.html b/mobile/android/geckoview/src/androidTest/assets/www/translations-tester-es.html new file mode 100644 index 0000000000..e9d39585ee --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/translations-tester-es.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html lang="es"> + <!-- See toolkit for original test.--> + <head> + <meta charset="utf-8" /> + <title>Translations Test</title> + <style> + div { + margin: 10px auto; + width: 300px; + } + p { + margin: 47px 0; + font-size: 21px; + line-height: 2; + } + </style> + </head> + <body> + <div> + <header lang="en"> + The following is an excerpt from Don Quijote de la Mancha, which is in + the public domain + </header> + <h1>Don Quijote de La Mancha</h1> + <h2>Capítulo VIII.</h2> + <p> + Del buen suceso que el valeroso don Quijote tuvo en la espantable y + jamás imaginada aventura de los molinos de viento, con otros sucesos + dignos de felice recordación + </p> + <p> + En esto, descubrieron treinta o cuarenta molinos de viento que hay en + aquel campo; y, así como don Quijote los vio, dijo a su escudero: + </p> + <p> + — La ventura va guiando nuestras cosas mejor de lo que acertáramos a + desear, porque ves allí, amigo Sancho Panza, donde se descubren treinta, + o pocos más, desaforados gigantes, con quien pienso hacer batalla y + quitarles a todos las vidas, con cuyos despojos comenzaremos a + enriquecer; que ésta es buena guerra, y es gran servicio de Dios quitar + tan mala simiente de sobre la faz de la tierra. + </p> + <p>— ¿Qué gigantes? —dijo Sancho Panza.</p> + <p> + — Aquellos que allí ves —respondió su amo— de los brazos largos, que los + suelen tener algunos de casi dos leguas. + </p> + <p> + — Mire vuestra merced —respondió Sancho— que aquellos que allí se + parecen no son gigantes, sino molinos de viento, y lo que en ellos + parecen brazos son las aspas, que, volteadas del viento, hacen andar la + piedra del molino. + </p> + <p> + — Bien parece —respondió don Quijote— que no estás cursado en esto de + las aventuras: ellos son gigantes; y si tienes miedo, quítate de ahí, y + ponte en oración en el espacio que yo voy a entrar con ellos en fiera y + desigual batalla. + </p> + <p> + Y, diciendo esto, dio de espuelas a su caballo Rocinante, sin atender a + las voces que su escudero Sancho le daba, advirtiéndole que, sin duda + alguna, eran molinos de viento, y no gigantes, aquellos que iba a + acometer. Pero él iba tan puesto en que eran gigantes, que ni oía las + voces de su escudero Sancho ni echaba de ver, aunque estaba ya bien + cerca, lo que eran; antes, iba diciendo en voces altas: + </p> + <p> + — Non fuyades, cobardes y viles criaturas, que un solo caballero es el + que os acomete. + </p> + <p> + Levantóse en esto un poco de viento y las grandes aspas comenzaron a + moverse, lo cual visto por don Quijote, dijo: + </p> + <p> + — Pues, aunque mováis más brazos que los del gigante Briareo, me lo + habéis de pagar. + </p> + </div> + </body> +</html> diff --git a/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif b/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif Binary files differnew file mode 100644 index 0000000000..e565824aaf --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/transparent.gif 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 Binary files differnew file mode 100644 index 0000000000..518531a93f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/videos/gizmo.webm diff --git a/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 b/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 Binary files differnew file mode 100644 index 0000000000..a674b7eb68 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/videos/short.mp4 diff --git a/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg b/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg Binary files differnew file mode 100644 index 0000000000..ac7ece3519 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/assets/www/videos/video.ogg 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..6e2d79e0b0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/AccessibilityTest.kt @@ -0,0 +1,2186 @@ +/* -*- 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.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.Assume.assumeThat +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( + AccessibilityNodeInfo::class.java.getMethod( + "getChildId", + Int::class.java, + ).invoke(this, index) as Long, + ) + } + } + + 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"), + ) + 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"), + ) + 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() { + // disabled for having over 120+ failures in the last 7 days - turned permafailing on Bug 1837126 + assumeThat(sessionRule.env.isDebugBuild, equalTo(true)) + 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")) + 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")) + 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")) + 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")) + 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) + 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)) + 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)) + 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", + "#email1" to "a@b.c", + "#number1" to "24", + "#tel1" to "42", + ) + + // 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)) + 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 { + 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 = AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE + val ACTION_SET_TEXT = AccessibilityNodeInfo.ACTION_SET_TEXT + + 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)) + 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")) + 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)) + 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)) + 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"), + ) + 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"), + ) + 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")) + 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")) + 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")) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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"), + ) + 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..655db7248f --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/BaseSessionTest.kt @@ -0,0 +1,317 @@ +/* -*- 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 org.mozilla.geckoview.test.util.TestServer +import kotlin.reflect.KClass + +/** + * Common base class for tests using GeckoSessionTestRule, + * providing the test rule and other utilities. + */ +open class BaseSessionTest( + noErrorCollector: Boolean = false, + serverCustomHeaders: Map<String, String>? = null, + responseModifiers: Map<String, TestServer.ResponseModifier>? = null, +) { + 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 DND_HTML_PATH = "/assets/www/dnd.html" + 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 FEDCM_RP_HTML_PATH = "/assets/www/fedcm_rp.html" + const val FEDCM_IDP_MANIFEST_PATH = "/assets/www/fedcm_idp_manifest.json" + 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 PULL_TO_REFRESH_SUBFRAME_PATH = "/assets/www/pull-to-refresh-subframe.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 ORANGE_PDF_PATH = "/assets/www/orange.pdf" + const val NO_META_VIEWPORT_HTML_PATH = "/assets/www/no-meta-viewport.html" + const val TRANSLATIONS_EN = "/assets/www/translations-tester-en.html" + const val TRANSLATIONS_ES = "/assets/www/translations-tester-es.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(serverCustomHeaders, responseModifiers) + + // 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.synthesizeMouse(downTime: Long, action: Int, x: Int, y: Int, buttonState: Int) = + sessionRule.synthesizeMouse(this, downTime, action, x, y, buttonState) + + 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) + + fun GeckoSession.triggerTranslationsOffer() = + sessionRule.triggerTranslationsOffer(this) + + fun GeckoSession.triggerLanguageStateChange(languageState: JSONObject) = + sessionRule.triggerLanguageStateChange(this, languageState) + 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..249b47b095 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentBlockingControllerTest.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/ */ + +// 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.AntiTracking +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 cookieBannerGlobalRulesEnabledSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is off", + contentBlocking.cookieBannerGlobalRulesEnabled, + equalTo(false), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "cookiebanners.service.enableGlobalRules", + ) + + assertThat("Actual value is correct", originalPrefs[0] as Boolean, equalTo(contentBlocking.cookieBannerGlobalRulesEnabled)) + + contentBlocking.cookieBannerGlobalRulesEnabled = true + + val actualPrefs = sessionRule.getPrefs("cookiebanners.service.enableGlobalRules") + + assertThat("Actual value is correct", actualPrefs[0] as Boolean, equalTo(contentBlocking.cookieBannerGlobalRulesEnabled)) + } + + @Test + fun cookieBannerGlobalRulesSubFramesEnabledSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is off", + contentBlocking.cookieBannerGlobalRulesSubFramesEnabled, + equalTo(false), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "cookiebanners.service.enableGlobalRules.subFrames", + ) + + assertThat("Actual value is correct", originalPrefs[0] as Boolean, equalTo(contentBlocking.cookieBannerGlobalRulesSubFramesEnabled)) + + contentBlocking.cookieBannerGlobalRulesSubFramesEnabled = true + + val actualPrefs = sessionRule.getPrefs("cookiebanners.service.enableGlobalRules.subFrames") + + assertThat("Actual value is correct", actualPrefs[0] as Boolean, equalTo(contentBlocking.cookieBannerGlobalRulesSubFramesEnabled)) + } + + @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), + ) + } + + @Test + fun queryParameterStrippingSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is off", + contentBlocking.queryParameterStrippingEnabled, + equalTo(false), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "privacy.query_stripping.enabled", + ) + + assertThat( + "Initial value is correct", + originalPrefs[0] as Boolean, + equalTo(contentBlocking.queryParameterStrippingEnabled), + ) + + contentBlocking.queryParameterStrippingEnabled = true + + val actualPrefs = sessionRule.getPrefs( + "privacy.query_stripping.enabled", + ) + + assertThat( + "The value is updated", + actualPrefs[0] as Boolean, + equalTo(contentBlocking.queryParameterStrippingEnabled), + ) + } + + @Test + fun queryParameterStrippingPrivateBrowsingSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is off", + contentBlocking.queryParameterStrippingPrivateBrowsingEnabled, + equalTo(false), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "privacy.query_stripping.enabled.pbmode", + ) + + assertThat( + "Initial value is correct", + originalPrefs[0] as Boolean, + equalTo(contentBlocking.queryParameterStrippingPrivateBrowsingEnabled), + ) + + contentBlocking.queryParameterStrippingPrivateBrowsingEnabled = true + + val actualPrefs = sessionRule.getPrefs( + "privacy.query_stripping.enabled.pbmode", + ) + + assertThat( + "The value is updated", + actualPrefs[0] as Boolean, + equalTo(contentBlocking.queryParameterStrippingPrivateBrowsingEnabled), + ) + } + + @Test + fun queryParameterStrippingAllowListSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is empty string", + contentBlocking.queryParameterStrippingAllowList.joinToString(","), + equalTo(""), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "privacy.query_stripping.allow_list", + ) + + assertThat( + "Initial value is correct", + originalPrefs[0] as String, + equalTo(contentBlocking.queryParameterStrippingAllowList.joinToString(",")), + ) + + contentBlocking.setQueryParameterStrippingAllowList("item_one", "item_two") + + val actualPrefs = sessionRule.getPrefs( + "privacy.query_stripping.allow_list", + ) + + assertThat( + "The value is updated", + actualPrefs[0] as String, + equalTo(contentBlocking.queryParameterStrippingAllowList.joinToString(",")), + ) + } + + @Test + fun queryParameterStrippingStripListSettings() { + // Check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + assertThat( + "Expect correct default value which is empty string", + contentBlocking.queryParameterStrippingStripList.joinToString(","), + equalTo(""), + ) + + // Checks that the pref value is also consistent with the runtime settings + val originalPrefs = sessionRule.getPrefs( + "privacy.query_stripping.strip_list", + ) + + assertThat( + "Initial value is correct", + originalPrefs[0] as String, + equalTo(contentBlocking.queryParameterStrippingStripList.joinToString(",")), + ) + + contentBlocking.setQueryParameterStrippingAllowList("item_one", "item_two") + + val actualPrefs = sessionRule.getPrefs( + "privacy.query_stripping.strip_list", + ) + + assertThat( + "The value is updated", + actualPrefs[0] as String, + equalTo(contentBlocking.queryParameterStrippingStripList.joinToString(",")), + ) + } + + @Test + fun toggleEmailTrackingForPrivateBrowsingMode() { + // check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + val originalPref = sessionRule.getPrefs( + "privacy.trackingprotection.emailtracking.pbmode.enabled", + ) + assertThat( + "Expect correct default value which is off", + originalPref[0] as Boolean, + equalTo(false), + ) + + contentBlocking.setEmailTrackerBlockingPrivateBrowsing(true) + + val updatedPref = sessionRule.getPrefs( + "privacy.trackingprotection.emailtracking.pbmode.enabled", + ) + assertThat( + "Expect new value which is on", + updatedPref[0] as Boolean, + equalTo(true), + ) + } + + @Test + fun toggleEmailTrackingWhenETBAddedToAntiTrackingList() { + // check default value + val contentBlocking = sessionRule.runtime.settings.contentBlocking + + val originalPref = sessionRule.getPrefs( + "privacy.trackingprotection.emailtracking.enabled", + ) + assertThat( + "Expect correct default value which is off", + originalPref[0] as Boolean, + equalTo(false), + ) + + contentBlocking.setAntiTracking(AntiTracking.EMAIL) + + val updatedPref = sessionRule.getPrefs( + "privacy.trackingprotection.emailtracking.enabled", + ) + assertThat( + "Expect new value which is on", + updatedPref[0] as Boolean, + equalTo(true), + ) + } +} 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..d76a1b2e9e --- /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, "web") + } + + @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..511d58b5a6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateChildTest.kt @@ -0,0 +1,313 @@ +/* -*- 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"), + ) + } + }) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun notRequestContextMenuWithPreventDefault() { + mainSession.loadTestPath(CONTEXT_MENU_LINK_HTML_PATH) + mainSession.waitForPageStop() + + val contextmenuEventPromise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + document.documentElement.addEventListener('contextmenu', event => { + event.preventDefault(); + resolve(true); + }, { once: true }); + }); + """.trimIndent(), + ) + + mainSession.delegateUntilTestEnd(object : ContentDelegate { + @AssertCalled(false) + override fun onContextMenu( + session: GeckoSession, + screenX: Int, + screenY: Int, + element: ContextElement, + ) { + } + }) + + sendLongPress(50f, 50f) + + assertThat("contextmenu", contextmenuEventPromise.value as Boolean, equalTo(true)) + + mainSession.waitForRoundTrip() + } +} 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..c85e57eb81 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ContentDelegateMultipleSessionsTest.kt @@ -0,0 +1,156 @@ +/* -*- 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.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() { + // We need to make sure all sessions in a given content process receive onCrash + // or onKill. To test this, we need to make sure we have two tabs sharing the same process. + 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) + } + }) + + 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/DragAndDropTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DragAndDropTest.kt new file mode 100644 index 0000000000..2a2a7c4305 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/DragAndDropTest.kt @@ -0,0 +1,154 @@ +/* -*- 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.os.Build +import android.os.SystemClock +import android.view.DragEvent +import android.view.MotionEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.filters.SdkSuppress +import org.hamcrest.Matchers.equalTo +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay + +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = Build.VERSION_CODES.N) +@MediumTest +class DragAndDropTest : BaseSessionTest() { + // DragEvent has no constructor, so we create it via Java reflection. + fun createDragEvent(action: Int, x: Float = 0.0F, y: Float = 0.0F): DragEvent { + val method = DragEvent::class.java.getDeclaredMethod("obtain") + method.setAccessible(true) + val dragEvent = method.invoke(null) as DragEvent + + val fieldAction = DragEvent::class.java.getDeclaredField("mAction") + fieldAction.setAccessible(true) + fieldAction.set(dragEvent, action) + + if (listOf(DragEvent.ACTION_DRAG_STARTED, DragEvent.ACTION_DRAG_LOCATION, DragEvent.ACTION_DROP).contains(action)) { + val fieldX = DragEvent::class.java.getDeclaredField("mX") + fieldX.setAccessible(true) + fieldX.set(dragEvent, x) + + val fieldY = DragEvent::class.java.getDeclaredField("mY") + fieldY.setAccessible(true) + fieldY.set(dragEvent, y) + } + + val clipData = ClipData.newPlainText("label", "foo") + if (action == DragEvent.ACTION_DROP) { + val fieldClipData = DragEvent::class.java.getDeclaredField("mClipData") + fieldClipData.setAccessible(true) + fieldClipData.set(dragEvent, clipData) + } + + if (action != DragEvent.ACTION_DRAG_ENDED) { + var clipDescription = clipData.getDescription() + val fieldClipDescription = DragEvent::class.java.getDeclaredField("mClipDescription") + fieldClipDescription.setAccessible(true) + fieldClipDescription.set(dragEvent, clipDescription) + } + + return dragEvent + } + + fun sendDragEvent(startX: Float, startY: Float, endY: Float) { + // Android doesn't fire MotionEvent during drag and drop. + val dragStartEvent = createDragEvent(DragEvent.ACTION_DRAG_STARTED) + mainSession.panZoomController.onDragEvent(dragStartEvent) + val dragEnteredEvent = createDragEvent(DragEvent.ACTION_DRAG_ENTERED) + mainSession.panZoomController.onDragEvent(dragEnteredEvent) + listOf(startY, endY).forEach { + val dragLocationEvent = createDragEvent(DragEvent.ACTION_DRAG_LOCATION, startX, it) + mainSession.panZoomController.onDragEvent(dragLocationEvent) + } + val dropEvent = createDragEvent(DragEvent.ACTION_DROP, startX, endY) + mainSession.panZoomController.onDragEvent(dropEvent) + val dragEndedEvent = createDragEvent(DragEvent.ACTION_DRAG_ENDED) + mainSession.panZoomController.onDragEvent(dragEndedEvent) + } + + @WithDisplay(width = 300, height = 300) + @Test + fun dragStartTest() { + mainSession.loadTestPath(DND_HTML_PATH) + sessionRule.waitForPageStop() + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise(r => document.querySelector('#drag').addEventListener('dragstart', r, { once: true })) + """.trimIndent(), + ) + val downTime = SystemClock.uptimeMillis() + mainSession.synthesizeMouse(downTime, MotionEvent.ACTION_DOWN, 50, 20, MotionEvent.BUTTON_PRIMARY) + for (y in 30..50) { + mainSession.synthesizeMouse(downTime, MotionEvent.ACTION_MOVE, 50, y, MotionEvent.BUTTON_PRIMARY) + } + mainSession.synthesizeMouse(downTime, MotionEvent.ACTION_UP, 50, 50, 0) + promise.value + + assertThat("drag event is started correctly", true, equalTo(true)) + } + + @WithDisplay(width = 300, height = 300) + @Test + fun dropFromExternalTest() { + mainSession.loadTestPath(DND_HTML_PATH) + sessionRule.waitForPageStop() + + val promise = mainSession.evaluatePromiseJS( + """ + new Promise( + r => document.querySelector('#drop').addEventListener( + 'drop', + e => r(e.dataTransfer.getData('text/plain')), + { once: true })) + """.trimIndent(), + ) + + sendDragEvent(100.0F, 150.0F, 250.0F) + + assertThat("drop event is fired correctly", promise.value as String, equalTo("foo")) + } + + @WithDisplay(width = 300, height = 500) + @Test + fun dropFromExternalToTextControlTest() { + mainSession.loadTestPath(DND_HTML_PATH) + sessionRule.waitForPageStop() + + val promiseDragOver = mainSession.evaluatePromiseJS( + """ + new Promise( + r => document.querySelector('textarea').addEventListener( + 'dragover', + e => r({ types: e.dataTransfer.types, data: e.dataTransfer.getData('text/plain') }), + { once: true })) + """.trimIndent(), + ) + + val promiseSetValue = mainSession.evaluatePromiseJS( + """ + new Promise( + r => document.querySelector('textarea').addEventListener( + 'input', + e => r(document.querySelector('textarea').value), + { once: true })) + """.trimIndent(), + ) + + sendDragEvent(100.0F, 250.0F, 450.0F) + + var value = promiseDragOver.value as JSONObject + assertThat("dataTransfer type is text/plain", value.getJSONArray("types").getString(0), equalTo("text/plain")) + assertThat("dataTransfer set empty string during dragover event", value.getString("data"), equalTo("")) + assertThat("input event is fired correctly", promiseSetValue.value as String, equalTo("foo")) + } +} 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/ExperimentDelegateTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExperimentDelegateTest.kt new file mode 100644 index 0000000000..20a210f562 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ExperimentDelegateTest.kt @@ -0,0 +1,130 @@ +/* -*- 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.ExperimentDelegate +import org.mozilla.geckoview.ExperimentDelegate.ExperimentException +import org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_EXPERIMENT_SLUG_NOT_FOUND +import org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_FEATURE_NOT_FOUND +import org.mozilla.geckoview.GeckoResult +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled +import java.lang.RuntimeException + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ExperimentDelegateTest : BaseSessionTest() { + + @Test + fun withPdfJS() { + mainSession.loadTestPath(TRACEMONKEY_PDF_PATH) + sessionRule.addExternalDelegateUntilTestEnd( + ExperimentDelegate::class, + sessionRule::setExperimentDelegate, + { sessionRule.setExperimentDelegate(null) }, + object : ExperimentDelegate { + @AssertCalled(count = 1) + override fun onGetExperimentFeature(feature: String): GeckoResult<JSONObject> { + assertThat( + "Feature id should match", + feature, + equalTo("pdfjs"), + ) + return GeckoResult<JSONObject>() + } + }, + ) + } + + /* + Basic test of setting a runtime experiment delegate and an example experiment delegate with example functionality usage. + */ + @Test + fun experimentDelegateTest() { + sessionRule.addExternalDelegateUntilTestEnd( + ExperimentDelegate::class, + sessionRule::setExperimentDelegate, + { sessionRule.setExperimentDelegate(null) }, + object : ExperimentDelegate { + @AssertCalled(count = 1) + override fun onGetExperimentFeature(feature: String): GeckoResult<JSONObject> { + val result = GeckoResult<JSONObject>() + if (feature == "test") { + result.complete(JSONObject().put("item-one", true).put("item-two", 5)) + } else { + result.completeExceptionally(ExperimentException(ERROR_FEATURE_NOT_FOUND)) + } + return result + } + + @AssertCalled(count = 1) + override fun onRecordExposureEvent(feature: String): GeckoResult<Void> { + val result = GeckoResult<Void>() + if (feature == "test") { + result.complete(null) + } else { + result.completeExceptionally(ExperimentException(ERROR_FEATURE_NOT_FOUND)) + } + return result + } + + @AssertCalled(count = 3) + override fun onRecordExperimentExposureEvent(feature: String, slug: String): GeckoResult<Void> { + val result = GeckoResult<Void>() + if (feature == "test" && slug == "test") { + result.complete(null) + } else if (slug != "test" && feature == "test") { + result.completeExceptionally(ExperimentException(ERROR_EXPERIMENT_SLUG_NOT_FOUND)) + } else { + result.completeExceptionally(ExperimentException(ERROR_FEATURE_NOT_FOUND)) + } + return result + } + + @AssertCalled(count = 1) + override fun onRecordMalformedConfigurationEvent(feature: String, part: String): GeckoResult<Void> { + val result = GeckoResult<Void>() + if (feature == "test") { + result.complete(null) + } else { + result.completeExceptionally(ExperimentException(ERROR_FEATURE_NOT_FOUND)) + } + return result + } + }, + ) + val experimentDelegate = sessionRule.runtime.settings.experimentDelegate!! + val experimentFeature = sessionRule.waitForResult(experimentDelegate.onGetExperimentFeature("test")) + assertThat("Experiment item one matches", experimentFeature?.get("item-one"), equalTo(true)) + assertThat("Experiment item two matches", experimentFeature?.get("item-two"), equalTo(5)) + val recordedExposureEvent = sessionRule.waitForResult(experimentDelegate.onRecordExposureEvent("test")) + assertThat("Recorded an exposure event", recordedExposureEvent, equalTo(null)) + val recordedExperimentExposureEvent = sessionRule.waitForResult(experimentDelegate.onRecordExperimentExposureEvent("test", "test")) + assertThat("Recorded an experiment exposure event", recordedExperimentExposureEvent, equalTo(null)) + val recordedMalformedEvent = sessionRule.waitForResult(experimentDelegate.onRecordMalformedConfigurationEvent("test", "data")) + assertThat("Recorded a malformed event", recordedMalformedEvent, equalTo(null)) + + try { + sessionRule.waitForResult(experimentDelegate.onRecordExperimentExposureEvent("test", "no-slug")) + } catch (re: RuntimeException) { + // no-op, wait for result for testing throws this + } catch (ee: ExperimentException) { + assertThat("Correct error of no slug found.", ee.code, equalTo(ERROR_EXPERIMENT_SLUG_NOT_FOUND)) + } + + try { + sessionRule.waitForResult(experimentDelegate.onRecordExperimentExposureEvent("no-feature", "test")) + } catch (re: RuntimeException) { + // no-op, wait for result for testing throws this + } catch (ee: ExperimentException) { + assertThat("Correct error of no feature found.", ee.code, equalTo(ERROR_FEATURE_NOT_FOUND)) + } + } +} 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..abe3c58218 --- /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..d6380bf5bf --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/GeckoSessionTestRuleTest.kt @@ -0,0 +1,2133 @@ +/* -*- 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.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.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 synthesizeMouse() { + mainSession.loadTestPath(MOUSE_TO_RELOAD_HTML_PATH) + mainSession.waitForPageStop() + + val time = SystemClock.uptimeMillis() + mainSession.evaluateJS("document.body.addEventListener('mousedown', () => { window.location.reload() })") + mainSession.synthesizeMouse(time, MotionEvent.ACTION_DOWN, 50, 50, MotionEvent.BUTTON_PRIMARY) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.body.addEventListener('mouseup', () => { window.location.reload() })") + mainSession.synthesizeMouse(time, MotionEvent.ACTION_UP, 50, 50, 0) + mainSession.waitForPageStop() + } + + @WithDisplay(width = 100, height = 100) + @Test + fun synthesizeMouseMove() { + mainSession.loadTestPath(MOUSE_TO_RELOAD_HTML_PATH) + mainSession.waitForPageStop() + + mainSession.evaluateJS("document.body.addEventListener('mousemove', () => { window.location.reload() })") + 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..1bb568123c --- /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..125b519dbe --- /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, null) + } + + @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..370594a93f --- /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 1837551 + 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..d0030c4721 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ImageResourceTest.kt @@ -0,0 +1,315 @@ +/* -*- 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, + ) + + val kValidTestImage7 = TestImage( + "RaNdoMCasE.PnG", + 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) + testValidImage(kValidTestImage7) + } + + @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..c81068c294 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/InputResultDetailTest.kt @@ -0,0 +1,549 @@ +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.PanZoomController.InputResultDetail +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) + mainSession.waitForPageStop() + mainSession.promiseAllPaintsDone() + 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") { + PanZoomController.INPUT_RESULT_HANDLED_CONTENT + } else if (scrollable && !subframe) { + PanZoomController.INPUT_RESULT_HANDLED + } else { + PanZoomController.INPUT_RESULT_UNHANDLED + } + + var expectedScrollableDirections = if (scrollable) { + PanZoomController.SCROLLABLE_FLAG_BOTTOM + } else { + PanZoomController.SCROLLABLE_FLAG_NONE + } + + var expectedOverscrollDirections = if (touchAction == "none") { + 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 or PanZoomController.SCROLLABLE_FLAG_TOP), + 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), + ) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testPreventTouchMoveAfterLongTap() { + sessionRule.display?.run { setDynamicToolbarMaxHeight(20) } + + setupDocument(ROOT_100VH_HTML_PATH) + + // Setup a touchmove event listener preventing scrolling. + val touchmovePromise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.addEventListener('touchmove', (e) => { + e.preventDefault(); + resolve(true); + }, { passive: false }); + }); + """.trimIndent(), + ) + + // Setup a contextmenu event. + val contextmenuPromise = mainSession.evaluatePromiseJS( + """ + new Promise(resolve => { + window.addEventListener('contextmenu', (e) => { + e.preventDefault(); + resolve(true); + }, { once: true }); + }); + """.trimIndent(), + ) + + // Explicitly call `waitForRoundTrip()` to make sure the above event listeners + // have set up in the content. + mainSession.waitForRoundTrip() + + mainSession.flushApzRepaints() + + val downTime = SystemClock.uptimeMillis() + val down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 50f, + 50f, + 0, + ) + val result = mainSession.panZoomController.onTouchEventForDetailResult(down) + + // Wait until a contextmenu event happens. + assertThat("contextmenu", contextmenuPromise.value as Boolean, equalTo(true)) + + // Start moving. + val move = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + 50f, + 70f, + 0, + ) + mainSession.panZoomController.onTouchEvent(move) + + assertThat("touchmove", touchmovePromise.value as Boolean, equalTo(true)) + + val value = sessionRule.waitForResult(result) + + // The input result for the initial touch-start event should have been handled by + // the content. + assertResultDetail( + ROOT_100VH_HTML_PATH, + value, + PanZoomController.INPUT_RESULT_HANDLED_CONTENT, + PanZoomController.SCROLLABLE_FLAG_BOTTOM, + (PanZoomController.OVERSCROLL_FLAG_HORIZONTAL or PanZoomController.OVERSCROLL_FLAG_VERTICAL), + ) + + val up = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + 50f, + 70f, + 0, + ) + + mainSession.panZoomController.onTouchEvent(up) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun testTouchCancelBeforeFirstTouchMove() { + setupDocument(ROOT_100VH_HTML_PATH) + + // Setup a touchmove event listener preventing scrolling. + mainSession.evaluateJS( + """ + window.addEventListener('touchmove', (e) => { + e.preventDefault(); + }, { passive: false }); + """.trimIndent(), + ) + + // Explicitly call `waitForRoundTrip()` to make sure the above event listener + // has been set up in the content. + mainSession.waitForRoundTrip() + + mainSession.flushApzRepaints() + + // Send a touchstart. The result will not be produced yet because + // we will wait for the first touchmove. + val downTime = SystemClock.uptimeMillis() + val down = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + 50f, + 50f, + 0, + ) + val result = mainSession.panZoomController.onTouchEventForDetailResult(down) + + // Before any touchmove, send a touchcancel. + val cancel = MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_CANCEL, + 50f, + 50f, + 0, + ) + mainSession.panZoomController.onTouchEvent(cancel) + + // Check that the touchcancel results in the same response as if + // the touchmove was prevented. + val value = sessionRule.waitForResult(result) + assertResultDetail( + "testTouchCancelBeforeFirstTouchMove", + value, + PanZoomController.INPUT_RESULT_HANDLED_CONTENT, + 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..0e1ead69f6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/MediaSessionTest.kt @@ -0,0 +1,1030 @@ +/* -*- 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, + ), + ) + } + + @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..aab32cd01d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/NavigationDelegateTest.kt @@ -0,0 +1,3152 @@ +/* -*- 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?) { + if (!errorPageUrl.startsWith("about:")) { + 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, + "about:blank", + ) + testLoadErrorWithErrorPage( + testLoader, + expectedCategory, + expectedError, + "about:blank", + ) + } + + 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?) {} + }) + } + } + + fun testLoadEarlyError( + testUri: String, + expectedCategory: Int, + expectedError: Int, + ) { + testLoadEarlyErrorWithErrorPage(testUri, expectedCategory, expectedError, "about:blank") + testLoadEarlyErrorWithErrorPage(testUri, expectedCategory, expectedError, null) + } + + @Test fun loadFileNotFound() { + 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() { + testLoadExpectError( + UNKNOWN_HOST_URI, + WebRequestError.ERROR_CATEGORY_URI, + WebRequestError.ERROR_UNKNOWN_HOST, + ) + } + + // External loads should not have access to privileged protocols + @Test fun loadExternalDenied() { + 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() { + 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, + ) + + if (!sessionRule.env.isFission) { // todo: Bug 1673954 + 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() { + // Bug 1849060. Hit debug assertion with fission + assumeThat(sessionRule.env.isFission and sessionRule.env.isDebugBuild, 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("about:blank") + } + + @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?) {} + }) + + 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), + ) + // When returning null then process is switched, web extension won't be loaded + // since there is no document element. + // So we shouldn't return null with fission if we want to use `evaluateJS`. + return GeckoResult.fromValue("about:blank") + } + + @AssertCalled(count = 1, order = [5]) + override fun onPageStop(session: GeckoSession, success: Boolean) { + assertThat("Load should fail", success, equalTo(false)) + } + }, + ) + + mainSession.load(testLoader) + sessionRule.waitForPageStop() + + // No good way to wait for loading about:blank error page. Use onLocaitonChange etc. + sessionRule.waitUntilCalled(object : ContentDelegate, NavigationDelegate { + override fun onLocationChange( + session: GeckoSession, + url: String?, + perms: MutableList<PermissionDelegate.ContentPermission>, + ) { + assertThat("URL should match", url, equalTo(httpsUri)) + } + + override fun onTitleChange(session: GeckoSession, title: String?) { + assertThat("Title should not be empty", title, not(isEmptyOrNullString())) + } + }) + + 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 + } + }, + ) + + // Calling eloadWithHttpsOnlyException may causes that the document will be unloaded + // immediately before native message isn't handled. + try { + mainSession.evaluateJS("document.reloadWithHttpsOnlyException();") + } catch (ex: RejectedPromiseException) { + // Communication port for web extensions is immediately disconnected. Re-try. + mainSession.evaluateJS("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() { + 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() { + 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() { + 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() { + 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 onReadyResult = GeckoResult<String>() + var extBaseUrl = "" + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.AddonManagerDelegate::class, + { delegate -> controller.setAddonManagerDelegate(delegate) }, + { controller.setAddonManagerDelegate(null) }, + object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 1) + override fun onReady(extension: WebExtension) { + extBaseUrl = extension.metaData.baseUrl + onReadyResult.complete(null) + super.onReady(extension) + } + }, + ) + + val extension = sessionRule.waitForResult( + controller.install( + "https://example.org/tests/junit/page-history.xpi", + null, + ), + ) + + // Wait for the extension to have been started before trying to navigate + // to the test extension page. + sessionRule.waitForResult(onReadyResult) + + assertThat( + "baseUrl should be a valid extension URL", + extBaseUrl, + startsWith("moz-extension://"), + ) + + val url = extBaseUrl + "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 1837551 + 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 1837551 + 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/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..ba4992ff80 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PanZoomControllerTest.kt @@ -0,0 +1,683 @@ +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 pullToRefreshSubframe() { + setupDocument(PULL_TO_REFRESH_SUBFRAME_PATH) + + // No touch handler and no room to scroll up + var value = sessionRule.waitForResult(sendDownEvent(50f, 10f)) + assertThat( + "Touch when subframe has no room to scroll up should be unhandled", + value, + equalTo(PanZoomController.INPUT_RESULT_UNHANDLED), + ) + + // Touch handler with preventDefault + value = sessionRule.waitForResult(sendDownEvent(50f, 35f)) + assertThat( + "Touch when content handles the input should indicate so", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + + // Content with room to scroll up + value = sessionRule.waitForResult(sendDownEvent(50f, 60f)) + assertThat( + "Touch when subframe has room to scroll up should be handled by content", + value, + equalTo(PanZoomController.INPUT_RESULT_HANDLED_CONTENT), + ) + + // Touch handler without preventDefault and no room to scroll up + value = sessionRule.waitForResult(sendDownEvent(50f, 85f)) + assertThat( + "Touch no room up and not handled by content should be unhandled", + value, + equalTo(PanZoomController.INPUT_RESULT_UNHANDLED), + ) + } + + @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) + + // Scroll down a bit to ensure the original tap cannot be the start of a + // pull to refresh gesture. + mainSession.evaluateJS( + """ + const iframe = document.querySelector('iframe'); + iframe.contentWindow.scrollTo({ + left: 0, + top: 50, + behavior: 'instant', + }); + """.trimIndent(), + ) + waitForScroll(scrollWaitTimeout) + mainSession.flushApzRepaints() + + 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) + + // Scroll down a bit to ensure the original tap cannot be the start of a + // pull to refresh gesture. + mainSession.evaluateJS( + """ + const iframe = document.querySelector('iframe'); + iframe.contentWindow.scrollTo({ + left: 0, + top: 50, + behavior: 'instant', + }); + """.trimIndent(), + ) + waitForScroll(scrollWaitTimeout) + mainSession.flushApzRepaints() + + 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..627c076fc4 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PdfCreationTest.kt @@ -0,0 +1,180 @@ +/* -*- 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.Assume.assumeThat +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.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() + } + } + } + + @Ignore // TODO: Re-enable it in bug 1846296. + @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)) + } + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun saveAContentPdfDocument() { + // Bug 1864622. + assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) + activityRule.scenario.onActivity { + val originalBytes = getTestBytes(HELLO_PDF_WORLD_PDF_PATH) + TestContentProvider.setTestData(originalBytes, "application/pdf") + mainSession.loadUri("content://org.mozilla.geckoview.test.provider/pdf") + mainSession.waitForPageStop() + + val response = mainSession.pdfFileSaver.save() + sessionRule.waitForResult(response).let { + assertThat("The PDF File must the same as the original one.", it.body?.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..913203e61c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PrintDelegateTest.kt @@ -0,0 +1,338 @@ +/* -*- 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.accessibilityservice.AccessibilityService +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.GeckoResult.fromException +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.GeckoSession.GeckoPrintException +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() { + 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 printSuccessWithStatus() { + 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 + val result = mainSession.didPrintPageContent() + val orange = rgb(255, 113, 57) + val centerPixel = printCenterPixelColor() + assertTrue( + "Android print opened and rendered.", + sessionRule.waitForResult(centerPixel) == orange, + ) + uiAutomation.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK) + assertTrue( + "Printing should conclude when back is pressed.", + sessionRule.waitForResult(result), + ) + } + } + + @Test + fun printFailWithStatus() { + activityRule.scenario.onActivity { + // CSS rules render this blue on screen and orange on print + mainSession.loadTestPath(PRINT_CONTENT_CHANGE) + mainSession.waitForPageStop() + mainSession.printDelegate = null + val result = mainSession.didPrintPageContent().accept { + assertTrue("Should not be able to print.", false) + }.exceptionally( + GeckoResult.OnExceptionListener<Throwable> { error: Throwable -> + assertTrue("Should receive a missing print delegate exception.", (error as GeckoPrintException).code == GeckoPrintException.ERROR_NO_PRINT_DELEGATE) + fromException(error) + }, + ) + try { + sessionRule.waitForResult(result) + } catch (e: Exception) { + assertTrue("Should have an exception", true) + } + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun basicWindowDotPrintTest() { + 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() { + 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() { + 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() { + 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() { + 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) + } + } + + @NullDelegate(Autofill.Delegate::class) + @Test + fun contentPDFWindowDotPrintTest() { + activityRule.scenario.onActivity { activity -> + // CSS rules render this blue on screen and orange on print + mainSession.loadTestPath(ORANGE_PDF_PATH) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + mainSession.printPageContent() + 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 availableCanonicalBrowsingContext() { + activityRule.scenario.onActivity { activity -> + // CSS rules render this blue on screen and orange on print + mainSession.loadTestPath(ORANGE_PDF_PATH) + mainSession.waitForPageStop() + // Setting to the default delegate (test rules changed it) + mainSession.printDelegate = activity.view.printDelegate + mainSession.setFocused(false) + mainSession.printPageContent() + val centerPixel = printCenterPixelColor() + val orange = rgb(255, 113, 57) + assertTrue( + "Android print opened and rendered.", + sessionRule.waitForResult(centerPixel) == orange, + ) + } + } +} 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..3097452da8 --- /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 1837551 + 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() { + // Bug 1662035 - disable to reduce intermittent failures + assumeThat(sessionRule.env.isX86, equalTo(false)) + // TODO: Bug 1837551 + 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 1837551 + 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 1837551 + 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..0120f4e411 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/PromptDelegateTest.kt @@ -0,0 +1,1312 @@ +/* -*- 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.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.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 +import org.mozilla.geckoview.test.util.TestServer +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PromptDelegateTest : BaseSessionTest( + serverCustomHeaders = mapOf( + "Access-Control-Allow-Origin" to "*", + ), + responseModifiers = mapOf( + "/assets/www/fedcm_accounts_endpoint.json" to TestServer.ResponseModifier { response -> + response.replace("\$RANDOM_ID", UUID.randomUUID().toString()) + }, + ), +) { + @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 selectTestShowPicker() { + mainSession.loadTestPath(SELECT_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + document.body.focus(); + document.body.addEventListener('keydown', () => { + document.getElementById('simple').showPicker() + }); + """.trimIndent(), + ) + mainSession.pressKey(KeyEvent.KEYCODE_SPACE) + + sessionRule.waitUntilCalled(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")) + return null + } + }) + + mainSession.loadTestPath(SELECT_MULTIPLE_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + document.body.focus(); + document.body.addEventListener('keydown', () => { + document.getElementById('multiple').showPicker() + }); + """.trimIndent(), + ) + mainSession.pressKey(KeyEvent.KEYCODE_SPACE) + + sessionRule.waitUntilCalled(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")) + return null + } + }) + + mainSession.loadTestPath(SELECT_LISTBOX_HTML_PATH) + sessionRule.waitForPageStop() + + mainSession.evaluateJS( + """ + document.body.focus(); + document.body.addEventListener('keydown', () => { + document.getElementById('multiple').showPicker() + }); + """.trimIndent(), + ) + mainSession.pressKey(KeyEvent.KEYCODE_SPACE) + + sessionRule.waitUntilCalled(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")) + return null + } + }) + } + + @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 fedCMProviderPromptTest() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "dom.security.credentialmanagement.identity.enabled" to true, + ), + ) + sessionRule.setPrefsUntilTestEnd( + mapOf( + "dom.security.credentialmanagement.identity.test_ignore_well_known" to true, + ), + ) + mainSession.loadTestPath(FEDCM_RP_HTML_PATH) + + sessionRule.delegateDuringNextWait(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onSelectIdentityCredentialProvider( + session: GeckoSession, + prompt: PromptDelegate.IdentityCredential.ProviderSelectorPrompt, + ): GeckoResult<PromptResponse> { + prompt.providers.mapIndexed { index, item -> + assertThat("ID should match", index, equalTo(item.id)) + assertThat( + "Name should be the name of the IDP taken from the manifest", + item.name, + containsString("Demo IDP"), + ) + assertThat("Icon should contain a valid image", item.icon ?: "", containsString("data:image")) + } + return GeckoResult.fromValue(prompt.confirm(0)) + } + + @AssertCalled(count = 1) + override fun onSelectIdentityCredentialAccount( + session: GeckoSession, + prompt: PromptDelegate.IdentityCredential.AccountSelectorPrompt, + ): GeckoResult<PromptResponse> { + prompt.accounts.forEachIndexed { index, item -> + assertThat("ID should match", index, equalTo(item.id)) + } + return GeckoResult.fromValue(prompt.confirm(0)) + } + + @AssertCalled(count = 1) + override fun onShowPrivacyPolicyIdentityCredential( + session: GeckoSession, + prompt: PromptDelegate.IdentityCredential.PrivacyPolicyPrompt, + ): GeckoResult<PromptResponse> { + assertThat("Host should be localhost", prompt.host, equalTo("localhost")) + assertThat("Privacy policy url should be the same as specified in fedcm_idp_metadata.json ", prompt.privacyPolicyUrl, equalTo("privacy_policy")) + assertThat("Terms of service url should be the same as specified in fedcm_idp_metadata.json ", prompt.termsOfServiceUrl, equalTo("terms_of_service")) + assertThat("Icon should contain a valid image", prompt.icon ?: "", containsString("data:image")) + return GeckoResult.fromValue(prompt.confirm(true)) + } + }) + + mainSession.waitForJS( + """ + navigator.credentials.get({ + identity: { + providers: [{ + configURL: "${createTestUrl(FEDCM_IDP_MANIFEST_PATH)}", + clientId: "localhost", + nonce: "nonce", + }] + } + }); + """.trimIndent(), + ) + } + + @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) + } + + @WithDisplay(width = 100, height = 100) + @Test + fun dateMonthTestShowPicker() { + mainSession.loadTestPath(PROMPT_HTML_PATH) + mainSession.waitForPageStop() + + // type=month and type=week have no custom controls on all platforms. + // But mobile has the picker with dom.forms.datetime.others=true + + mainSession.evaluateJS( + """ + document.body.focus(); + document.body.addEventListener('keydown', () => { + document.getElementById('monthexample').showPicker() + }, { once: true }); + """.trimIndent(), + ) + mainSession.pressKey(KeyEvent.KEYCODE_SPACE) + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> { + assertThat("showPicker for <input type=month>", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.MONTH)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + mainSession.evaluateJS( + """ + document.body.focus(); + document.body.addEventListener('keydown', () => { + document.getElementById('weekexample').showPicker() + }, { once: true }); + """.trimIndent(), + ) + mainSession.pressKey(KeyEvent.KEYCODE_SPACE) + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> { + assertThat("showPicker for <input type=week>", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.WEEK)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + + // desktop has no type=time picker, but mobile has. + + mainSession.evaluateJS( + """ + document.body.focus(); + document.body.addEventListener('keydown', () => { + document.getElementById('timeexample').showPicker() + }, { once: true }); + """.trimIndent(), + ) + mainSession.pressKey(KeyEvent.KEYCODE_SPACE) + + sessionRule.waitUntilCalled(object : PromptDelegate { + @AssertCalled(count = 1) + override fun onDateTimePrompt(session: GeckoSession, prompt: PromptDelegate.DateTimePrompt): GeckoResult<PromptDelegate.PromptResponse> { + assertThat("showPicker for <input type=time>", prompt.type, equalTo(PromptDelegate.DateTimePrompt.Type.TIME)) + return GeckoResult.fromValue(prompt.dismiss()) + } + }) + } + + @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/ReviewQualityCheckerTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ReviewQualityCheckerTest.kt new file mode 100644 index 0000000000..254130db36 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/ReviewQualityCheckerTest.kt @@ -0,0 +1,235 @@ +/* -*- 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 junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports +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.AnalysisStatusResponse +import org.mozilla.geckoview.GeckoSession.ContentDelegate +import org.mozilla.geckoview.GeckoSession.Recommendation +import org.mozilla.geckoview.GeckoSession.ReviewAnalysis +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ReviewQualityCheckerTest : BaseSessionTest() { + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "toolkit.shopping.ohttpRelayURL" to "", + "toolkit.shopping.ohttpConfigURL" to "", + "geckoview.shopping.mock_test_response" to true, + ), + ) + } + + @After + fun cleanup() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "geckoview.shopping.mock_test_response" to false, + ), + ) + } + + @Test + fun onProductUrl() { + mainSession.loadUri("example.com") + sessionRule.waitForPageStop() + + mainSession.loadUri("example.com/dp/ABCDEFG") + sessionRule.waitForPageStop() + + // test below working product urls + mainSession.loadUri("example.com/dp/ABCDEFG123") + sessionRule.waitForPageStop() + + mainSession.loadUri("example.com/dp/HIJKLMN456") + sessionRule.waitForPageStop() + + mainSession.loadUri("example.com/dp/OPQRSTU789") + sessionRule.waitForPageStop() + + mainSession.delegateUntilTestEnd(object : ContentDelegate { + @AssertCalled(count = 3) + override fun onProductUrl(session: GeckoSession) {} + }) + } + + @Test + fun requestAnalysis() { + // Test for the builder constructor + val productId = "banana" + val grade = "A" + val adjustedRating = 4.5 + val lastAnalysisTime = 12345.toLong() + val analysisURL = "https://analysis.com" + + val analysisObject = ReviewAnalysis.Builder(productId) + .analysisUrl(analysisURL) + .grade(grade) + .adjustedRating(adjustedRating) + .needsAnalysis(true) + .pageNotSupported(false) + .notEnoughReviews(false) + .highlights(null) + .lastAnalysisTime(lastAnalysisTime) + .deletedProductReported(true) + .deletedProduct(true) + .build() + assertThat("Analysis URL should match", analysisObject.analysisURL, equalTo(analysisURL)) + assertThat("Product id should match", analysisObject.productId, equalTo(productId)) + assertThat("Product grade should match", analysisObject.grade, equalTo(grade)) + assertThat("Product adjusted rating should match", analysisObject.adjustedRating, equalTo(adjustedRating)) + assertTrue("NeedsAnalysis should match", analysisObject.needsAnalysis) + assertFalse("PageNotSupported should match", analysisObject.pageNotSupported) + assertFalse("NotEnoughReviews should match", analysisObject.notEnoughReviews) + assertNull("Highlights should match", analysisObject.highlights) + assertTrue("Product should not be reported that it was deleted", analysisObject.deletedProductReported) + assertTrue("Not a deleted product", analysisObject.deletedProduct) + assertThat("Last analysis time should match", analysisObject.lastAnalysisTime, equalTo(lastAnalysisTime)) + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "geckoview.shopping.mock_test_response" to true, + ), + ) + val result = mainSession.requestAnalysis("https://www.example.com/mock") + sessionRule.waitForResult(result).let { + assertThat("Review analysis url should match", it.analysisURL, equalTo("https://www.example.com/mock_analysis_url")) + assertThat("Product id should match", it.productId, equalTo("ABCDEFG123")) + assertThat("Product grade should match", it.grade, equalTo("B")) + assertThat("Product adjusted rating should match", it.adjustedRating, equalTo(4.5)) + assertTrue("NeedsAnalysis should match", it.needsAnalysis) + assertTrue("PageNotSupported should match", it.pageNotSupported) + assertTrue("NotEnoughReviews should match", it.notEnoughReviews) + assertNull("Highlights should match", analysisObject.highlights) + assertThat("Last analysis time should match", analysisObject.lastAnalysisTime, equalTo(lastAnalysisTime)) + assertTrue("DeletedProductReported should match", it.deletedProductReported) + assertTrue("DeletedProduct should match", it.deletedProduct) + } + } + + @Test + fun requestCreateAnalysisAndStatus() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "geckoview.shopping.mock_test_response" to true, + ), + ) + val createResult = mainSession.requestCreateAnalysis("https://www.example.com/mock/") + assertThat("Analysis status should match", sessionRule.waitForResult(createResult), equalTo("pending")) + + val status = "in_progress" + val progress = 90.9 + + val analysisObject = AnalysisStatusResponse.Builder(status) + .progress(progress) + .build() + assertThat("Analysis URL should match", analysisObject, notNullValue()) + assertThat("Analysis URL should match", analysisObject.status, equalTo(status)) + assertThat("Product id should match", analysisObject.progress, equalTo(progress)) + + val statusResult = mainSession.requestAnalysisStatus("https://www.example.com/mock/") + sessionRule.waitForResult(statusResult).let { + assertThat( + "Analysis status should match", + it.status, + equalTo("in_progress"), + ) + assertThat( + "Analysis progress should match", + it.progress, + equalTo(90.9), + ) + } + } + + @Test + fun requestRecommendations() { + // Test the Builder constructor + val url = "https://example.com/mock_url" + val adjustedRating = 3.5 + val imageUrl = "https://example.com/mock_image_url" + val aid = "mock_aid" + val name = "Mock Product" + val grade = "C" + val price = "450" + val currency = "USD" + + val recommendationObject = Recommendation.Builder(url) + .adjustedRating(adjustedRating) + .sponsored(true) + .imageUrl(imageUrl) + .aid(aid) + .name(name) + .grade(grade) + .price(price) + .currency(currency) + .build() + assertThat("Recommendation URL should match", recommendationObject.url, equalTo(url)) + assertThat("Adjusted rating should match", recommendationObject.adjustedRating, equalTo(adjustedRating)) + assertThat("Recommendation sponsored field should match", recommendationObject.sponsored, equalTo(true)) + assertThat("Image URL should match", recommendationObject.imageUrl, equalTo(imageUrl)) + assertThat("Aid should match", recommendationObject.aid, equalTo(aid)) + assertThat("Name should match", recommendationObject.name, equalTo(name)) + assertThat("Grade should match", recommendationObject.grade, equalTo(grade)) + assertThat("Price should match", recommendationObject.price, equalTo(price)) + assertThat("Currency should match", recommendationObject.currency, equalTo(currency)) + + val result = mainSession.requestRecommendations("https://www.example.com/mock") + sessionRule.waitForResult(result) + .let { + assertThat("Recommendation URL should match", recommendationObject.url, equalTo(url)) + assertThat("Adjusted rating should match", recommendationObject.adjustedRating, equalTo(adjustedRating)) + assertThat("Recommendation sponsored field should match", recommendationObject.sponsored, equalTo(true)) + assertThat("Image URL should match", recommendationObject.imageUrl, equalTo(imageUrl)) + assertThat("Aid should match", recommendationObject.aid, equalTo(aid)) + assertThat("Name should match", recommendationObject.name, equalTo(name)) + assertThat("Grade should match", recommendationObject.grade, equalTo(grade)) + assertThat("Price should match", recommendationObject.price, equalTo(price)) + assertThat("Currency should match", recommendationObject.currency, equalTo(currency)) + } + } + + @Test + fun sendAttributionEvents() { + val aid = "TEST_AID" + val validClickResult = mainSession.sendClickAttributionEvent(aid) + assertThat( + "Click event success result should be true", + sessionRule.waitForResult(validClickResult), + equalTo(true), + ) + val validImpressionResult = mainSession.sendImpressionAttributionEvent(aid) + assertThat( + "Impression event success result should be true", + sessionRule.waitForResult(validImpressionResult), + equalTo(true), + ) + val validPlacementResult = mainSession.sendPlacementAttributionEvent(aid) + assertThat( + "Placement event success result should be true", + sessionRule.waitForResult(validPlacementResult), + equalTo(true), + ) + } + + @Test + fun reportBackInStock() { + val result = mainSession.reportBackInStock("https://www.example.com/mock") + assertThat("Report back in stock status matches", sessionRule.waitForResult(result), equalTo("report created")) + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsDefaultsTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsDefaultsTest.kt new file mode 100644 index 0000000000..99c59bf535 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsDefaultsTest.kt @@ -0,0 +1,129 @@ +/* -*- 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 +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.Setting + +@RunWith(AndroidJUnit4::class) +@MediumTest +class RuntimeSettingsDefaultsTest : BaseSessionTest() { + @Test + fun globalPrivacyControlDefaultsInNormalMode() { + mainSession.loadTestPath(HELLO2_HTML_PATH) + mainSession.waitForPageStop() + + val geckoRuntimeSettings = sessionRule.runtime.settings + val globalPrivacyControl = + (sessionRule.getPrefs("privacy.globalprivacycontrol.enabled").get(0)) as Boolean + val globalPrivacyControlPrivateMode = + (sessionRule.getPrefs("privacy.globalprivacycontrol.pbmode.enabled").get(0)) as Boolean + val globalPrivacyControlFunctionality = ( + sessionRule.getPrefs("privacy.globalprivacycontrol.functionality.enabled").get(0) + ) as Boolean + + assertThat( + "Global Privacy Control runtime settings should be disabled by default in normal tabs", + geckoRuntimeSettings.globalPrivacyControl, + equalTo(false), + ) + + assertThat( + "Global Privacy Control runtime settings should be enabled by default in private tabs", + geckoRuntimeSettings.globalPrivacyControlPrivateMode, + equalTo(true), + ) + + assertThat( + "Global Privacy Control should be disabled by default in normal tabs", + globalPrivacyControl, + equalTo(false), + ) + + assertThat( + "Global Privacy Control should be disabled by default in private tabs", + globalPrivacyControlPrivateMode, + equalTo(true), + ) + + assertThat( + "Global Privacy Control Functionality enabled by default", + globalPrivacyControlFunctionality, + equalTo(true), + ) + + val gpcValue = mainSession.evaluateJS( + "window.navigator.globalPrivacyControl", + ) + + assertThat( + "Global Privacy Control should be disabled in normal mode", + gpcValue, + equalTo(false), + ) + } + + @Test + @Setting.List(Setting(key = Setting.Key.USE_PRIVATE_MODE, value = "true")) + fun globalPrivacyControlDefaultsInPrivateMode() { + mainSession.loadTestPath(HELLO2_HTML_PATH) + mainSession.waitForPageStop() + + val geckoRuntimeSettings = sessionRule.runtime.settings + val globalPrivacyControl = + (sessionRule.getPrefs("privacy.globalprivacycontrol.enabled").get(0)) as Boolean + val globalPrivacyControlPrivateMode = + (sessionRule.getPrefs("privacy.globalprivacycontrol.pbmode.enabled").get(0)) as Boolean + val globalPrivacyControlFunctionality = + ( + sessionRule.getPrefs("privacy.globalprivacycontrol.functionality.enabled") + .get(0) + ) as Boolean + + assertThat( + "Global Privacy Control runtime settings should be disabled by default in normal tabs", + geckoRuntimeSettings.globalPrivacyControl, + equalTo(false), + ) + + assertThat( + "Global Privacy Control runtime settings should be enabled by default in private tabs", + geckoRuntimeSettings.globalPrivacyControlPrivateMode, + equalTo(true), + ) + + assertThat( + "Global Privacy Control should be disabled by default in normal tabs", + globalPrivacyControl, + equalTo(false), + ) + + assertThat( + "Global Privacy Control should be disabled by default in private tabs", + globalPrivacyControlPrivateMode, + equalTo(true), + ) + + assertThat( + "Global Privacy Control Functionality enabled by default", + globalPrivacyControlFunctionality, + equalTo(true), + ) + + val gpcValue = mainSession.evaluateJS( + "window.navigator.globalPrivacyControl", + ) + + assertThat( + "Global Privacy Control should be disabled in private mode", + gpcValue, + equalTo(true), + ) + } +} 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..6504af8a4c --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/RuntimeSettingsTest.kt @@ -0,0 +1,415 @@ +/* -*- 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 junit.framework.TestCase.assertTrue +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.BuildConfig +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 largeKeepaliveFactor() { + val defaultLargeKeepaliveFactor = 10 + val settings = sessionRule.runtime.settings + + val largeKeepaliveFactorPref = "network.http.largeKeepaliveFactor" + var prefValue = (sessionRule.getPrefs(largeKeepaliveFactorPref)[0] as Int) + assertThat( + "default LargeKeepaliveFactor should be 10", + prefValue, + `is`(defaultLargeKeepaliveFactor), + ) + + for (factor in 1..10) { + settings.setLargeKeepaliveFactor(factor) + prefValue = (sessionRule.getPrefs(largeKeepaliveFactorPref)[0] as Int) + assertThat( + "setting LargeKeepaliveFactor to an integer value between 1..10 should work", + prefValue, + `is`(factor), + ) + } + + val sanitizedDefaultLargeKeepaliveFactor = 1 + + /** + * Setting an invalid factor will cause an exception to be throw in debug build. + * otherwise, the factor will be reset to default when an invalid factor is given. + */ + try { + settings.setLargeKeepaliveFactor(128) + prefValue = (sessionRule.getPrefs(largeKeepaliveFactorPref)[0] as Int) + assertThat( + "set LargeKeepaliveFactor to default when input is invalid", + prefValue, + `is`(sanitizedDefaultLargeKeepaliveFactor), + ) + } catch (e: Exception) { + if (BuildConfig.DEBUG_BUILD) { + assertTrue("Should have an exception in DEBUG_BUILD", true) + } + } + } + + @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() + } + + @Test + fun globalPrivacyControlEnabling() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val geckoRuntimeSettings = sessionRule.runtime.settings + + geckoRuntimeSettings.setGlobalPrivacyControl(true) + + val gpcValue = mainSession.evaluateJS( + "window.navigator.globalPrivacyControl", + ) + + assertThat( + "Global Privacy Control should now be enabled", + gpcValue, + equalTo(true), + ) + + assertThat( + "Global Privacy Control runtime settings should now be enabled in normal tabs", + geckoRuntimeSettings.globalPrivacyControl, + equalTo(true), + ) + + assertThat( + "Global Privacy Control runtime settings should still be enabled in private tabs", + geckoRuntimeSettings.globalPrivacyControlPrivateMode, + equalTo(true), + ) + + val globalPrivacyControl = + (sessionRule.getPrefs("privacy.globalprivacycontrol.enabled").get(0)) as Boolean + val globalPrivacyControlPrivateMode = + (sessionRule.getPrefs("privacy.globalprivacycontrol.pbmode.enabled").get(0)) as Boolean + val globalPrivacyControlFunctionality = ( + sessionRule.getPrefs("privacy.globalprivacycontrol.functionality.enabled").get(0) + ) as Boolean + + assertThat( + "Global Privacy Control should be enabled in normal tabs", + globalPrivacyControl, + equalTo(true), + ) + + assertThat( + "Global Privacy Control should still be in private tabs", + globalPrivacyControlPrivateMode, + equalTo(true), + ) + + assertThat( + "Global Privacy Control Functionality flag should be enabled", + globalPrivacyControlFunctionality, + equalTo(true), + ) + } + + @Test + fun globalPrivacyControlDisabling() { + mainSession.loadTestPath(HELLO_HTML_PATH) + mainSession.waitForPageStop() + + val geckoRuntimeSettings = sessionRule.runtime.settings + + geckoRuntimeSettings.setGlobalPrivacyControl(false) + + val gpcValue = mainSession.evaluateJS( + "window.navigator.globalPrivacyControl", + ) + + assertThat( + "Global Privacy Control should now be disabled in normal mode", + gpcValue, + equalTo(false), + ) + + assertThat( + "Global Privacy Control runtime settings should now be enabled in normal tabs", + geckoRuntimeSettings.globalPrivacyControl, + equalTo(false), + ) + + assertThat( + "Global Privacy Control runtime settings should still be enabled in private tabs", + geckoRuntimeSettings.globalPrivacyControlPrivateMode, + equalTo(true), + ) + + val globalPrivacyControl = + (sessionRule.getPrefs("privacy.globalprivacycontrol.enabled").get(0)) as Boolean + val globalPrivacyControlPrivateMode = + (sessionRule.getPrefs("privacy.globalprivacycontrol.pbmode.enabled").get(0)) as Boolean + val globalPrivacyControlFunctionality = ( + sessionRule.getPrefs("privacy.globalprivacycontrol.functionality.enabled").get(0) + ) as Boolean + + assertThat( + "Global Privacy Control should be enabled in normal tabs", + globalPrivacyControl, + equalTo(false), + ) + + assertThat( + "Global Privacy Control should still be enabled in private tabs", + globalPrivacyControlPrivateMode, + equalTo(true), + ) + + assertThat( + "Global Privacy Control Functionality flag should still be enabled", + globalPrivacyControlFunctionality, + equalTo(true), + ) + } +} 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..cee16f3f4c --- /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 1837551 + 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..e5e8ec6ce2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/SelectionActionDelegateTest.kt @@ -0,0 +1,1024 @@ +/* -*- 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.net.Uri +import android.os.Build +import android.util.Base64 +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 pasteImage() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#contenteditable")) + + val bytes = this.getTestBytes("/assets/www/images/test.gif") + val base64Utf8String = Base64.encodeToString(bytes, Base64.NO_WRAP) + val result = "<img src=\"data:image/gif;base64,${base64Utf8String}\" alt=\"\">" + + withImageClipboard("/assets/www/images/test.gif", "image/gif") { + testThat(selectedContent, withResponse(ACTION_PASTE), changesHtmlContentTo(result)) + } + } + + @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)) + + withClipboard("clipboardReadAllow") {} // Reset clipboard data + + 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( + "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)) + + withClipboard("clipboardReadDeny") {} // Reset clipboard data + + 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( + "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)) + + withClipboard("clipboardReadDeactivate") {} // Reset clipboard data + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + val result = GeckoResult<Void>() + val permissionResult = GeckoResult<AllowOrDeny>() + 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 permissionResult + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + @AssertCalled + override fun onDismissClipboardPermissionRequest(session: GeckoSession) { + permissionResult.complete(AllowOrDeny.DENY) + } + }) + + mainSession.loadTestPath(HELLO_HTML_PATH) + sessionRule.waitForResult(permissionResult) + sessionRule.waitForPageStop() + } + + @WithDisplay(width = 100, height = 100) + @Test + fun clipboardReadDismiss() { + assumeThat("Unnecessary to run multiple times", id, equalTo("#text")) + + sessionRule.setPrefsUntilTestEnd(mapOf("dom.events.asyncClipboard.readText" to true)) + + withClipboard("clipboardReadDismiss") {} // Reset clipboard data + + val url = createTestUrl(CLIPBOARD_READ_HTML_PATH) + mainSession.loadUri(url) + mainSession.waitForPageStop() + + val result = GeckoResult<Void>() + val permissionResult = GeckoResult<AllowOrDeny>() + 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 permissionResult + } + }) + + mainSession.synthesizeTap(50, 50) // Provides user activation. + sessionRule.waitForResult(result) + + mainSession.delegateDuringNextWait(object : SelectionActionDelegate { + @AssertCalled + override fun onDismissClipboardPermissionRequest(session: GeckoSession) { + permissionResult.complete(AllowOrDeny.DENY) + } + }) + + mainSession.synthesizeTap(10, 10) // click to dismiss. + sessionRule.waitForResult(permissionResult) + } + + /** Interface that defines behavior for a particular type of content */ + private interface SelectedContent { + fun focus() {} + fun select() {} + val initialContent: String + val content: String + val htmlContent: 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() + sessionRule.waitForContentTransformsReceived(mainSession) + + val requestClientRect: (String) -> RectF = { + mainSession.reload() + mainSession.waitForPageStop() + sessionRule.waitForContentTransformsReceived(mainSession) + + 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 expectedDiff$expectedDiff", + 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 withImageClipboard(contentPath: String = "", mime: String = "", lambda: () -> Unit) { + val oldClip = clipboard.primaryClip + try { + TestContentProvider.setTestData(this.getTestBytes(contentPath), mime) + val clipData = ClipData("image", arrayOf(mime), ClipData.Item(Uri.parse("content://org.mozilla.geckoview.test.provider/gif"))) + clipboard.setPrimaryClip(clipData) + + 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 htmlContent: String get() { + return mainSession.evaluateJS("document.querySelector('$id').innerHTML") 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 htmlContent: String get() { + return content + } + + 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 htmlContent: String get() { + return content + } + + 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 htmlContent: String get() { + return content + } + + 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)) + } + + private fun changesHtmlContentTo(content: String) = { it: SelectedContent -> + assertThat("Changed HTML content should match", it.htmlContent, 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..39dc1be489 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TestCrashHandler.java @@ -0,0 +1,329 @@ +/* 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.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import org.json.JSONException; +import org.json.JSONObject; +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. + * @param expectedRemoteType The type of content process the incoming crash is expected to be + * for. + */ + public void setEvalNextCrashDump( + final String expectedProcessType, final String expectedRemoteType) { + setEvalResult(null); + mReceiver.post( + new Runnable() { + @Override + public void run() { + final Bundle bundle = new Bundle(); + bundle.putString(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, expectedProcessType); + bundle.putString(GeckoRuntime.EXTRA_CRASH_REMOTE_TYPE, expectedRemoteType); + 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; + private String mExpectedRemoteType; + + 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); + mExpectedRemoteType = bundle.getString(GeckoRuntime.EXTRA_CRASH_REMOTE_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; + } + + public String getExpectedRemoteType() { + return mExpectedRemoteType; + } + } + + private Messenger mMessenger; + private MessageHandler mMsgHandler; + + public TestCrashHandler() {} + + 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 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(); + try { + final JSONObject annotations = readExtraFile(extrasFile.getPath()); + final String moz_crash_reason = annotations.getString("MozCrashReason"); + + if (!moz_crash_reason.startsWith("MOZ_CRASH(")) { + return new EvalResult(false, "Missing or invalid child crash annotations"); + } + + extrasFile.delete(); + } catch (final Exception e) { + return new EvalResult(false, e.toString()); + } + + 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); + } + + final String expectedRemoteType = mMsgHandler.getExpectedRemoteType(); + final String remoteType = intent.getStringExtra(GeckoRuntime.EXTRA_CRASH_REMOTE_TYPE); + if ((remoteType == null && expectedRemoteType != null) + || (remoteType != null && !remoteType.equals(expectedRemoteType))) { + return new EvalResult( + false, "Expected remote type " + expectedRemoteType + ", found " + remoteType); + } + + 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..7e4015a246 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TextInputDelegateTest.kt @@ -0,0 +1,1406 @@ +/* -*- 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) + processChildEvents() + pressKey(ic, KeyEvent.KEYCODE_DPAD_LEFT) + processChildEvents() + 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) + processChildEvents() + pressKey(ic, KeyEvent.KEYCODE_DPAD_RIGHT) + processChildEvents() + 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"))) + 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"))) + + 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/TranslationsTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TranslationsTest.kt new file mode 100644 index 0000000000..a2c4eede62 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TranslationsTest.kt @@ -0,0 +1,624 @@ +/* -*- 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 junit.framework.TestCase.assertTrue +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.GeckoResult +import org.mozilla.geckoview.GeckoSession +import org.mozilla.geckoview.TranslationsController +import org.mozilla.geckoview.TranslationsController.Language +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.ALL +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.ALWAYS +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.DELETE +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.DOWNLOAD +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.LANGUAGE +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.ModelManagementOptions +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.NEVER +import org.mozilla.geckoview.TranslationsController.RuntimeTranslation.OFFER +import org.mozilla.geckoview.TranslationsController.SessionTranslation.Delegate +import org.mozilla.geckoview.TranslationsController.SessionTranslation.TranslationOptions +import org.mozilla.geckoview.TranslationsController.SessionTranslation.TranslationState +import org.mozilla.geckoview.TranslationsController.TranslationsException +import org.mozilla.geckoview.TranslationsController.TranslationsException.ERROR_MODEL_COULD_NOT_DELETE +import org.mozilla.geckoview.TranslationsController.TranslationsException.ERROR_MODEL_COULD_NOT_DOWNLOAD +import org.mozilla.geckoview.TranslationsController.TranslationsException.ERROR_MODEL_DOWNLOAD_REQUIRED +import org.mozilla.geckoview.TranslationsController.TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED +import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled + +@RunWith(AndroidJUnit4::class) +@MediumTest +class TranslationsTest : BaseSessionTest() { + @Before + fun setup() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "browser.translations.enable" to true, + "browser.translations.automaticallyPopup" to true, + "intl.accept_languages" to "en", + "browser.translations.geckoview.enableAllTestMocks" to true, + "browser.translations.simulateUnsupportedEngine" to false, + ), + ) + } + + @After + fun cleanup() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "browser.translations.automaticallyPopup" to false, + "browser.translations.geckoview.enableAllTestMocks" to false, + ), + ) + } + + private var mockedExpectedLanguages: List<TranslationsController.Language> = listOf( + Language("en", "English"), + Language("es", "Spanish"), + ) + + @Test + fun onExpectedTranslateDelegateTest() { + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + + val handled = GeckoResult<Void>() + sessionRule.delegateUntilTestEnd(object : Delegate { + @AssertCalled(count = 1) + override fun onExpectedTranslate(session: GeckoSession) { + handled.complete(null) + } + }) + var expectedTranslateEvent = JSONObject( + """ + { + "actor":{ + "languageState":{ + "detectedLanguages": { + "userLangTag": "en", + "isDocLangTagSupported": true, + "docLangTag": "es" + }, + "requestedTranslationPair": null, + "error": null, + "isEngineReady": false + } + } + } + """.trimIndent(), + ) + mainSession.triggerLanguageStateChange(expectedTranslateEvent) + sessionRule.waitForResult(handled) + } + + @Test + fun onOfferTranslateDelegateTest() { + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + + val handled = GeckoResult<Void>() + sessionRule.delegateUntilTestEnd(object : Delegate { + @AssertCalled(count = 1) + override fun onOfferTranslate(session: GeckoSession) { + handled.complete(null) + } + }) + + mainSession.triggerTranslationsOffer() + sessionRule.waitForResult(handled) + } + + @Test + fun onTranslationStateChangeDelegateTest() { + if (sessionRule.env.isAutomation) { + sessionRule.delegateDuringNextWait(object : Delegate { + @AssertCalled(count = 1) + override fun onTranslationStateChange( + session: GeckoSession, + translationState: TranslationState?, + ) { + } + }) + } else { + // For use when running from Android Studio + sessionRule.delegateDuringNextWait(object : Delegate { + @AssertCalled(count = 2) + override fun onTranslationStateChange( + session: GeckoSession, + translationState: TranslationState?, + ) { + } + }) + } + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + } + + // Simpler translation test that doesn't test delegate state. + // Tests en -> es + @Test + fun simpleTranslateTest() { + mainSession.loadTestPath(TRANSLATIONS_EN) + mainSession.waitForPageStop() + // No options specified should just perform default expectations + val translate = sessionRule.session.sessionTranslation!!.translate("en", "es", null) + try { + sessionRule.waitForResult(translate) + assertTrue("Translate should complete.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception while translating.", false) + } + + // Options should work as expected + var options = TranslationOptions.Builder().downloadModel(true).build() + val translateOptions = sessionRule.session.sessionTranslation!!.translate("en", "es", options) + try { + sessionRule.waitForResult(translateOptions) + assertTrue("Translate should complete with options.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception while translating with options.", false) + } + + // Language tags should be fault tolerant of minor variations + val longLanguageTag = sessionRule.session.sessionTranslation!!.translate("EN", "ES", null) + try { + sessionRule.waitForResult(longLanguageTag) + assertTrue("Translate should complete with longer language tag.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception while translating with a longer language tag.", false) + } + } + + @Test + fun simpleTranslateFailureTest() { + // Note: Test endpoint is using a mocked response for checking sizes in CI + mainSession.loadTestPath(TRANSLATIONS_EN) + mainSession.waitForPageStop() + + // In Android Studio tests, it is checking for real models, so delete to ensure clear test framework. + if (!sessionRule.env.isAutomation) { + val allDeleteAttempt = ModelManagementOptions.Builder() + .operation(DELETE) + .operationLevel(ALL) + .build() + sessionRule.waitForResult(RuntimeTranslation.manageLanguageModel(allDeleteAttempt)) + } + + var options = TranslationOptions.Builder().downloadModel(false).build() + val translate = sessionRule.session.sessionTranslation!!.translate("en", "es", options) + try { + sessionRule.waitForResult(translate) + assertTrue("Translate should not complete", false) + } catch (e: RuntimeException) { + // Wait call causes a runtime exception too. + val te = e.cause as TranslationsException + assertTrue( + "Correctly rejected performing a download for a translation.", + te.code == ERROR_MODEL_DOWNLOAD_REQUIRED, + ) + } + } + + // More comprehensive translation test that also tests delegate state. + // Tests es -> en + @Test + fun translateTest() { + var delegateCalled = 0 + sessionRule.delegateUntilTestEnd(object : Delegate { + @AssertCalled(count = 3) + override fun onTranslationStateChange( + session: GeckoSession, + translationState: TranslationState?, + ) { + delegateCalled++ + // Before page load + if (delegateCalled == 1) { + assertTrue( + "Translations correctly does not have a requested pair.", + translationState?.requestedTranslationPair == null, + ) + } + // Page load + if (delegateCalled == 2) { + assertTrue("Translations correctly has detected a page language. ", translationState?.detectedLanguages?.docLangTag == "es") + } + + // Translate + if (delegateCalled == 3) { + assertTrue("Translations correctly has set a translation pair from language. ", translationState?.requestedTranslationPair?.fromLanguage == "es") + assertTrue("Translations correctly has set a translation pair to language. ", translationState?.requestedTranslationPair?.toLanguage == "en") + } + } + }) + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + val translate = sessionRule.session.sessionTranslation!!.translate("es", "en", null) + try { + sessionRule.waitForResult(translate) + assertTrue("Should be able to translate.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception.", false) + } + } + + @Test + fun checkPairDownloadSizeTest() { + // Note: Test endpoint is using a mocked response when checking sizes in CI + val size = RuntimeTranslation.checkPairDownloadSize("es", "en") + try { + val result = sessionRule.waitForResult(size) + assertTrue("Should return a download size.", true) + if (sessionRule.env.isAutomation) { + assertTrue("Received mocked value of 1234567.", result == 1234567L) + } + } catch (e: Exception) { + assertTrue("Should not have an exception.", false) + } + } + + @Test + fun restoreOriginalPageLanguageTest() { + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + val restore = sessionRule.session.sessionTranslation!!.restoreOriginalPage() + try { + sessionRule.waitForResult(restore) + assertTrue("Should be able to restore.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception.", false) + } + } + + @Test + fun testTranslationOptions() { + var options = TranslationOptions.Builder().downloadModel(true).build() + assertTrue("TranslationOptions builder options work as expected.", options.downloadModel) + } + + @Test + fun testIsTranslationsEngineSupported() { + sessionRule.setPrefsUntilTestEnd(mapOf("browser.translations.simulateUnsupportedEngine" to false)) + val isSupportedResult = TranslationsController.RuntimeTranslation.isTranslationsEngineSupported() + assertTrue( + "The translations engine is correctly reporting as supported.", + sessionRule.waitForResult(isSupportedResult), + ) + } + + @Test + fun testIsTranslationsEngineNotSupported() { + sessionRule.setPrefsUntilTestEnd(mapOf("browser.translations.simulateUnsupportedEngine" to true)) + val isSupportedResult = TranslationsController.RuntimeTranslation.isTranslationsEngineSupported() + assertTrue( + "The translations engine is correctly reporting as not supported.", + sessionRule.waitForResult(isSupportedResult) == false, + ) + } + + @Test + fun testGetPreferredLanguage() { + sessionRule.setPrefsUntilTestEnd(mapOf("intl.accept_languages" to "fr-CA, it, de")) + val preferredLanguages = TranslationsController.RuntimeTranslation.preferredLanguages() + sessionRule.waitForResult(preferredLanguages).let { languages -> + assertTrue( + "French is the first language preference.", + languages[0] == "fr", + ) + assertTrue( + "Italian is the second language preference.", + languages[1] == "it", + ) + assertTrue( + "German is the third language preference.", + languages[2] == "de", + ) + // "en" is likely the 4th preference via system language; + // however, this is difficult to guarantee/set in automation. + } + } + + @Test + fun testManageLanguageModel() { + val options = ModelManagementOptions.Builder() + .languageToManage("en") + .operation(TranslationsController.RuntimeTranslation.DOWNLOAD) + .build() + + assertTrue("ModelManagementOptions builder options work as expected.", options.language == "en" && options.operation == DOWNLOAD) + } + + @Test + fun testListSupportedLanguages() { + // Note: Test endpoint is using a mocked response + val translationDropdowns = TranslationsController.RuntimeTranslation.listSupportedLanguages() + try { + sessionRule.waitForResult(translationDropdowns) + assertTrue("Should be able to list supported languages.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception.", false) + } + var fromPresent = true + var toPresent = true + sessionRule.waitForResult(translationDropdowns).let { dropdowns -> + // Test is checking for minimum options are present based on mocked expectations. + for (expected in mockedExpectedLanguages) { + if (!dropdowns.fromLanguages!!.contains(expected)) { + assertTrue("Language $expected was not in from list.", false) + fromPresent = false + } + if (!dropdowns.toLanguages!!.contains(expected)) { + assertTrue("Language $expected was not in to list.", false) + toPresent = false + } + } + } + assertTrue( + "All primary from languages are present.", + fromPresent, + ) + assertTrue( + "All primary to languages are present.", + toPresent, + ) + } + + @Test + fun testListModelDownloadStates() { + // Note: Test endpoint is using a mocked response + var modelStatesResult = TranslationsController.RuntimeTranslation.listModelDownloadStates() + try { + sessionRule.waitForResult(modelStatesResult) + assertTrue("Should not be able to list models.", true) + } catch (e: Exception) { + assertTrue("Should not have an exception.", false) + } + + sessionRule.waitForResult(modelStatesResult).let { models -> + assertTrue( + "Received information on the state of the models.", + models.size >= mockedExpectedLanguages.size - 1, + ) + assertTrue( + "Received information on the size in bytes of the first returned model.", + models[0].size > 0, + ) + assertTrue( + "Received information on the language of the first returned model.", + models[0].language != null, + ) + assertTrue( + "Received information on the download state of the first returned model", + !models[0].isDownloaded, + ) + } + } + + @Test + fun testSetLanguageSettings() { + // Not a valid language tag + try { + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("EN_US", NEVER)) + } catch (e: Exception) { + assertTrue("Should have an exception, this isn't a valid tag.", true) + } + + // Capital BG is non-canonical BCP 47, but the API should normalize it to "bg". + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("BG", ALWAYS)) + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("fr", OFFER)) + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("de", NEVER)) + + // Query corresponding prefs + val alwaysTranslate = (sessionRule.getPrefs("browser.translations.alwaysTranslateLanguages").get(0) as String).split(",") + val neverTranslate = (sessionRule.getPrefs("browser.translations.neverTranslateLanguages").get(0) as String).split(",") + + // Test setting + assertTrue("BG was correctly set to ALWAYS", alwaysTranslate.contains("bg")) + assertTrue("FR was correctly set to OFFER", !alwaysTranslate.contains("fr") && !neverTranslate.contains("fr")) + assertTrue("DE was correctly set to NEVER", neverTranslate.contains("de")) + + // Reset back to offer + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("BG", OFFER)) + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("fr", OFFER)) + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.setLanguageSettings("de", OFFER)) + + // Query corresponding prefs + val alwaysTranslateReset = (sessionRule.getPrefs("browser.translations.alwaysTranslateLanguages").get(0) as String).split(",") + val neverTranslateReset = (sessionRule.getPrefs("browser.translations.neverTranslateLanguages").get(0) as String).split(",") + + // Test offer reset + assertTrue("BG was correctly set back to OFFER", !alwaysTranslateReset.contains("bg") && !neverTranslateReset.contains("bg")) + assertTrue("FR was correctly set back to OFFER", !alwaysTranslateReset.contains("fr") && !neverTranslateReset.contains("fr")) + assertTrue("DE was correctly set back to OFFER", !alwaysTranslateReset.contains("de") && !neverTranslateReset.contains("de")) + } + + @Test + fun testGetLanguageSettings() { + // Note: Test endpoint is using a mocked response and doesn't reflect actual prefs + var languageSettings: Map<String, String> = + sessionRule.waitForResult(TranslationsController.RuntimeTranslation.getLanguageSettings()) + + var frLanguageSetting = sessionRule.waitForResult(TranslationsController.RuntimeTranslation.getLanguageSetting("fr")) + + if (sessionRule.env.isAutomation) { + assertTrue("FR was correctly set to ALWAYS via full query.", languageSettings["fr"] == ALWAYS) + assertTrue("FR was correctly set to ALWAYS via individual query.", frLanguageSetting == ALWAYS) + assertTrue("DE was correctly set to OFFER via full query.", languageSettings["de"] == OFFER) + assertTrue("ES was correctly set to NEVER via full query.", languageSettings["es"] == NEVER) + } else { + // For use when running from Android Studio + assertTrue("Correctly queried language settings.", languageSettings.isNotEmpty()) + assertTrue("Correctly queried FR language setting.", frLanguageSetting.isNotEmpty()) + } + } + + @Test + fun testOfferPopup() { + assertTrue("Translation offer popups are enabled, as expected.", sessionRule.runtime.settings.translationsOfferPopup) + sessionRule.runtime.settings.translationsOfferPopup = false + assertTrue("Translation offer popups are disabled, as expected.", !sessionRule.runtime.settings.translationsOfferPopup) + val finalPrefCheck = (sessionRule.getPrefs("browser.translations.automaticallyPopup").get(0)) as Boolean + assertTrue("Translation offer popups are disabled, as expected and match test harness reported value.", finalPrefCheck == sessionRule.runtime.settings.translationsOfferPopup) + } + + @Test + fun testNeverTranslateSite() { + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + + var neverTranslateSetting = sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.neverTranslateSiteSetting) + assertTrue("Expect never translate to be false on a new page.", !neverTranslateSetting) + + sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.setNeverTranslateSiteSetting(true)) + neverTranslateSetting = sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.neverTranslateSiteSetting) + assertTrue("Expect never translate to be true after setting.", neverTranslateSetting) + + sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.setNeverTranslateSiteSetting(false)) + } + + @Test + fun testNeverTranslateSpecificSite() { + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + + // Get never translate list using Runtime API (if any) and clear never translate settings + var listOfSitesNeverToTranslate = sessionRule.waitForResult(RuntimeTranslation.getNeverTranslateSiteList()) + for (site in listOfSitesNeverToTranslate) { + sessionRule.waitForResult(RuntimeTranslation.setNeverTranslateSpecifiedSite(false, site)) + } + + // Get never translate list using Runtime API + listOfSitesNeverToTranslate = sessionRule.waitForResult(RuntimeTranslation.getNeverTranslateSiteList()) + assertTrue("Expect there to be no never translate sites set.", listOfSitesNeverToTranslate.isEmpty()) + + // Set site using Session API and confirm set + sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.setNeverTranslateSiteSetting(true)) + var sessionNeverTranslateSetting = sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.neverTranslateSiteSetting) + assertTrue("Expect never translate to be true after setting using session API.", sessionNeverTranslateSetting) + + // Get list again using Runtime API + listOfSitesNeverToTranslate = sessionRule.waitForResult(RuntimeTranslation.getNeverTranslateSiteList()) + assertTrue("Expect there to be one site in the list after setting.", listOfSitesNeverToTranslate.size == 1) + + // Unset using Runtime API + sessionRule.waitForResult(RuntimeTranslation.setNeverTranslateSpecifiedSite(false, listOfSitesNeverToTranslate[0])) + + // Check unset again using Session API + sessionNeverTranslateSetting = sessionRule.waitForResult(sessionRule.session.sessionTranslation!!.neverTranslateSiteSetting) + assertTrue("Expect never translate to be false after unsetting using runtime API.", !sessionNeverTranslateSetting) + } + + @Test + fun testBCP47PrefSetting() { + // Only test when running locally in Android Studio (not ./mach geckoview-junit) + // Remote settings and translations behaves the same as production when ran from Android Studio. + if (!sessionRule.env.isAutomation) { + // Check that nothing has been set between test runs + val activeTranslationPrefs = ( + sessionRule.getPrefs("browser.translations.alwaysTranslateLanguages") + .get(0) as String + ) + assertTrue( + "There should be no active preferences for always translate set. Preferences: $activeTranslationPrefs", + activeTranslationPrefs == "", + ) + + // Set to always translate + sessionRule.waitForResult( + TranslationsController.RuntimeTranslation.setLanguageSettings( + "ES", + ALWAYS, + ), + ) + + var translateCompleted = GeckoResult<Void>() + sessionRule.delegateUntilTestEnd(object : Delegate { + @AssertCalled(count = 4) + override fun onTranslationStateChange( + session: GeckoSession, + translationState: TranslationState?, + ) { + if (translationState?.isEngineReady == true) { + assertTrue("Auto requested the from language as Spanish on the page.", translationState.requestedTranslationPair?.fromLanguage == "es") + translateCompleted.complete(null) + } + } + }) + + mainSession.loadTestPath(TRANSLATIONS_ES) + mainSession.waitForPageStop() + sessionRule.waitForResult(translateCompleted) + + // Reset back to offer + sessionRule.waitForResult( + TranslationsController.RuntimeTranslation.setLanguageSettings( + "ES", + OFFER, + ), + ) + } + } + + @Test + fun testManageLanguageModelErrors() { + val missingLanguage = ModelManagementOptions.Builder() + .operation(DOWNLOAD) + .operationLevel(LANGUAGE) + .build() + try { + sessionRule.waitForResult(RuntimeTranslation.manageLanguageModel(missingLanguage)) + assertTrue("Should not complete requests on an incompatible state.", false) + } catch (e: RuntimeException) { + // Wait call causes a runtime exception too. + val te = e.cause as TranslationsException + assertTrue( + "Correctly rejected an incompatible state with missing language.", + te.code == ERROR_MODEL_LANGUAGE_REQUIRED, + ) + } + + // In the Android Studio test runner, these should be skipped because Remote Settings is + // active. However, in CI, these will fail as expected because no download service is available. + if (sessionRule.env.isAutomation) { + val allDownloadAttempt = ModelManagementOptions.Builder() + .operation(DOWNLOAD) + .operationLevel(ALL) + .build() + try { + sessionRule.waitForResult(RuntimeTranslation.manageLanguageModel(allDownloadAttempt)) + assertTrue("Should not complete downloads in automation.", false) + } catch (e: RuntimeException) { + // Wait call causes a runtime exception too. + val te = e.cause as TranslationsException + assertTrue( + "Correctly could not download on automated test harness.", + te.code == ERROR_MODEL_COULD_NOT_DOWNLOAD, + ) + } + + val allDeleteAttempt = ModelManagementOptions.Builder() + .operation(DELETE) + .operationLevel(ALL) + .build() + try { + sessionRule.waitForResult(RuntimeTranslation.manageLanguageModel(allDeleteAttempt)) + assertTrue("Should not complete deletes in automation.", false) + } catch (e: RuntimeException) { + // Wait call causes a runtime exception too. + val te = e.cause as TranslationsException + assertTrue( + "Correctly could not delete on automated test harness.", + te.code == ERROR_MODEL_COULD_NOT_DELETE, + ) + } + } + } +} diff --git a/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrustedRecursiveResolverTest.kt b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrustedRecursiveResolverTest.kt new file mode 100644 index 0000000000..b2dfd754c2 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/TrustedRecursiveResolverTest.kt @@ -0,0 +1,86 @@ +/* -*- 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.GeckoRuntimeSettings + +@RunWith(AndroidJUnit4::class) +@MediumTest +class TrustedRecursiveResolverTest : BaseSessionTest() { + + @Test fun trustedRecursiveResolverMode() { + val settings = sessionRule.runtime.settings + val trustedRecursiveResolverModePerf = "network.trr.mode" + + var prefValue = (sessionRule.getPrefs(trustedRecursiveResolverModePerf)[0] as Int) + assertThat( + "Initial TRR mode should be TRR_MODE_OFF (0)", + prefValue, + `is`(0), + ) + + settings.setTrustedRecursiveResolverMode(GeckoRuntimeSettings.TRR_MODE_FIRST) + prefValue = (sessionRule.getPrefs(trustedRecursiveResolverModePerf)[0] as Int) + + assertThat( + "Setting TRR mode to TRR_MODE_FIRST (2)", + prefValue, + `is`(2), + ) + + settings.setTrustedRecursiveResolverMode(GeckoRuntimeSettings.TRR_MODE_ONLY) + prefValue = (sessionRule.getPrefs(trustedRecursiveResolverModePerf)[0] as Int) + + assertThat( + "Setting TRR mode to TRR_MODE_ONLY (3)", + prefValue, + `is`(3), + ) + + settings.setTrustedRecursiveResolverMode(GeckoRuntimeSettings.TRR_MODE_DISABLED) + prefValue = (sessionRule.getPrefs(trustedRecursiveResolverModePerf)[0] as Int) + + assertThat( + "Setting TRR mode to TRR_MODE_DISABLED (5)", + prefValue, + `is`(5), + ) + + settings.setTrustedRecursiveResolverMode(GeckoRuntimeSettings.TRR_MODE_OFF) + prefValue = (sessionRule.getPrefs(trustedRecursiveResolverModePerf)[0] as Int) + + assertThat( + "Setting TRR mode to TRR_MODE_OFF (0)", + prefValue, + `is`(0), + ) + } + + @Test fun trustedRecursiveResolverUrl() { + val settings = sessionRule.runtime.settings + val trustedRecursiveResolverUriPerf = "network.trr.uri" + + var prefValue = (sessionRule.getPrefs(trustedRecursiveResolverUriPerf)[0] as String) + assertThat( + "Initial TRR Uri should be empty", + prefValue, + `is`(""), + ) + + val exampleValue = "https://mozilla.cloudflare-dns.com/dns-query" + settings.setTrustedRecursiveResolverUri(exampleValue) + prefValue = (sessionRule.getPrefs(trustedRecursiveResolverUriPerf)[0] as String) + assertThat( + "Setting custom TRR Uri should work", + prefValue, + `is`(exampleValue), + ) + } +} 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..5cc94e7bc9 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExecutorTest.kt @@ -0,0 +1,542 @@ +/* -*- 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 androidx.test.filters.MediumTest +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 + 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..126e52da34 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebExtensionTest.kt @@ -0,0 +1,3485 @@ +/* -*- 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.Matchers.greaterThan +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") + + // Check some of the metadata + assertEquals(borderify.metaData.incognito, "spanning") + + // 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, + signatureDisabled: Boolean = false, + appVersionDisabled: Boolean = false, + ) { + val enabled = !userDisabled && !appDisabled && !blocklistDisabled && !signatureDisabled && !appVersionDisabled + + 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), + ) + assertThat( + "signatureDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.SIGNATURE > 0, + equalTo(signatureDisabled), + ) + assertThat( + "appVersionDisabled should match", + extension.metaData.disabledFlags and DisabledFlags.APP_VERSION > 0, + equalTo(appVersionDisabled), + ) + } + + @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", + null, + ), + ) + 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, + ) + assertEquals(extension.metaData.incognito, "spanning") + + return GeckoResult.allow() + } + }) + + val borderify = sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/borderify.xpi", + null, + ), + ) + + 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", + null, + ), + ) + + // 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, + ), + ) + + // Wait for the onReady AddonManagerDelegate method to be called, and assert + // that the baseUrl and optionsPageUrl are both available as expected. + val onReadyResult = GeckoResult<Void>() + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.AddonManagerDelegate::class, + { delegate -> controller.setAddonManagerDelegate(delegate) }, + { controller.setAddonManagerDelegate(null) }, + object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 1) + override fun onReady(extension: WebExtension) { + assertNotNull(extension.metaData.baseUrl) + assertTrue(extension.metaData.baseUrl.matches("^moz-extension://[0-9a-f\\-]*/$".toRegex())) + assertNotNull(extension.metaData.optionsPageUrl) + assertTrue((extension.metaData.optionsPageUrl ?: "").matches("^moz-extension://[0-9a-f\\-]*/options.html$".toRegex())) + onReadyResult.complete(null) + super.onReady(extension) + } + }, + ) + + 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", + null, + ), + ) + + // In the onReady AddonManagerDelegate optionsPageUrl metadata is asserted again + // and expected to not be empty anymore. + assertNull(dummy.metaData.optionsPageUrl) + + sessionRule.waitForResult(onReadyResult) + 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", + null, + ) + val dummyResult = controller.install( + "resource://android/assets/web_extensions/dummy.xpi", + null, + ) + + 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, + expectedExtensionID: String?, + expectedExtension: Boolean = true, + ) { + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 0) + 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 = 1) + override fun onInstallationFailed( + extension: WebExtension?, + installException: InstallException, + ) { + // Make sure the extension is present when it should be. + assertEquals(expectedExtension, extension != null) + assertEquals(expectedExtensionID, extension?.id) + assertEquals(installException.code, expectedError) + } + }, + ) + sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/$name", + null, + ) + .accept({ + // We should not be able to install an extension here. + 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 + } + + private fun testInstallUnsignedExtensionSignatureNotRequired( + extensionArchiveURL: String, + extensionName: String, + ) { + 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(extensionArchiveURL, null) + .then { extension -> + assertEquals( + extension!!.metaData.signedState, + WebExtension.SignedStateFlags.MISSING, + ) + assertEquals( + extension.metaData.blocklistState, + WebExtension.BlocklistStateFlags.NOT_BLOCKED, + ) + assertEquals(extension.metaData.name, extensionName) + GeckoResult.fromValue(extension) + }, + ) + + sessionRule.waitForResult(controller.uninstall(borderify)) + } + + @Test + fun installUnsignedExtensionSignatureNotRequired() { + testInstallUnsignedExtensionSignatureNotRequired( + extensionArchiveURL = "resource://android/assets/web_extensions/borderify-unsigned.xpi", + extensionName = "Borderify", + ) + } + + @Test + fun installUnsignedExtensionAsZipFile() { + testInstallUnsignedExtensionSignatureNotRequired( + extensionArchiveURL = "resource://android/assets/web_extensions/borderify-unsigned.zip", + extensionName = "Borderify", + ) + } + + @Test + fun installUnsignedExtensionSignatureRequired() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to true, + ), + ) + testInstallError( + name = "borderify-unsigned.xpi", + expectedError = InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, + expectedExtensionID = null, + expectedExtension = false, + ) + } + + @Test + fun installUnsignedExtensionSignatureRequiredAsZipFile() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to true, + ), + ) + testInstallError( + name = "borderify-unsigned.zip", + expectedError = InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, + expectedExtensionID = null, + expectedExtension = false, + ) + } + + @Test + fun installExtensionFileNotFound() { + testInstallError( + name = "file-not-found.xpi", + expectedError = InstallException.ErrorCodes.ERROR_NETWORK_FAILURE, + expectedExtensionID = null, + expectedExtension = false, + ) + } + + @Test + fun installExtensionMissingId() { + testInstallError( + name = "borderify-missing-id.xpi", + expectedError = InstallException.ErrorCodes.ERROR_CORRUPT_FILE, + expectedExtensionID = null, + expectedExtension = false, + ) + } + + @Test + fun corruptFileErrorWillNotReturnAnWebExtensionWithoutId() { + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 0) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + return GeckoResult.allow() + } + }) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.AddonManagerDelegate { + @AssertCalled(count = 1) + override fun onInstallationFailed( + extension: WebExtension?, + installException: InstallException, + ) { + assertNull(extension) + } + }) + + sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/borderify-missing-id.xpi", + null, + ) + .accept({ + // We should not be able to install extensions without an id. + assertTrue(false) + }, { exception -> + val installException = exception as WebExtension.InstallException + assertEquals(installException.code, InstallException.ErrorCodes.ERROR_CORRUPT_FILE) + }), + ) + } + + @Test + fun installExtensionIncompatible() { + testInstallError( + name = "dummy-incompatible.xpi", + expectedError = InstallException.ErrorCodes.ERROR_INCOMPATIBLE, + expectedExtensionID = "dummy@tests.mozilla.org", + expectedExtension = true, + ) + } + + @Test + fun installAddonUnsupportedType() { + testInstallError( + name = "langpack_signed.xpi", + expectedError = InstallException.ErrorCodes.ERROR_UNSUPPORTED_ADDON_TYPE, + expectedExtensionID = "langpack-klingon@firefox.mozilla.org", + expectedExtension = true, + ) + } + + @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", + null, + ).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", null), + ) + + 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", null), + ) + + 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", null), + ) + + 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 1837551 + 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.update.enabled" to true, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + "extensions.getAddons.cache.enabled" to true, + "extensions.getAddons.cache.lastUpdate" to 0, + ), + ) + 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", null), + ) + + 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("") + + // This pref should have been updated because we expect the cached + // metadata to have been refreshed. + val geckoPrefs = sessionRule.getPrefs( + "extensions.getAddons.cache.lastUpdate", + ) + assumeThat(geckoPrefs[0] as Int, greaterThan(0)) + } + + @Test + fun updateDisabled() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + // This is the important change here: + "extensions.update.enabled" to false, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + // Install an extension that can be updated. + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi", null), + ) + + // Attempt to update the extension, which should not be possible since + // we set the pref to `false` above. + val update2 = sessionRule.waitForResult(controller.update(update1)) + assertNull(update2) + + // Cleanup. + sessionRule.waitForResult(controller.uninstall(update1)) + } + + @Test + fun updateWithMetadataNotStale() { + val now = (System.currentTimeMillis() / 1000).toInt() + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.update.enabled" to true, + "extensions.install.requireBuiltInCerts" to false, + "extensions.update.requireBuiltInCerts" to false, + "extensions.getAddons.cache.enabled" to true, + "extensions.getAddons.cache.lastUpdate" to now, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.version, "1.0") + + return GeckoResult.allow() + } + }) + + // 1. Install + val update1 = sessionRule.waitForResult( + controller.install("https://example.org/tests/junit/update-1.xpi", null), + ) + // 2. Update + val update2 = sessionRule.waitForResult(controller.update(update1)) + // 3. Uninstall + sessionRule.waitForResult(controller.uninstall(update2)) + + // This pref should not have been updated because the cache isn't stale + // (we set the pref to the current time at the top of this test case). + val geckoPrefs = sessionRule.getPrefs( + "extensions.getAddons.cache.lastUpdate", + ) + assumeThat(geckoPrefs[0] as Int, equalTo(now)) + } + + // Test extension updating when the new extension has different permissions. + @Test + fun updateWithPerms() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "xpinstall.signatures.required" to false, + "extensions.update.enabled" to true, + "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", null), + ) + + 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", + null, + ), + ) + + 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.update.enabled" to true, + "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", null), + ) + + 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", null) + 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() + } + }) + + val install = controller.install( + "resource://android/assets/web_extensions/borderify.xpi", + null, + ) + 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.update.enabled" to true, + "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", + null, + ), + ) + + 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.update.enabled" to true, + "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", + null, + ), + ) + + 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", + null, + ), + ) + + 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", + null, + ), + ) + + 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 + } + } + + @Test + fun testMozAddonManagerDisabledByDefault() { + // Assert the expected precondition (the pref to be set to false by default). + val geckoPrefs = sessionRule.getPrefs( + "extensions.webapi.enabled", + ) + assumeThat(geckoPrefs[0] as Boolean, equalTo(false)) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + // This pref normally exposes the mozAddonManager API to `example.com`. + sessionRule.setPrefsUntilTestEnd(mapOf("extensions.webapi.testing" to true)) + + assertThat( + "mozAddonManager is not exposed", + mainSession.evaluateJS("typeof navigator.mozAddonManager") as String, + equalTo("undefined"), + ) + } + + @Test + fun testMozAddonManagerCanBeEnabledByPref() { + // TODO: Bug 1837551 + assumeThat(sessionRule.env.isFission, equalTo(false)) + + mainSession.loadUri("https://example.com") + sessionRule.waitForPageStop() + + sessionRule.setPrefsUntilTestEnd( + mapOf( + "extensions.webapi.enabled" to true, + // We still need this pref to be set to allow the API on `example.com`. + "extensions.webapi.testing" to true, + ), + ) + + assertThat( + "mozAddonManager is exposed", + mainSession.evaluateJS("typeof navigator.mozAddonManager") as String, + equalTo("object"), + ) + assertThat( + "mozAddonManager.abuseReportPanelEnabled should be false", + mainSession.evaluateJS("navigator.mozAddonManager.abuseReportPanelEnabled") as Boolean, + equalTo(false), + ) + + // Install an add-on, then assert results got from `mozAddonManager.getAddonByID()`. + var addonId = "" + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> { + assertEquals(extension.metaData.name, "Borderify") + assertEquals(extension.metaData.version, "1.0") + assertEquals(extension.isBuiltIn, false) + addonId = extension.id + return GeckoResult.allow() + } + }) + + val borderify = sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/borderify.xpi", + null, + ), + ) + + var jsCode = """ + navigator.mozAddonManager.getAddonByID("$addonId").then( + addon => [addon.name, addon.version, addon.type].join(":") + ); + """ + assertThat( + "mozAddonManager.getAddonByID() resolved to the expected result", + mainSession.evaluateJS(jsCode) as String, + equalTo("Borderify:1.0:extension"), + ) + + // Uninstall the add-on before exiting the test. + sessionRule.waitForResult(controller.uninstall(borderify)) + } + + @Test + fun testMozAddonManagerSetting() { + val settings = GeckoRuntimeSettings.Builder().build() + assertThat( + "Extension web API setting should be set to false", + settings.extensionsWebAPIEnabled, + equalTo(false), + ) + + val geckoPrefs = sessionRule.getPrefs("extensions.webapi.enabled") + assertThat( + "extensionsWebAPIEnabled matches Gecko pref value", + settings.extensionsWebAPIEnabled, + equalTo(geckoPrefs[0] as Boolean), + ) + } + + @Test + fun testExtensionsProcessDisabledByDefault() { + val settings = GeckoRuntimeSettings.Builder() + .build() + + assertThat( + "extensionsProcessEnabled setting default should be null", + settings.extensionsProcessEnabled, + equalTo(null), + ) + + val geckoPrefs = sessionRule.getPrefs( + "extensions.webextensions.remote", + ) + + assertThat( + "extensions.webextensions.remote pref default value should be false", + geckoPrefs[0] as Boolean, + equalTo(false), + ) + } + + @Test + fun testExtensionsProcessControlledFromSettings() { + val settings = GeckoRuntimeSettings.Builder() + .extensionsProcessEnabled(true) + .build() + + assertThat( + "extensionsProcessEnabled setting should be set to true", + settings.extensionsProcessEnabled, + equalTo(true), + ) + } + + @Test + fun testExtensionProcessCrashThresholdsControlledFromSettings() { + var crashThreshold = 1 + var timeframe = 60000L + + val settings = GeckoRuntimeSettings.Builder() + .extensionsProcessCrashThreshold(crashThreshold) + .extensionsProcessCrashTimeframe(timeframe) + .build() + + assertThat( + "extensionProcessCrashThresholdMaxCount should be set to $crashThreshold", + settings.extensionsProcessCrashThreshold, + equalTo(crashThreshold), + ) + + assertThat( + "extensionsProcessCrashThresholdTimeframeSeconds should be set to $timeframe", + settings.extensionsProcessCrashTimeframe, + equalTo(timeframe), + ) + + // Update with setters and check that settings have updated + crashThreshold = 5 + timeframe = 120000L + settings.setExtensionsProcessCrashThreshold(crashThreshold) + settings.setExtensionsProcessCrashTimeframe(timeframe) + + assertThat( + "extensionProcessCrashThresholdMaxCount should be updated to $crashThreshold", + settings.extensionsProcessCrashThreshold, + equalTo(crashThreshold), + ) + + assertThat( + "extensionsProcessCrashThresholdTimeframeSeconds should be updated to $timeframe", + settings.extensionsProcessCrashTimeframe, + equalTo(timeframe), + ) + } + + @Test + fun testExtensionProcessCrash() { + sessionRule.setPrefsUntilTestEnd( + mapOf( + "extensions.webextensions.remote" to true, + "dom.ipc.keepProcessesAlive.extension" to 1, + "xpinstall.signatures.required" to false, + ), + ) + + sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate { + @AssertCalled(count = 1) + override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny>? { + return GeckoResult.allow() + } + }) + + sessionRule.addExternalDelegateUntilTestEnd( + WebExtensionController.ExtensionProcessDelegate::class, + { delegate -> controller.setExtensionProcessDelegate(delegate) }, + { controller.setExtensionProcessDelegate(null) }, + object : WebExtensionController.ExtensionProcessDelegate { + @AssertCalled(count = 1) + override fun onDisabledProcessSpawning() {} + }, + ) + + val borderify = sessionRule.waitForResult( + controller.install( + "resource://android/assets/web_extensions/borderify.xpi", + null, + ), + ) + + val list = extensionsMap(sessionRule.waitForResult(controller.list())) + assertTrue(list.containsKey(borderify.id)) + + mainSession.loadUri("about:crashextensions") + + sessionRule.waitForResult(controller.uninstall(borderify)) + } +} 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..5c8ebe844d --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/WebPushUtils.java @@ -0,0 +1,164 @@ +/* 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"); + + return (ECPublicKey) factory.generatePublic(spec); + } 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..0c90a27329 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/crash/ParentCrashTest.kt @@ -0,0 +1,44 @@ +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.junit.Assert.assertTrue +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() { + val client = TestCrashHandler.Client(targetContext) + + assertTrue(client.connect(timeout)) + client.setEvalNextCrashDump(GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN, null) + + 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..9c9a9d6188 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/rule/GeckoSessionTestRule.java @@ -0,0 +1,2989 @@ +/* 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.ExperimentDelegate; +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.TranslationsController; +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> + * @Setting.List(@Setting(key = Setting.Key.USE_TRACKING_PROTECTION, + * value = "false")) + * @Test public void test() { ... } + * </pre> + * + * <p>Use multiple settings: + * + * <pre> + * @Setting.List({@Setting(key = Setting.Key.USE_PRIVATE_MODE, + * value = "true"), + * @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); + DEFAULT_DELEGATES.add(TranslationsController.SessionTranslation.Delegate.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, + TranslationsController.SessionTranslation.Delegate, + // 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; + + @Nullable private Map<String, String> mServerCustomHeaders = null; + @Nullable private Map<String, TestServer.ResponseModifier> mResponseModifiers = null; + + public GeckoSessionTestRule() { + mDefaultSettings = new GeckoSessionSettings.Builder().build(); + } + + public GeckoSessionTestRule(@Nullable Map<String, String> mServerCustomHeaders) { + this(); + this.mServerCustomHeaders = mServerCustomHeaders; + } + + public GeckoSessionTestRule( + @Nullable Map<String, String> serverCustomHeaders, + @Nullable Map<String, TestServer.ResponseModifier> responseModifiers) { + this(); + this.mServerCustomHeaders = serverCustomHeaders; + this.mResponseModifiers = responseModifiers; + } + + /** + * 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); + } + + /** Sets an experiment delegate on the runtime creator. */ + public void setExperimentDelegate(final ExperimentDelegate delegate) { + RuntimeCreator.setExperimentDelegate(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 if (cls == TranslationsController.SessionTranslation.Delegate.class) { + session.setTranslationsSessionDelegate( + (TranslationsController.SessionTranslation.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); + } + if (cls == TranslationsController.SessionTranslation.Delegate.class) { + return GeckoSession.class.getMethod("getTranslationsSessionDelegate").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 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); + RuntimeCreator.setExperimentDelegate(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(), + mServerCustomHeaders, + mResponseModifiers); + + 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 event at the specified location using the main session. The session must + * have been created with a display. + * + * @param session Target session + * @param downTime A time when any buttons are down + * @param action An action such as MotionEvent.ACTION_DOWN + * @param x X coordinate + * @param y Y coordinate + * @param buttonState A button stats such as MotionEvent.BUTTON_PRIMARY + */ + public void synthesizeMouse( + final @NonNull GeckoSession session, + final long downTime, + final int action, + final int x, + final int y, + final int buttonState) { + 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 MotionEvent moveEvent = + MotionEvent.obtain( + downTime, + SystemClock.uptimeMillis(), + action, + 1, + pointerProperties, + pointerCoords, + 0, + buttonState, + 1.0f, + 1.0f, + 0, + 0, + InputDevice.SOURCE_MOUSE, + 0); + session.getPanZoomController().onTouchEvent(moveEvent); + } + + /** + * 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 long moveTime = SystemClock.uptimeMillis(); + synthesizeMouse(session, moveTime, MotionEvent.ACTION_HOVER_MOVE, x, y, 0); + } + + /** + * 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 void waitForContentTransformsReceived(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "WaitForContentTransformsReceived", null); + } + + 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) { + return (Boolean) webExtensionApiCall(session, "GetActive", null); + } + + public void triggerCookieBannerDetected(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerCookieBannerDetected", null); + } + + public void triggerCookieBannerHandled(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerCookieBannerHandled", null); + } + + public void triggerTranslationsOffer(final @NonNull GeckoSession session) { + webExtensionApiCall(session, "TriggerTranslationsOffer", null); + } + + public void triggerLanguageStateChange( + final @NonNull GeckoSession session, final @NonNull JSONObject languageState) { + webExtensionApiCall( + session, + "TriggerLanguageStateChange", + args -> { + args.put("languageState", languageState); + }); + } + + 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..a632874dfd --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/Environment.java @@ -0,0 +1,87 @@ +/* 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 = Build.SUPPORTED_ABIS[0]; + 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..7eda360459 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/RuntimeCreator.java @@ -0,0 +1,233 @@ +/* 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.json.JSONObject; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.ExperimentDelegate; +import org.mozilla.geckoview.GeckoResult; +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); + } + } + } + + /** + * The ExperimentDelegate can only be set when starting the RuntimeCreator, so for testing we are + * setting up a proxy here + */ + public static class RuntimeExperimentDelegate implements ExperimentDelegate { + public ExperimentDelegate delegate = null; + + @Override + public GeckoResult<JSONObject> onGetExperimentFeature(@NonNull String feature) { + if (delegate != null) { + return delegate.onGetExperimentFeature(feature); + } + return ExperimentDelegate.super.onGetExperimentFeature(feature); + } + + @Override + public GeckoResult<Void> onRecordExposureEvent(@NonNull String feature) { + if (delegate != null) { + return delegate.onRecordExposureEvent(feature); + } + return ExperimentDelegate.super.onRecordExposureEvent(feature); + } + + @Override + public GeckoResult<Void> onRecordExperimentExposureEvent( + @NonNull String feature, @NonNull String slug) { + if (delegate != null) { + return delegate.onRecordExperimentExposureEvent(feature, slug); + } + return ExperimentDelegate.super.onRecordExperimentExposureEvent(feature, slug); + } + + @Override + public GeckoResult<Void> onRecordMalformedConfigurationEvent( + @NonNull String feature, @NonNull String part) { + if (delegate != null) { + return delegate.onRecordMalformedConfigurationEvent(feature, part); + } + return ExperimentDelegate.super.onRecordMalformedConfigurationEvent(feature, part); + } + } + + public static final RuntimeTelemetryDelegate sRuntimeTelemetryProxy = + new RuntimeTelemetryDelegate(); + + public static RuntimeExperimentDelegate sRuntimeExperimentDelegateProxy = + new RuntimeExperimentDelegate(); + 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; + } + + /** + * Set the {@link ExperimentDelegate} 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 ExperimentDelegate} for this test to use. + */ + public static void setExperimentDelegate(final ExperimentDelegate delegate) { + sRuntimeExperimentDelegateProxy.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) + .experimentDelegate(sRuntimeExperimentDelegateProxy) + .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..28b1dfc100 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/java/org/mozilla/geckoview/test/util/TestServer.kt @@ -0,0 +1,188 @@ +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.http.server.HttpServerRequestCallback +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 @JvmOverloads constructor( + context: Context, + private val customHeaders: Map<String, String>? = null, + private val responseModifiers: Map<String, ResponseModifier>? = null, +) { + private val server = AsyncHttpServer() + private val assets: AssetManager + private val stallingResponses = Vector<AsyncHttpServerResponse>() + + init { + 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) + + val assetsCallback = HttpServerRequestCallback { 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() + + customHeaders?.forEach { (header, value) -> + response.headers.set(header, value) + } + + responseModifiers?.get(request.path)?.let { modifier -> + response.send(mimeType, modifier.transformResponse(asset.decodeToString())) + return@HttpServerRequestCallback + } + + response.send(mimeType, asset) + } catch (e: FileNotFoundException) { + response.code(404) + response.end() + } + } + + server.get("/assets/.*", assetsCallback) + server.post("/assets/.*", assetsCallback) + + 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() + } + + fun interface ResponseModifier { + abstract fun transformResponse(response: String): String + } +} 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..8b9fb00c27 --- /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 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 Binary files differnew file mode 100644 index 0000000000..c9a2788e53 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors.png 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 Binary files differnew file mode 100644 index 0000000000..da4eba73b3 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br.png 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 Binary files differnew file mode 100644 index 0000000000..c402e73bb6 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_br_scaled.png 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 Binary files differnew file mode 100644 index 0000000000..eda5c5ebf0 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl.png 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 Binary files differnew file mode 100644 index 0000000000..0ce5a631c4 --- /dev/null +++ b/mobile/android/geckoview/src/androidTest/res/drawable-nodpi/colors_tl_scaled.png 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..65458e004d --- /dev/null +++ b/mobile/android/geckoview/src/main/AndroidManifest.xml @@ -0,0 +1,90 @@ +<?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"> + + <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..f12051b9ef --- /dev/null +++ b/mobile/android/geckoview/src/main/aidl/org/mozilla/gecko/process/IChildProcess.aidl @@ -0,0 +1,47 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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); + + 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..99be57fc12 --- /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 enum Axis { + X(MotionEvent.AXIS_X), + Y(MotionEvent.AXIS_Y), + Z(MotionEvent.AXIS_Z), + RZ(MotionEvent.AXIS_RZ); + + public final int axis; + + 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 enum Trigger { + Left(6), + Right(7); + + public final int button; + + 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 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; + + DpadAxis(final int axis, final int negativeButton, final int positiveButton) { + this.axis = axis; + this.negativeButton = negativeButton; + this.positiveButton = positiveButton; + } + } + + private 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; + + 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..6ef2dd3073 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java @@ -0,0 +1,284 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.ClipboardManager.OnPrimaryClipChangedListener; +import android.content.Context; +import android.content.res.AssetFileDescriptor; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicLong; +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 static final int DEFAULT_BUFFER_SIZE = 8192; + + private static OnPrimaryClipChangedListener sClipboardChangedListener = null; + private static final AtomicLong sClipboardSequenceNumber = new AtomicLong(); + + 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 getTextData(context, PLAINTEXT_MIME); + } + + /** + * Get the text 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") + private static String getTextData(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; + } + + /** + * Get the blob data on the primary clip on clipboard + * + * @param mimeType the mime type we want. + * @return a byte array into clipboard. + */ + @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult") + private static byte[] getRawData(final String mimeType) { + final Context context = GeckoAppShell.getApplicationContext(); + 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 (description.hasMimeType(mimeType)) { + return getRawDataFromClipData(context, clip); + } + } + return null; + } + + private static byte[] getRawDataFromClipData(final Context context, final ClipData clipData) { + try (final AssetFileDescriptor descriptor = + context + .getContentResolver() + .openAssetFileDescriptor(clipData.getItemAt(0).getUri(), "r"); + final InputStream inputStream = new FileInputStream(descriptor.getFileDescriptor()); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + final byte[] data = new byte[DEFAULT_BUFFER_SIZE]; + int readed; + while ((readed = inputStream.read(data)) != -1) { + outputStream.write(data, 0, readed); + } + return outputStream.toByteArray(); + } catch (final IOException e) { + Log.e(LOGTAG, "Couldn't get clip data from clipboard due to I/O error", e); + } catch (final OutOfMemoryError e) { + Log.e(LOGTAG, "Couldn't get clip data from clipboard due to OOM", 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") + private 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") + private 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(getTextData(context, mimeType)); + } + } + + // 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 description.hasMimeType(mimeType); + } + + /** + * Deletes all data from the clipboard. + * + * @param context application context + */ + @WrapForJNI(calledFrom = "gecko") + private static void clear(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(); + } + + /** + * Start monitor clipboard sequence number. + * + * @param context application context + */ + @WrapForJNI(calledFrom = "gecko") + private static void startTrackingClipboardData(final Context context) { + if (sClipboardChangedListener != null) { + return; + } + + sClipboardChangedListener = + new OnPrimaryClipChangedListener() { + @Override + public void onPrimaryClipChanged() { + Clipboard.sClipboardSequenceNumber.incrementAndGet(); + } + }; + + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + cm.addPrimaryClipChangedListener(sClipboardChangedListener); + } + + /** Stop monitor clipboard sequence number. */ + @WrapForJNI(calledFrom = "gecko") + private static void stopTrackingClipboardData(final Context context) { + if (sClipboardChangedListener == null) { + return; + } + + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + cm.removePrimaryClipChangedListener(sClipboardChangedListener); + sClipboardChangedListener = null; + } + + /** Get clipboard sequence number. */ + @WrapForJNI(calledFrom = "gecko") + private static long getSequenceNumber(final Context context) { + return sClipboardSequenceNumber.get(); + } +} 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..568fc3a0bb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java @@ -0,0 +1,1614 @@ +/* -*- 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.InetSocketAddress; +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.CrashHandler; +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 + public String getAppPackageName() { + final Context appContext = getAppContext(); + if (appContext == null) { + return "<unknown>"; + } + return appContext.getPackageName(); + } + + @Override + public Context getAppContext() { + return getApplicationContext(); + } + + @SuppressLint("ApplySharedPref") + @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") + public 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, + android.R.attr.colorAccent, + }; + + 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"; + } + + final InetSocketAddress proxyAddress = (InetSocketAddress) proxy.address(); + final String proxyString = proxyAddress.getHostString() + ":" + proxyAddress.getPort(); + + switch (proxy.type()) { + case HTTP: + return "PROXY " + proxyString; + case SOCKS: + return "SOCKS " + proxyString; + } + + 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(); + } + + 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 { + sScreenCompat = new JellyBeanMR1ScreenCompat(); + } + } + + /* 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; + + 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; + + 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(); + locales[0] = locale.toLanguageTag(); + 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() { + // 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/GeckoDragAndDrop.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoDragAndDrop.java new file mode 100644 index 0000000000..9c1473d4e7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoDragAndDrop.java @@ -0,0 +1,253 @@ +/* -*- Mode: Java; c-basic-offset: 2; tab-width: 20; indent-tabs-mode: nil; -*- */ +/* vim: set ts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.ClipData; +import android.content.ClipDescription; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Point; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import android.view.DragEvent; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.WrapForJNI; + +@TargetApi(Build.VERSION_CODES.N) +public class GeckoDragAndDrop { + private static final String LOGTAG = "GeckoDragAndDrop"; + private static final boolean DEBUG = false; + + /** The drag/drop data is nsITransferable and stored into nsDragService. */ + private static final String MIMETYPE_NATIVE = "application/x-moz-draganddrop"; + + private static final String[] sSupportedMimeType = { + MIMETYPE_NATIVE, ClipDescription.MIMETYPE_TEXT_HTML, ClipDescription.MIMETYPE_TEXT_PLAIN + }; + + private static ClipData sDragClipData; + private static float sX; + private static float sY; + private static boolean mEndingSession; + + private static class DrawDragImage extends View.DragShadowBuilder { + private final Bitmap mBitmap; + + public DrawDragImage(final Bitmap bitmap) { + mBitmap = bitmap; + } + + @Override + public void onProvideShadowMetrics(final Point outShadowSize, final Point outShadowTouchPoint) { + if (mBitmap == null) { + super.onProvideShadowMetrics(outShadowSize, outShadowTouchPoint); + return; + } + outShadowSize.set(mBitmap.getWidth(), mBitmap.getHeight()); + } + + @Override + public void onDrawShadow(final Canvas canvas) { + if (mBitmap == null) { + super.onDrawShadow(canvas); + return; + } + canvas.drawBitmap(mBitmap, 0.0f, 0.0f, null); + } + } + + @WrapForJNI + public static class DropData { + public final String mimeType; + public final String text; + + @WrapForJNI(skip = true) + public DropData() { + this.mimeType = MIMETYPE_NATIVE; + this.text = null; + } + + @WrapForJNI(skip = true) + public DropData(final String mimeType) { + this.mimeType = mimeType; + this.text = ""; + } + + @WrapForJNI(skip = true) + public DropData(final String mimeType, final String text) { + this.mimeType = mimeType; + this.text = text; + } + } + + public static void startDragAndDrop(final View view, final Bitmap bitmap) { + view.startDragAndDrop(sDragClipData, new DrawDragImage(bitmap), null, View.DRAG_FLAG_GLOBAL); + sDragClipData = null; + } + + public static void updateDragImage(final View view, final Bitmap bitmap) { + view.updateDragShadow(new DrawDragImage(bitmap)); + } + + public static boolean onDragEvent(@NonNull final DragEvent event) { + if (DEBUG) { + final StringBuilder sb = new StringBuilder("onDragEvent: action="); + sb.append(event.getAction()) + .append(", x=") + .append(event.getX()) + .append(", y=") + .append(event.getY()); + Log.d(LOGTAG, sb.toString()); + } + + switch (event.getAction()) { + case DragEvent.ACTION_DRAG_STARTED: + mEndingSession = false; + sX = event.getX(); + sY = event.getY(); + break; + case DragEvent.ACTION_DRAG_LOCATION: + sX = event.getX(); + sY = event.getY(); + break; + case DragEvent.ACTION_DROP: + sX = event.getX(); + sY = event.getY(); + break; + case DragEvent.ACTION_DRAG_ENDED: + mEndingSession = true; + return true; + default: + break; + } + if (mEndingSession) { + return false; + } + return true; + } + + public static float getLocationX() { + return sX; + } + + public static float getLocationY() { + return sY; + } + + /** + * Create drop data by DragEvent. This ClipData will be stored into nsDragService as + * nsITransferable. If this type has MIMETYPE_NATIVE, this is already stored into nsDragService. + * So do nothing. + * + * @param event A DragEvent + * @return DropData that is from ClipData. If null, no data that we can convert to Gecko's type. + */ + public static DropData createDropData(final DragEvent event) { + final ClipDescription description = event.getClipDescription(); + + if (event.getAction() == DragEvent.ACTION_DRAG_ENTERED) { + // Android API cannot get real dragging item until drop event. So we set MIME type only. + for (final String mimeType : sSupportedMimeType) { + if (description.hasMimeType(mimeType)) { + return new DropData(mimeType); + } + } + return null; + } + + if (event.getAction() != DragEvent.ACTION_DROP) { + return null; + } + + final ClipData clip = event.getClipData(); + if (clip == null || clip.getItemCount() == 0) { + return null; + } + + if (description.hasMimeType(MIMETYPE_NATIVE)) { + if (DEBUG) { + Log.d(LOGTAG, "Drop data is native nsITransferable. Do nothing"); + } + return new DropData(); + } + if (description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) { + final CharSequence data = clip.getItemAt(0).getHtmlText(); + if (data == null) { + return null; + } + if (DEBUG) { + Log.d(LOGTAG, "Drop data is text/html"); + } + return new DropData(ClipDescription.MIMETYPE_TEXT_HTML, data.toString()); + } + + final CharSequence text = clip.getItemAt(0).coerceToText(GeckoAppShell.getApplicationContext()); + if (!TextUtils.isEmpty(text)) { + if (DEBUG) { + Log.d(LOGTAG, "Drop data is text/plain"); + } + return new DropData(ClipDescription.MIMETYPE_TEXT_PLAIN, text.toString()); + } + return null; + } + + private static void setDragClipData(final ClipData clipData) { + sDragClipData = clipData; + } + + private static @Nullable ClipData getDragClipData() { + return sDragClipData; + } + + /** + * Set drag item before calling View.startDragAndDrop. This is set from nsITransferable, so it + * marks as native data. + */ + @WrapForJNI + private static void setDragData(final CharSequence text, final String htmlText) { + if (TextUtils.isEmpty(text)) { + final ClipDescription description = + new ClipDescription("drag item", new String[] {MIMETYPE_NATIVE}); + final ClipData.Item item = new ClipData.Item(""); + final ClipData clipData = new ClipData(description, item); + setDragClipData(clipData); + return; + } + + if (TextUtils.isEmpty(htmlText)) { + final ClipDescription description = + new ClipDescription( + "drag item", new String[] {MIMETYPE_NATIVE, ClipDescription.MIMETYPE_TEXT_PLAIN}); + final ClipData.Item item = new ClipData.Item(text); + final ClipData clipData = new ClipData(description, item); + setDragClipData(clipData); + return; + } + + final ClipDescription description = + new ClipDescription( + "drag item", + new String[] { + MIMETYPE_NATIVE, + ClipDescription.MIMETYPE_TEXT_HTML, + ClipDescription.MIMETYPE_TEXT_PLAIN + }); + final ClipData.Item item = new ClipData.Item(text, htmlText); + final ClipData clipData = new ClipData(description, item); + setDragClipData(clipData); + return; + } + + @WrapForJNI + private static void endDragSession() { + mEndingSession = true; + } +} 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..78d66cc352 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java @@ -0,0 +1,73 @@ +/* -*- 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.content.Context; +import android.hardware.display.DisplayManager; +import android.util.Log; +import android.view.Display; + +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..ce7a48c4da --- /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; + + 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..8b188438a4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java @@ -0,0 +1,195 @@ +/* -*- 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.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.Log; +import android.view.InputDevice; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.InputDeviceUtils; +import org.mozilla.gecko.util.ThreadUtils; + +public class GeckoSystemStateListener implements InputManager.InputDeviceListener { + private static final String LOGTAG = "SystemStateListener"; + + private static final GeckoSystemStateListener listenerInstance = new GeckoSystemStateListener(); + + private boolean mInitialized; + private ContentObserver mContentObserver; + private static Context sApplicationContext; + private InputManager mInputManager; + private boolean mIsNightMode; + + public static GeckoSystemStateListener getInstance() { + return listenerInstance; + } + + private GeckoSystemStateListener() {} + + public synchronized void initialize(final Context context) { + if (mInitialized) { + Log.w(LOGTAG, "Already initialized!"); + return; + } + mInputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + mInputManager.registerInputDeviceListener(listenerInstance, ThreadUtils.getUiHandler()); + + sApplicationContext = context; + 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); + + final Uri textContrastSetting = + Settings.Secure.getUriFor( + /*Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED*/ "high_text_contrast_enabled"); + contentResolver.registerContentObserver(textContrastSetting, 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; + } + + @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() { + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + + return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1) + == 0.0f; + } + + @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() { + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + + return Settings.Secure.getInt( + contentResolver, Settings.Secure.ACCESSIBILITY_DISPLAY_INVERSION_ENABLED, 0) + == 1; + } + + @WrapForJNI(calledFrom = "gecko") + /** + * For prefers-contrast queries feature. + * + * <p>Uses `Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED` which was introduced in API + * version 21. + */ + private static boolean prefersContrast() { + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + + return Settings.Secure.getInt( + contentResolver, /*Settings.Secure.ACCESSIBILITY_HIGH_TEXT_CONTRAST_ENABLED*/ + "high_text_contrast_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..f88421ad03 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java @@ -0,0 +1,967 @@ +/* -*- 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; + + 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; + + private ParcelFileDescriptors(final Builder builder) { + prefs = builder.prefs; + prefMap = builder.prefMap; + ipc = builder.ipc; + crashReporter = builder.crashReporter; + } + + public FileDescriptors detach() { + return FileDescriptors.builder() + .prefs(detach(prefs)) + .prefMap(detach(prefMap)) + .ipc(detach(ipc)) + .crashReporter(detach(crashReporter)) + .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); + } + + 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)) + .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; + + 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 static final class FileDescriptors { + final int prefs; + final int prefMap; + final int ipc; + final int crashReporter; + + private FileDescriptors(final Builder builder) { + prefs = builder.prefs; + prefMap = builder.prefMap; + ipc = builder.ipc; + crashReporter = builder.crashReporter; + } + + 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; + + 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 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, + !isChildProcess && 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; + + // The default capacity value is the same with the min capacity, but users + // can still enter a different capacity. We also keep this variable to make + // sure that the entered value is not below the min capacity. + // 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 represents 128MiB in entry size. + // This is calculated as: + // 128 * 1024 * 1024 / 8 = 16777216 + final int minCapacity = 16777216; + + // ~16M entries which is 128MiB in entry size. + // Keep this in sync with `PROFILER_DEFAULT_STARTUP_ENTRIES`. + // It's computed as 16 * 1024 * 1024 there, which is the same number. + int capacity = minCapacity; + + // 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..120098a931 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java @@ -0,0 +1,104 @@ +/* -*- 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.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 (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..9bb116451e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java @@ -0,0 +1,227 @@ +/* -*- 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 = tss.getDefaultLanguage(); + 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..d533d2ad39 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java @@ -0,0 +1,151 @@ +/* -*- 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; + mIsAvailable = p.readByte() == 1; + 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(true, 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..b063fc9c2c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java @@ -0,0 +1,314 @@ +/* -*- 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 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); + } + + 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 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) { + // 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 = new GeckoSurfaceTexture(handle, singleBufferMode); + + 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..f3cca81a81 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java @@ -0,0 +1,139 @@ +/* -*- 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; + } + + 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..7732cc3bc9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java @@ -0,0 +1,111 @@ +/* -*- 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 final 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); + } + + final SurfaceControl.Transaction transaction = + new SurfaceControl.Transaction() + .setVisibility(child, true) + .setBufferSize(child, width, height); + transaction.apply(); + transaction.close(); + + 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()) { + // Explicitly reparenting the old SurfaceControl to null ensures SurfaceFlinger does not hold + // on to it. We used to not do this in order to avoid a blank screen until we resume rendering + // in to a new SurfaceControl, but on some devices this was causing glitches. + final SurfaceControl.Transaction transaction = + new SurfaceControl.Transaction().reparent(child, null); + transaction.apply(); + transaction.close(); + 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..b29d488c6c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java @@ -0,0 +1,61 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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 { + 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); + } + + void setCallbacks(Callbacks callbacks, Handler handler); + + void configure(MediaFormat format, Surface surface, MediaCrypto crypto, int flags); + + boolean isAdaptivePlaybackSupported(String mimeType); + + boolean isTunneledPlaybackSupported(final String mimeType); + + void start(); + + void stop(); + + void flush(); + + // Must be called after flush(). + void resumeReceivingInputs(); + + void release(); + + ByteBuffer getInputBuffer(int index); + + MediaFormat getInputFormat(); + + ByteBuffer getOutputBuffer(int index); + + void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags); + + void setBitrate(int bps); + + void queueSecureInputBuffer( + int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); + + 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..467d67681c --- /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 { + + enum TrackType { + UNDEFINED, + AUDIO, + VIDEO, + TEXT, + } + + enum ResourceError { + BASE(-100), + UNKNOWN(-101), + PLAYER(-102), + UNSUPPORTED(-103); + + private int mNumVal; + + ResourceError(final int numVal) { + mNumVal = numVal; + } + + public int code() { + return mNumVal; + } + } + + enum DemuxerError { + BASE(-200), + UNKNOWN(-201), + PLAYER(-202), + UNSUPPORTED(-203); + + private int mNumVal; + + DemuxerError(final int numVal) { + mNumVal = numVal; + } + + public int code() { + return mNumVal; + } + } + + interface DemuxerCallbacks { + void onInitialized(boolean hasAudio, boolean hasVideo); + + void onError(int errorCode); + } + + interface ResourceCallbacks { + void onLoad(String mediaUrl); + + void onDataArrived(); + + void onError(int errorCode); + } + + // Used to identify player instance. + int getId(); + + // ======================================================================= + // API for GeckoHLSResourceWrapper + // ======================================================================= + void init(String url, ResourceCallbacks callback); + + boolean isLiveStream(); + + // ======================================================================= + // API for GeckoHLSDemuxerWrapper + // ======================================================================= + void addDemuxerWrapperCallbackListener(DemuxerCallbacks callback); + + ConcurrentLinkedQueue<GeckoHLSSample> getSamples(TrackType trackType, int number); + + long getBufferedPosition(); + + int getNumberOfTracks(TrackType trackType); + + GeckoVideoInfo getVideoInfo(int index); + + GeckoAudioInfo getAudioInfo(int index); + + boolean seek(long positionUs); + + long getNextKeyFrameTime(); + + void suspend(); + + void resume(); + + void play(); + + void pause(); + + 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..eb07f6146c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java @@ -0,0 +1,713 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.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; + + int numCodecs = 0; + final List<String> found = new ArrayList<>(); + try { + numCodecs = MediaCodecList.getCodecCount(); + } catch (final RuntimeException e) { + Log.e(LOGTAG, "Failed retrieving codec count finding matching codec names", e); + return found; + } + + 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) { + 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; + } + } + + 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..34bba3e593 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java @@ -0,0 +1,503 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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 (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..99287974f5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java @@ -0,0 +1,199 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.Build; +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>{@link MediaFormat#KEY_COLOR_RANGE + * <li>{@link MediaFormat#KEY_COLOR_STANDARD} + * <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)); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (bundle.containsKey(MediaFormat.KEY_COLOR_RANGE)) { + mFormat.setInteger(MediaFormat.KEY_COLOR_RANGE, bundle.getInt(MediaFormat.KEY_COLOR_RANGE)); + } + if (bundle.containsKey(MediaFormat.KEY_COLOR_STANDARD)) { + mFormat.setInteger( + MediaFormat.KEY_COLOR_STANDARD, bundle.getInt(MediaFormat.KEY_COLOR_STANDARD)); + } + } + } + + @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)); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (mFormat.containsKey(MediaFormat.KEY_COLOR_RANGE)) { + bundle.putInt(MediaFormat.KEY_COLOR_RANGE, mFormat.getInteger(MediaFormat.KEY_COLOR_RANGE)); + } + if (mFormat.containsKey(MediaFormat.KEY_COLOR_STANDARD)) { + bundle.putInt( + MediaFormat.KEY_COLOR_STANDARD, mFormat.getInteger(MediaFormat.KEY_COLOR_STANDARD)); + } + } + 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..36c714ba72 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java @@ -0,0 +1,164 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.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; + + 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); + return mPlayer.getAudioInfo(index); + } + + @WrapForJNI + public GeckoVideoInfo getVideoInfo(final int index) { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "[getVideoInfo] formatIndex : " + index); + return mPlayer.getVideoInfo(index); + } + + @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..d60f7c1ccd --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java @@ -0,0 +1,167 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +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); + 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 = + ((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..4fe5064072 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java @@ -0,0 +1,1107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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; + } + } + return new GeckoVideoInfo( + fmt.width, + fmt.height, + fmt.width, + fmt.height, + fmt.rotationDegrees, + fmt.stereoMode, + getDuration(), + fmt.sampleMimeType, + null, + null); + } + + // 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); + return new GeckoAudioInfo( + fmt.sampleRate, fmt.channelCount, 16, 0, getDuration(), fmt.sampleMimeType, csd); + } + + // 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() { + return mVRenderer != null ? mVRenderer.getNextKeyFrameTime() : Long.MAX_VALUE; + } + + // 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..28f7bad5cf --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java @@ -0,0 +1,502 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.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); + 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) { + 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..75dc7b2a80 --- /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 { + 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..9d098a303f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java @@ -0,0 +1,766 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.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 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; + +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. + return "Widevine CDM v1.0"; + } +} 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..3b055f0bca --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java @@ -0,0 +1,481 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCrypto; +import android.media.MediaFormat; +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; + } + + return !(mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)); + } + + 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 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) { + 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 (((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..aaf8810bbb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java @@ -0,0 +1,248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.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; + +/* 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..1bfab37063 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java @@ -0,0 +1,297 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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) { + return new MediaDrmProxy(keySystem, nativeCallbacks); + } + + 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..7a2e74c9af --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java @@ -0,0 +1,248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.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"); + handleRemoteDeath(); + } + + private synchronized void handleRemoteDeath() { + mConnection.waitDisconnect(); + + notifyError(!(init() && recoverRemoteCodec())); + } + + 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..8f9e42fde1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java @@ -0,0 +1,248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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 { + 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..bebc580916 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java @@ -0,0 +1,432 @@ +/* -*- 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()); + } + + 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; + } + } + + final String[] abis = Build.SUPPORTED_ABIS; + for (final String abi : abis) { + if (tryLoadWithABI(lib, outDir, apkPath, abi)) { + return true; + } + } + return false; + } + + 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, + 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..7e6139ffd7 --- /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 { + void release(); + + 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..736c292ff1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java @@ -0,0 +1,924 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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 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) + .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); + 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..92ab609908 --- /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; + + GeckoProcessType(final String geckoName) { + mGeckoName = geckoName; + } + + @Override + public String toString() { + return mGeckoName; + } + + @WrapForJNI + private static 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..f0a234a2d6 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java @@ -0,0 +1,223 @@ +/* -*- 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(); + + private enum ProcessState { + NEW, + CREATED, + BOUND, + STARTED, + DESTROYED, + } + + // Keep track of the process state to ensure we don't reuse the process + private static ProcessState sState = ProcessState.NEW; + + @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 (sState != ProcessState.NEW) { + // We don't support reusing processes, and this could get us in a really weird state, + // so let's throw here. + throw new RuntimeException( + String.format("Cannot reuse process %s: %s", getClass().getSimpleName(), sState)); + } + sState = ProcessState.CREATED; + + 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 ParcelFileDescriptors pfds = + ParcelFileDescriptors.builder() + .prefs(prefsPfd) + .prefMap(prefMapPfd) + .ipc(ipcPfd) + .crashReporter(crashReporterPfd) + .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(); + } + } + }); + sState = ProcessState.STARTED; + 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"); + sState = ProcessState.DESTROYED; + System.exit(0); + } + + @Override + public IBinder onBind(final Intent intent) { + // Calling stopSelf ensures that whenever the client unbinds the process dies immediately. + stopSelf(); + sState = ProcessState.BOUND; + 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..496fa9d2be --- /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 enum PriorityLevel { + FOREGROUND(Context.BIND_IMPORTANT), + BACKGROUND(0), + IDLE(Context.BIND_WAIVE_PRIORITY); + + private final int mAndroidFlag; + + 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 < 10, as well as for content process services + * that are not defined as isolated processes. In this case, the number of possible content + * service IDs has a fixed upper bound, so we use a BitSet to manage their allocation. + */ + private static final class DefaultContentPolicy implements ContentAllocationPolicy { + private final int mMaxNumSvcs; + private final BitSet mAllocator; + private final SecureRandom mRandom; + + public DefaultContentPolicy() { + mMaxNumSvcs = getContentServiceCount(); + mAllocator = new BitSet(mMaxNumSvcs); + mRandom = new SecureRandom(); + } + + @Override + public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) { + return info.new DefaultBindDelegate(); + } + + @Override + public String allocate() { + final int[] available = new int[mMaxNumSvcs]; + int size = 0; + for (int i = 0; i < mMaxNumSvcs; i++) { + if (!mAllocator.get(i)) { + available[size] = i; + size++; + } + } + + if (size == 0) { + throw new RuntimeException("No more content services available"); + } + + final int next = available[mRandom.nextInt(size)]; + mAllocator.set(next); + return Integer.toString(next); + } + + @Override + public void release(final String stringId) { + final int id = Integer.valueOf(stringId); + if (!mAllocator.get(id)) { + throw new IllegalStateException("Releasing an unallocated id=" + id); + } + + mAllocator.clear(id); + } + + /** + * @return The number of content services defined in our manifest. + */ + private static int getContentServiceCount() { + return ServiceUtils.getServiceCount( + GeckoAppShell.getApplicationContext(), GeckoProcessType.CONTENT); + } + } + + /** + * This policy is intended for Android versions >= 10 when our content process services are + * defined in our manifest as having isolated processes. Since isolated services share a single + * service definition, there is no longer an Android-induced hard limit on the number of content + * processes that may be started. We simply use a monotonically-increasing counter to generate + * unique instance IDs in this case. + */ + private static final class IsolatedContentPolicy implements ContentAllocationPolicy { + private final Set<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..7a445de90a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java @@ -0,0 +1,130 @@ +/* -*- 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.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 { + 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..315c4a89d7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java @@ -0,0 +1,1194 @@ +/* -*- 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 a Double mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Double value + */ + public Double getDoubleObject(final String key) { + return getDoubleObject(key, null); + } + + /** + * Returns the value associated with a Double mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @return Double value + */ + public Double getDoubleObject(final String key, final Double defaultValue) { + final Object value = mMap.get(key); + if (value == null) { + return defaultValue; + } + return ((Number) value).doubleValue(); + } + + /** + * 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) { + // gradle task (testWithGeckoBinariesDebugUnitTest) won't use this since that unit test + // runs on build task. + final Object wrapped = JSONObject.wrap(value); + jsonValue = wrapped != null ? wrapped : value.toString(); + } else if (value == null) { + // This is used by UnitTest only + jsonValue = JSONObject.NULL; + } else if (value.getClass().isArray()) { + // This is used by UnitTest only + final JSONArray jsonArray = new JSONArray(); + for (int j = 0; j < Array.getLength(value); j++) { + jsonArray.put(Array.get(value, j)); + } + jsonValue = jsonArray; + } else { + // This is used by UnitTest only + 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..ccfce796bd --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java @@ -0,0 +1,389 @@ +/* -*- 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; + try { + final MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + codecList = list.getCodecInfos(); + } catch (final RuntimeException e) { + Log.e(LOGTAG, "Failed to retrieve media codec support list", e); + return new MediaCodecInfo[0]; + } + 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) { + if (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) { + for (final MediaCodecInfo info : getCodecList()) { + 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; + } + + return Build.VERSION.SDK_INT >= 29 + && ((profile == MediaCodecInfo.CodecProfileLevel.VP9Profile2HDR10Plus) + || (profile == MediaCodecInfo.CodecProfileLevel.VP9Profile3HDR10Plus)); + } +} 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..9f42d9bd85 --- /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 { + 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..d155ea951e --- /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; + 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..36fde18a02 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java @@ -0,0 +1,116 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy 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.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) { + return normalizeUriScheme( + aUri.indexOf(':') >= 0 ? Uri.parse(aUri) : new Uri.Builder().scheme(aUri).build()); + } + + 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. + 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..f3e5248466 --- /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 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 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..48ef71b6d6 --- /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> + * <form> + * <input type="text" placeholder="username"> + * <input type="password" placeholder="password"> + * <input type="submit" value="submit"> + * </form> + * </code></pre> + * + * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code> + * StorageDelegate.onLoginFetch("example.com")</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("example.com")</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.sys.mjs. + /** 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..6135c17d95 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java @@ -0,0 +1,1975 @@ +/* -*- 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") + .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 email tracker blocking is enabled in private mode. + * + * @param enabled A boolean indicating whether or not email tracker blocking should be enabled + * in private mode. + * @return The builder instance. + */ + public @NonNull Builder emailTrackerBlockingPrivateMode(final boolean enabled) { + getSettings().setEmailTrackerBlockingPrivateBrowsing(enabled); + 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; + } + + /** + * When set to true, enable the use of global CookieBannerRules. + * + * @param enabled A boolean indicating whether to enable the use of global CookieBannerRules. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerGlobalRulesEnabled(final boolean enabled) { + getSettings().setCookieBannerGlobalRulesEnabled(enabled); + return this; + } + + /** + * When set to true, enable the use of global CookieBannerRules in sub-frames. + * + * @param enabled A boolean indicating whether to enable the use of global CookieBannerRules + * in sub-frames. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerGlobalRulesSubFramesEnabled(final boolean enabled) { + getSettings().setCookieBannerGlobalRulesSubFramesEnabled(enabled); + return this; + } + + /** + * When set to true, query parameter stripping is enabled in normal mode. + * + * @param enabled A boolean indicating whether to query parameter stripping enabled in normal + * mode. + * @return The Builder instance. + */ + public @NonNull Builder queryParameterStrippingEnabled(final boolean enabled) { + getSettings().setQueryParameterStrippingEnabled(enabled); + return this; + } + + /** + * When set to true, query parameter stripping is enabled in private mode. + * + * @param enabled A boolean indicating whether to query parameter stripping enabled in private + * mode. + * @return The Builder instance. + */ + public @NonNull Builder queryParameterStrippingPrivateBrowsingEnabled(final boolean enabled) { + getSettings().setQueryParameterStrippingPrivateBrowsingEnabled(enabled); + return this; + } + + /** + * The allowed list for the query parameter stripping feature. + * + * @param list an array of identifiers for query parameter's stripping feature. + * @return The Builder instance. + */ + public @NonNull Builder queryParameterStrippingAllowList(final @NonNull String... list) { + getSettings().setQueryParameterStrippingAllowList(list); + return this; + } + + /** + * The strip list for the query parameter stripping feature. + * + * @param list an array of identifiers for the strip list of the query parameter's stripping + * feature. + * @return The Builder instance. + */ + public @NonNull Builder queryParameterStrippingStripList(final @NonNull String... list) { + getSettings().setQueryParameterStrippingStripList(list); + 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.catToPref(AntiTracking.NONE, AntiTracking.STP, STP)); + + /* 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<Boolean> mCbhGlobalRulesEnabled = + new Pref<Boolean>("cookiebanners.service.enableGlobalRules", false); + + final Pref<Boolean> mCbhGlobalRulesSubFramesEnabled = + new Pref<Boolean>("cookiebanners.service.enableGlobalRules.subFrames", false); + + /* package */ final Pref<Boolean> mQueryParameterStrippingEnabled = + new Pref<Boolean>("privacy.query_stripping.enabled", false); + + /* package */ final Pref<Boolean> mQueryParameterStrippingPrivateBrowsingEnabled = + new Pref<Boolean>("privacy.query_stripping.enabled.pbmode", false); + + /* package */ final Pref<String> mQueryParameterStrippingAllowList = + new Pref<>("privacy.query_stripping.allow_list", ""); + + /* package */ final Pref<String> mQueryParameterStrippingStripList = + new Pref<>("privacy.query_stripping.strip_list", ""); + + /* package */ final Pref<Boolean> mEtb = + new Pref<Boolean>("privacy.trackingprotection.emailtracking.enabled", false); + + /* package */ final Pref<Boolean> mEtbPrivateBrowsing = + new Pref<Boolean>("privacy.trackingprotection.emailtracking.pbmode.enabled", false); + + /* package */ final Pref<String> mEtbList = + new Pref<String>( + "urlclassifier.features.emailtracking.blocklistTables", + ContentBlocking.catToPref(AntiTracking.NONE, AntiTracking.EMAIL, EMAIL)); + + /* 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 allowed list for the query parameter stripping feature. + * + * @param list an array of identifiers for the allowed list of the query parameter's stripping + * feature. + * @return this {@link Settings} instance. + */ + public @NonNull Settings setQueryParameterStrippingAllowList(final @NonNull String... list) { + mQueryParameterStrippingAllowList.commit(ContentBlocking.listsToPref(list)); + return this; + } + + /** + * Get the allowed list for the query parameter stripping feature. + * + * @return an array of identifiers for the allowed list for the query parameter stripping + * feature. + */ + public @NonNull String[] getQueryParameterStrippingAllowList() { + return ContentBlocking.prefToLists(mQueryParameterStrippingAllowList.get()); + } + + /** + * Sets the strip list for the query parameter stripping feature. + * + * @param list an array of identifiers for the strip list of the query parameter's stripping + * feature. + * @return this {@link Settings} instance. + */ + public @NonNull Settings setQueryParameterStrippingStripList(final @NonNull String... list) { + mQueryParameterStrippingStripList.commit(ContentBlocking.listsToPref(list)); + return this; + } + + /** + * Get the strip list for the query parameter stripping feature + * + * @return an array of identifiers for the allowed list for the query parameter stripping + * feature. + */ + public @NonNull String[] getQueryParameterStrippingStripList() { + return ContentBlocking.prefToLists(mQueryParameterStrippingStripList.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.catToPref(cat, AntiTracking.STP, STP)); + + mEtb.commit(ContentBlocking.catToEtbPref(cat)); + mEtbList.commit(ContentBlocking.catToPref(cat, AntiTracking.EMAIL, EMAIL)); + 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()) + | ContentBlocking.etbListToAtCat(mEtbList.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; + } + + /** + * Enables/disables the use of global CookieBannerRules, which apply to all sites. This enable + * handling of CMPs across sites without the use of site-specific rules. + * + * @param enabled A boolean indicating whether or not to enable. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerGlobalRulesEnabled(final boolean enabled) { + mCbhGlobalRulesEnabled.commit(enabled); + return this; + } + + /** + * Indicates if global CookieBannerRules is enabled or not. + * + * @return Indicates if global CookieBannerRule is enabled or disabled. + */ + public boolean getCookieBannerGlobalRulesEnabled() { + return mCbhGlobalRulesEnabled.get(); + } + + /** + * Whether global rules are allowed to run in sub-frames. Running query selectors in every + * sub-frame may negatively impact performance, but is required for some CMPs. + * + * @param enabled A boolean indicating whether or not to enable. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerGlobalRulesSubFramesEnabled(final boolean enabled) { + mCbhGlobalRulesSubFramesEnabled.commit(enabled); + return this; + } + + /** + * Indicates if email tracker blocking is enabled in private mode. + * + * @return Indicates if email tracker blocking is enabled or disabled in private mode. + */ + public @NonNull Boolean getEmailTrackerBlockingPrivateBrowsingEnabled() { + return mEtbPrivateBrowsing.get(); + } + + /** + * Sets whether email tracker blocking is enabled in private mode. + * + * @param enabled A boolean indicating whether or not to enable. + * @return This Settings instance. + */ + public @NonNull Settings setEmailTrackerBlockingPrivateBrowsing(final boolean enabled) { + mEtbPrivateBrowsing.commit(enabled); + return this; + } + + /** + * Sets whether query parameter stripping is enabled in normal mode. + * + * @param enabled A boolean indicating whether or not to enable. + * @return This Settings instance. + */ + public @NonNull Settings setQueryParameterStrippingEnabled(final boolean enabled) { + mQueryParameterStrippingEnabled.commit(enabled); + return this; + } + + /** + * Indicates if query parameter stripping is enabled in normal mode. + * + * @return Indicates if query parameter stripping is enabled or disabled in normal mode. + */ + public boolean getQueryParameterStrippingEnabled() { + return mQueryParameterStrippingEnabled.get(); + } + + /** + * Sets Whether query parameter stripping is enabled in private mode. + * + * @param enabled A boolean indicating whether or not to enable in private mode. + * @return This Settings instance. + */ + public @NonNull Settings setQueryParameterStrippingPrivateBrowsingEnabled( + final boolean enabled) { + mQueryParameterStrippingPrivateBrowsingEnabled.commit(enabled); + return this; + } + + /** + * Indicates if query parameter stripping is enabled in private mode. + * + * @return Indicates if global CookieBannerRules is enabled or disabled in sub-frames. + */ + public boolean getQueryParameterStrippingPrivateBrowsingEnabled() { + return mQueryParameterStrippingPrivateBrowsingEnabled.get(); + } + + /** + * Indicates if global CookieBannerRules is enabled or not in sub-frames. + * + * @return Indicates if global CookieBannerRules is enabled or disabled in sub-frames. + */ + public boolean getCookieBannerGlobalRulesSubFramesEnabled() { + return mCbhGlobalRulesSubFramesEnabled.get(); + } + + /** + * 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 email trackers */ + public static final int EMAIL = 1 << 9; + + /** 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 | EMAIL; + + 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.EMAIL, + 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.etbListToAtCat(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"; + private static final String EMAIL = "base-email-track-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 boolean catToEtbPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.EMAIL) != 0; + } + + /** + * Generic method for converting a category of anti-tracking to a Pref. + * + * @param cat Int representing the enabled anti-tracking blockers. + * @param tbCat Int representing the category mask to check for. + * @param catPrefString String to return if [cat] contains [tbCat]. + * @return Pref string if [cat] contains [tbCat] otherwise empty string. + */ + /* package */ static String catToPref( + @CBAntiTracking final int cat, final int tbCat, final String catPrefString) { + if ((cat & tbCat) != 0) { + return catPrefString; + } else { + return ""; + } + } + + /* 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 @CBAntiTracking int etbListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(EMAIL) != -1) { + cat |= AntiTracking.EMAIL; + } + 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..151f289e5d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java @@ -0,0 +1,214 @@ +/* -*- 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; + + /** Email content has been blocked from loading. */ + public static final int BLOCKED_EMAILTRACKING_CONTENT = 0x00400000; + + /** EmailTracking content from the Disconnect level 1 has been loaded. */ + public static final int LOADED_EMAILTRACKING_LEVEL_1_CONTENT = 0x00800000; + + /** EmailTracking content from the Disconnect level 2 has been loaded. */ + public static final int LOADED_EMAILTRACKING_LEVEL_2_CONTENT = 0x00000100; + + /** + * 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, + Event.LOADED_EMAILTRACKING_LEVEL_1_CONTENT, Event.LOADED_EMAILTRACKING_LEVEL_2_CONTENT, + Event.BLOCKED_EMAILTRACKING_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/ContentInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentInputStream.java new file mode 100644 index 0000000000..aa3f5c3174 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentInputStream.java @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; +import android.content.res.AssetFileDescriptor; +import android.net.Uri; +import android.os.Process; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import java.io.IOException; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * This class provides an {@link OutputStream} wrapper for a Gecko nsIOutputStream (or really, + * nsIRequest). + */ +/* package */ class ContentInputStream extends GeckoViewInputStream { + private static final String LOGTAG = "ContentInputStream"; + + private static final byte[][] HEADERS = {{'%', 'P', 'D', 'F', '-'}}; + + private AssetFileDescriptor mFd; + + ContentInputStream(final @NonNull String aUri) { + final Uri uri = Uri.parse(aUri); + final Context context = GeckoAppShell.getApplicationContext(); + final ContentResolver cr = context.getContentResolver(); + + try { + mFd = cr.openAssetFileDescriptor(uri, "r"); + setInputStream(mFd.createInputStream()); + + if (!checkHeaders(HEADERS)) { + Log.e(LOGTAG, "Cannot open the uri: " + aUri + " (invalid header)"); + close(); + } + } catch (final IOException | SecurityException e) { + Log.e(LOGTAG, "Cannot open the uri: " + aUri, e); + close(); + } + } + + @Override + public void close() { + if (mFd != null) { + try { + mFd.close(); + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot close the file descriptor", e); + } finally { + mFd = null; + } + } + super.close(); + } + + private static boolean isExported(final @NonNull Context aCtx, final @NonNull Uri aUri) { + // For reference: + // https://developer.android.com/topic/security/risks/content-resolver#mitigations_2 + final String authority = aUri.getAuthority(); + final PackageManager packageManager = aCtx.getPackageManager(); + if (authority == null || packageManager == null) { + return false; + } + final ProviderInfo info = packageManager.resolveContentProvider(authority, 0); + if (info == null) { + return false; + } + + // We check that the provider is exported: + // https://developer.android.com/reference/android/content/pm/ComponentInfo?hl=en#exported + return info.exported; + } + + private static boolean wasGrantedPermission( + final @NonNull Context aCtx, final @NonNull Uri aUri) { + // For reference: + // https://developer.android.com/topic/security/risks/content-resolver#mitigations_2 + final int pid = Process.myPid(); + final int uid = Process.myUid(); + return aCtx.checkUriPermission(aUri, pid, uid, Intent.FLAG_GRANT_READ_URI_PERMISSION) + == PackageManager.PERMISSION_GRANTED; + } + + private static boolean belongsToCurrentApplication( + final @NonNull Context aCtx, final @NonNull Uri aUri) { + // For reference: + // https://developer.android.com/topic/security/risks/content-resolver#mitigations_2 + final String authority = aUri.getAuthority(); + final PackageManager packageManager = aCtx.getPackageManager(); + if (authority == null || packageManager == null) { + return false; + } + final ProviderInfo info = packageManager.resolveContentProvider(authority, 0); + if (info == null) { + return false; + } + + // We check that the provider is GV itself (when testing GV, the provider is GV itself). + final String packageName = aCtx.getPackageName(); + return packageName != null && packageName.equals(info.packageName); + } + + @WrapForJNI + @AnyThread + private static boolean isReadable(final @NonNull String aUri) { + final Uri uri = Uri.parse(aUri); + final Context context = GeckoAppShell.getApplicationContext(); + + try { + // The check for this criteria is based on recommendations in + // https://developer.android.com/privacy-and-security/risks/content-resolver#mitigations_2 + // The documentation recommends checking: + // 1. If URI targets our app (belongsToCurrentApplication) + // 2. OR if targeted provider is exported (isExported) + // 3. OR if granted explicit permission (wasGrantedPermission) + if (belongsToCurrentApplication(context, uri) + || isExported(context, uri) + || wasGrantedPermission(context, uri)) { + final ContentResolver cr = context.getContentResolver(); + cr.openAssetFileDescriptor(uri, "r").close(); + Log.d(LOGTAG, "The uri is readable: " + uri); + return true; + } + } catch (final IOException | SecurityException e) { + // A SecurityException could happen if the uri is no more valid or if + // we're in an isolated process. + Log.e(LOGTAG, "Cannot read the uri: " + uri, e); + } + + Log.d(LOGTAG, "The uri isn't readable: " + uri); + return false; + } + + @WrapForJNI + @AnyThread + private static @NonNull GeckoViewInputStream getInstance(final @NonNull String aUri) { + return new ContentInputStream(aUri); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java new file mode 100644 index 0000000000..eb00f87b41 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashHandler.java @@ -0,0 +1,587 @@ +/* -*- 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.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 androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +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.gecko.GeckoAppShell; + +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 + private @Nullable final Context mAppContext; + // Thread that this handler applies to, or null for a global handler + private @Nullable final Thread mHandlerThread; + private final @Nullable Thread.UncaughtExceptionHandler systemUncaughtHandler; + + private boolean mCrashing; + private boolean mUnregistered; + + private @Nullable final Class<? extends Service> mHandlerService; + + /** + * Get the root exception from the 'cause' chain of an exception. + * + * @param exc An exception + * @return The root exception + */ + @AnyThread + @NonNull + public static Throwable getRootException(@NonNull 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. + */ + @AnyThread + @NonNull + public static String getExceptionStackTrace(@NonNull 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. */ + @AnyThread + public static void terminateProcess() { + Process.killProcess(Process.myPid()); + } + + /** + * Create and register a CrashHandler for all threads and thread groups. + * + * @param handlerService Service receiving native code crashes + */ + public CrashHandler(@Nullable final Class<? extends Service> handlerService) { + this((Context) null, handlerService); + } + + /** + * Create and register a CrashHandler for all threads and thread groups. + * + * @param aAppContext A Context for retrieving application information. + * @param aHandlerService Service receiving native code crashes + */ + public CrashHandler( + @Nullable final Context aAppContext, + @Nullable final Class<? extends Service> aHandlerService) { + this.mAppContext = aAppContext; + this.mHandlerThread = null; + this.mHandlerService = aHandlerService; + this.systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(this); + } + + /** + * Create and register a CrashHandler for a particular thread. + * + * @param thread A thread to register the CrashHandler + * @param handlerService Service receiving native code crashes + */ + 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 aAppContext A Context for retrieving application information. + * @param aHandlerService Service receiving native code crashes + */ + public CrashHandler( + @Nullable final Thread thread, + final Context aAppContext, + final Class<? extends Service> aHandlerService) { + this.mAppContext = aAppContext; + this.mHandlerThread = thread; + this.mHandlerService = aHandlerService; + this.systemUncaughtHandler = thread.getUncaughtExceptionHandler(); + thread.setUncaughtExceptionHandler(this); + } + + /** Unregister this CrashHandler for exception handling. */ + @AnyThread + public void unregister() { + mUnregistered = 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 (mHandlerThread != null) { + if (mHandlerThread.getUncaughtExceptionHandler() == this) { + mHandlerThread.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 + */ + @AnyThread + public static void logException(@NonNull final Thread thread, @NonNull 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(); + } + + @Nullable + 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; + } + + /** + * @return the application package name. if context is not null; if context is null, + * CrashHandler's package name will be returned. + */ + @Nullable + @AnyThread + public 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(); + } + + /** + * @return application context. + */ + @AnyThread + @Nullable + public Context getAppContext() { + return mAppContext; + } + + /** + * Get the crash "extras" to be reported. + * + * @param thread The exception thread + * @param exc An exception + * @return "Extras" in the from of a Bundle + */ + @AnyThread + @NonNull + public Bundle getCrashExtras(@NonNull final Thread thread, @NonNull 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 + */ + @NonNull + @AnyThread + public byte[] getCrashDump(@Nullable final Thread thread, @Nullable final Throwable exc) { + return new byte[0]; // No minidump. + } + + @AnyThread + @NonNull + private static String normalizeUrlString(@Nullable 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 + * @return the URL that the crash reporter will submit reports to. + */ + @NonNull + @AnyThread + public String getServerUrl(@NonNull 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 + */ + @AnyThread + public boolean launchCrashReporter( + @NonNull final String dumpFile, @NonNull final String extraFile) { + try { + final Context context = getAppContext(); + final ProcessBuilder pb; + + if (mHandlerService == 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, mHandlerService); + + 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() + '/' + mHandlerService.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() + '/' + mHandlerService.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 + */ + @AnyThread + @SuppressLint("SdCardPath") + public boolean reportException(@NonNull final Thread thread, @NonNull 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(@Nullable final Thread thread, @NonNull final Throwable exc) { + if (this.mCrashing) { + // 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.mUnregistered) { + // Only process crash ourselves if we have not been unregistered. + + this.mCrashing = 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(); + } + } + + /** + * Return a default CrashHandler object for all threads and thread groups. + * + * @param context application context + * @return a default CrashHandler object + */ + @AnyThread + @NonNull + public static CrashHandler createDefaultCrashHandler(@NonNull final Context context) { + return new CrashHandler(context, null) { + @Override + public 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/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/ExperimentDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ExperimentDelegate.java new file mode 100644 index 0000000000..0eb7ee0252 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ExperimentDelegate.java @@ -0,0 +1,168 @@ +/* -*- 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.ExperimentDelegate.ExperimentException.ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.json.JSONObject; + +/** + * This delegate is used to pass experiment information between the embedding application and + * GeckoView. + * + * <p>An experiment is used to give users different application behavior in order to learn and + * improve upon what features users prefer the most. This is accomplished by providing users + * different application experiences and collecting data about how the differing experiences + * impacted user behavior. + */ +public interface ExperimentDelegate { + /** + * Used to retrieve experiment information for the given feature identification. + * + * <p>A @param feature is the item or experience the experimented is about. For example, "prompt" + * or "print" could be a feature. + * + * <p>The @return experiment information will be information on what the application should do for + * the experiment. This is highly context dependent on how the experiment was setup and is decided + * and controlled by the experiment framework. For example, a feature of "prompt" may return + * {dismiss-button: {color: "red", full-screen: true}} or "print" may return {dotprint-enabled: + * true}. That information can then be used to present differing behavior for the user. + * + * @param feature The name or identification of the experiment feature. + * @return A {@link GeckoResult<JSONObject>} with experiment criteria. Typically will have a value + * related to showing or adjusting a feature. Will complete exceptionally with {@link + * ExperimentException} if the feature wasn't found. + */ + @AnyThread + default @NonNull GeckoResult<JSONObject> onGetExperimentFeature(@NonNull String feature) { + final GeckoResult<JSONObject> result = new GeckoResult<>(); + result.completeExceptionally( + new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED)); + return result; + } + + /** + * Used to let the experiment framework know that the user was shown the feature. Should be + * recorded as close as possible to the differing behavior. + * + * <p>One important part of experimentation is knowing when users encountered an experiment + * surface or difference in behavior. Sending an exposure event is recording with the experiment + * framework that the user encountered a differing behavior. + * + * <p>For example, if a user never encountered a @param feature "prompt", then the exposure event + * would never be recorded. However, if the user does encounter a "prompt", then the experiment + * framework needs a record that the user encountered the experiment surface. + * + * @param feature The name or identification the experiment feature. + * @return A {@link GeckoResult<Void>} will complete if the feature was found and exposure + * recorded. Will complete exceptionally with {@link ExperimentException} if the feature + * wasn't found. + */ + @AnyThread + default @NonNull GeckoResult<Void> onRecordExposureEvent(@NonNull String feature) { + final GeckoResult<Void> result = new GeckoResult<>(); + result.completeExceptionally( + new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED)); + return result; + } + + /** + * Used to let the experiment framework know that the user was shown the feature in a given + * experiment. Should be recorded as close as possible to the differing behavior. + * + * <p>Use [onRecordExposureEvent], if there is no experiment slug. + * + * <p>This API is used similarly to [onRecordExposureEvent], but when a specific feature was + * encountered. For example a @param feature may be "prompt" and a given @param slug may be + * "dismiss" or "confirm". This is used to indicate a specific experiment surface was encountered. + * + * @param feature The name or identification the experiment feature. + * @param slug The name or identification of the specific experiment feature. + * @return A {@link GeckoResult<Void>} will complete if the feature was found and exposure + * recorded. Will complete exceptionally with {@link ExperimentException} if the feature + * wasn't found or not recorded. + */ + @AnyThread + default @NonNull GeckoResult<Void> onRecordExperimentExposureEvent( + @NonNull String feature, @NonNull String slug) { + final GeckoResult<Void> result = new GeckoResult<>(); + result.completeExceptionally( + new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED)); + return result; + } + + /** + * Used to let the experiment framework send a malformed configuration event when the feature + * configuration is not semantically valid. + * + * @param feature The name or identification the experiment feature. + * @param part An optional detail or part identifier to be attached to the event. + * @return A {@link GeckoResult<Void>} will complete if the feature was found and the event + * recorded. Will complete exceptionally with {@link ExperimentException} if the feature + * wasn't found or not recorded. + */ + @AnyThread + default @NonNull GeckoResult<Void> onRecordMalformedConfigurationEvent( + @NonNull String feature, @NonNull String part) { + final GeckoResult<Void> result = new GeckoResult<>(); + result.completeExceptionally( + new ExperimentException(ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED)); + return result; + } + + /** + * An exception to be used when there is an issue retrieving or sending information to the + * experiment framework. + */ + class ExperimentException extends Exception { + + /** + * Construct an [ExperimentException] + * + * @param code error code the given exception corresponds to + */ + public ExperimentException(final @Codes int code) { + this.code = code; + } + + /** Default error for unexpected issues. */ + public static final int ERROR_UNKNOWN = -1; + + /** The experiment feature was not available. */ + public static final int ERROR_FEATURE_NOT_FOUND = -2; + + /** The experiment slug was not available. */ + public static final int ERROR_EXPERIMENT_SLUG_NOT_FOUND = -3; + + /** The experiment delegate is not implemented. */ + public static final int ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED = -4; + + /** Experiment exception error codes. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + ERROR_UNKNOWN, + ERROR_FEATURE_NOT_FOUND, + ERROR_EXPERIMENT_SLUG_NOT_FOUND, + ERROR_EXPERIMENT_DELEGATE_NOT_IMPLEMENTED + }) + public @interface Codes {} + + /** One of {@link Codes} that provides more information about this exception. */ + public final @Codes int code; + + @Override + public String toString() { + return "ExperimentException: " + code; + } + } +} 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..d365f303c2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java @@ -0,0 +1,2613 @@ +/* -*- 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 + return keyCode == KeyEvent.KEYCODE_DEL || keyCode == KeyEvent.KEYCODE_FORWARD_DEL; + } + + 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..5426adb501 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java @@ -0,0 +1,819 @@ +/* -*- 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); + } + }); + } + + @Override // SessionTextInput.EditableListener + public void updateCompositionRects(final RectF[] rects, final RectF caretRect) { + 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); + } + }); + } + + /* 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. + 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<Integer> divide(final int dividend, final int divisor) { + * final GeckoResult<Integer> result = new GeckoResult<>(); + * (new Thread(() -> { + * if (divisor != 0) { + * result.complete(dividend / divisor); + * } else { + * result.completeExceptionally(new ArithmeticException("Dividing by zero")); + * } + * })).start(); + * return result; + * }</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<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) { + * // value == 21 + * } + * }, new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // Not called + * } + * });</pre> + * + * <p>And to retrieve a completed exception, + * + * <pre> + * divide(42, 0).then(new GeckoResult.OnValueListener<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) { + * // Not called + * } + * }, new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // exception instanceof ArithmeticException + * } + * });</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<Integer, String>() { + * @Override + * public GeckoResult<String> onValue(final Integer value) { + * return GeckoResult.fromValue(value.toString()); + * } + * }).then(new GeckoResult.OnValueListener<String, String>() { + * @Override + * public GeckoResult<String> onValue(final String value) { + * return GeckoResult.fromValue("42 / 2 = " + value); + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> onValue(final String value) { + * // value == "42 / 2 = 21" + * return null; + * } + * });</pre> + * + * <p>Chaining works with exception listeners as well. For example, + * + * <pre> + * divide(42, 0).then(new GeckoResult.OnExceptionListener<String>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * return "foo"; + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> onValue(final String value) { + * // value == "foo" + * } + * });</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<Integer, String>() { + * @Override + * public GeckoResult<String> onValue(final Integer value) { + * // Not called + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // exception instanceof ArithmeticException + * } + * });</pre> + * + * <p>However, any propagated value will be coerced to null. For example, + * + * <pre> + * divide(42, 2).then(new GeckoResult.OnExceptionListener<String>() { + * @Override + * public GeckoResult<String> onException(final Throwable exception) { + * // Not called + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> onValue(final String value) { + * // value == null + * } + * });</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<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) throws FooException { + * throw new FooException(); + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) throws Exception { + * // exception instanceof FooException + * throw new BarException(); + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) throws Throwable { + * // exception instanceof BarException + * return new BazException(); + * } + * });</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 < 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..e1e82a492d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java @@ -0,0 +1,1057 @@ +/* -*- 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"; + + /** + * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String + * containing the content process type, which might not be available even for child processes. + * + * @see GeckoSession.ContentDelegate#onCrash(GeckoSession) + */ + public static final String EXTRA_CRASH_REMOTE_TYPE = "remoteType"; + + /** + * 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(); + } else { + // Notify Gecko when the application has been moved in the foreground for the first time + // after being created and started (used by the ExtensionProcessCrashObserver on the Gecko + // side to adjust the appIsForeground property when the application-foreground or + // application-background topics are not notified). + EventDispatcher.getInstance().dispatch("GeckoView:InitialForeground", null); + } + 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(); + mScreenChangeListener = new GeckoScreenChangeListener(); + + 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)); + i.putExtra(EXTRA_CRASH_REMOTE_TYPE, message.getString(EXTRA_CRASH_REMOTE_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 + 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 = + Settings.Global.getString(context.getContentResolver(), Settings.Global.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..3da044e603 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java @@ -0,0 +1,1729 @@ +/* -*- 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} > 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 Extensions Process support should be enabled. + * + * @param flag A flag determining whether Extensions Process support should be enabled. Default + * is false. + * @return This Builder instance. + */ + public @NonNull Builder extensionsProcessEnabled(final boolean flag) { + getSettings().mExtensionsProcess.set(flag); + return this; + } + + /** + * Set the crash threshold within the timeframe before spawning is disabled for the remote + * extensions process. + * + * @param crashThreshold The crash threshold within the timeframe before spawning is disabled. + * @return This Builder instance. + */ + public @NonNull Builder extensionsProcessCrashThreshold(final @NonNull Integer crashThreshold) { + getSettings().mExtensionsProcessCrashThreshold.set(crashThreshold); + return this; + } + + /** + * Set the crash threshold timeframe before spawning is disabled for the remote extensions + * process. Crashes that are older than the current time minus timeframeMs will not be counted + * towards meeting the threshold. + * + * @param timeframeMs The timeframe for the crash threshold in milliseconds. Any crashes older + * than the current time minus the timeframeMs are not counted. + * @return This Builder instance. + */ + public @NonNull Builder extensionsProcessCrashTimeframe(final @NonNull Long timeframeMs) { + getSettings().mExtensionsProcessCrashTimeframe.set(timeframeMs); + 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 Global Privacy Control should be enabled. GPC is a mechanism for people to tell + * websites to respect their privacy rights. Once turned on, it sends a signal to the websites + * users visit telling them that the user doesn't want to be tracked and doesn't want their data + * to be sold. + * + * @param enabled A flag determining whether Global Privacy Control should be enabled. + * @return The builder instance. + */ + public @NonNull Builder globalPrivacyControlEnabled(final boolean enabled) { + getSettings().setGlobalPrivacyControl(enabled); + 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 <meta> + * viewport tag and have been loaded in a session using {@link + * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic + * will attempt to increase font sizes for the main text content of the page only. + * + * <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; + } + + /** + * Set whether a candidate page should automatically offer a translation via a popup. + * + * @param enabled A flag determining whether the translations offer popup should be enabled. + * @return The builder instance. + */ + public @NonNull Builder translationsOfferPopup(final boolean enabled) { + getSettings().setTranslationsOfferPopup(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; + } + + /** + * Set the {@link ExperimentDelegate} instance on this runtime, if any. This delegate is used to + * send and receive experiment information from Nimbus. + * + * @param delegate The {@link ExperimentDelegate} sending and retrieving experiment information. + * @return The builder instance. + */ + @AnyThread + public @NonNull Builder experimentDelegate(final @Nullable ExperimentDelegate delegate) { + getSettings().mExperimentDelegate = delegate; + 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; + } + + /** + * Sets whether the Add-on Manager web API (`mozAddonManager`) is enabled. + * + * @param flag True if the web API should be enabled, false otherwise. + * @return This Builder instance. + */ + public @NonNull Builder extensionsWebAPIEnabled(final boolean flag) { + getSettings().mExtensionsWebAPIEnabled.set(flag); + return this; + } + + /** + * Sets whether and how DNS-over-HTTPS (Trusted Recursive Resolver) is configured. + * + * @param mode One of the {@link GeckoRuntimeSettings#TRR_MODE_OFF TrustedRecursiveResolverMode} + * constants. + * @return This Builder instance. + */ + public @NonNull Builder trustedRecursiveResolverMode( + final @TrustedRecursiveResolverMode int mode) { + getSettings().setTrustedRecursiveResolverMode(mode); + return this; + } + + /** + * Set the DNS-over-HTTPS server URI. + * + * @param uri URI of the DNS-over-HTTPS server. + * @return This Builder instance. + */ + public @NonNull Builder trustedRecursiveResolverUri(final @NonNull String uri) { + getSettings().setTrustedRecursiveResolverUri(uri); + return this; + } + + /** + * Set the factor by which to increase the keepalive timeout when the NS_HTTP_LARGE_KEEPALIVE + * flag is used for a connection. + * + * @param factor FACTOR by which to increase the keepalive timeout. + * @return This Builder instance. + */ + public @NonNull Builder largeKeepaliveFactor(final int factor) { + getSettings().setLargeKeepaliveFactor(factor); + 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", BuildConfig.DEBUG_BUILD ? "Debug" : "Warn"); + /* 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> mAutomaticallyOfferPopup = + new Pref<Boolean>("browser.translations.automaticallyPopup", 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 PrefWithoutDefault<Integer> mTrustedRecursiveResolverMode = + new PrefWithoutDefault<>("network.trr.mode"); + /* package */ final PrefWithoutDefault<String> mTrustedRecursiveResolverUri = + new PrefWithoutDefault<>("network.trr.uri"); + /* package */ final PrefWithoutDefault<Integer> mLargeKeepalivefactor = + new PrefWithoutDefault<>("network.http.largeKeepaliveFactor"); + /* package */ final Pref<Integer> mProcessCount = new Pref<>("dom.ipc.processCount", 2); + /* package */ final Pref<Boolean> mExtensionsWebAPIEnabled = + new Pref<>("extensions.webapi.enabled", false); + /* package */ final PrefWithoutDefault<Boolean> mExtensionsProcess = + new PrefWithoutDefault<Boolean>("extensions.webextensions.remote"); + /* package */ final PrefWithoutDefault<Long> mExtensionsProcessCrashTimeframe = + new PrefWithoutDefault<Long>("extensions.webextensions.crash.timeframe"); + /* package */ final PrefWithoutDefault<Integer> mExtensionsProcessCrashThreshold = + new PrefWithoutDefault<Integer>("extensions.webextensions.crash.threshold"); + /* package */ final Pref<Boolean> mGlobalPrivacyControlEnabled = + new Pref<Boolean>("privacy.globalprivacycontrol.enabled", false); + /* package */ final Pref<Boolean> mGlobalPrivacyControlEnabledPrivateMode = + new Pref<Boolean>("privacy.globalprivacycontrol.pbmode.enabled", true); + /* package */ final Pref<Boolean> mGlobalPrivacyControlFunctionalityEnabled = + new Pref<Boolean>("privacy.globalprivacycontrol.functionality.enabled", true); + + /* 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; + /* package */ ExperimentDelegate mExperimentDelegate; + + /** + * 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; + mExperimentDelegate = settings.mExperimentDelegate; + } + + /* 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} > 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; + } + + /** + * Enable the Global Privacy Control Feature. + * + * <p>Note: Global Privacy Control is always enabled in private mode. + * + * @param enabled A flag determining whether GPC should be enabled. + * @return This GeckoRuntimeSettings instance + */ + public @NonNull GeckoRuntimeSettings setGlobalPrivacyControl(final boolean enabled) { + mGlobalPrivacyControlEnabled.commit(enabled); + // Global Privacy Control Feature is enabled by default in private browsing. + mGlobalPrivacyControlEnabledPrivateMode.commit(true); + mGlobalPrivacyControlFunctionalityEnabled.commit(true); + return this; + } + + /** + * Get whether Extensions Process support is enabled. + * + * @return Whether Extensions Process support is enabled. + */ + public @Nullable Boolean getExtensionsProcessEnabled() { + return mExtensionsProcess.get(); + } + + /** + * Set whether Extensions Process support should be enabled. + * + * @param flag A flag determining whether Extensions Process support should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setExtensionsProcessEnabled(final boolean flag) { + mExtensionsProcess.commit(flag); + return this; + } + + /** + * Get the crash threshold before spawning is disabled for the remote extensions process. + * + * @return the crash threshold + */ + public @Nullable Integer getExtensionsProcessCrashThreshold() { + return mExtensionsProcessCrashThreshold.get(); + } + + /** + * Get the timeframe in milliseconds for the threshold before spawning is disabled for the remote + * extensions process. + * + * @return the timeframe in milliseconds for the crash threshold + */ + public @Nullable Long getExtensionsProcessCrashTimeframe() { + return mExtensionsProcessCrashTimeframe.get(); + } + + /** + * Set the crash threshold before disabling spawning of the extensions remote process. + * + * @param crashThreshold max crashes allowed + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setExtensionsProcessCrashThreshold( + final @NonNull Integer crashThreshold) { + mExtensionsProcessCrashThreshold.commit(crashThreshold); + return this; + } + + /** + * Set the timeframe for the extensions process crash threshold. Any crashes older than the + * current time minus the timeframe are not included in the crash count. + * + * @param timeframeMs time in milliseconds + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setExtensionsProcessCrashTimeframe( + final @NonNull Long timeframeMs) { + mExtensionsProcessCrashTimeframe.commit(timeframeMs); + 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; + } + + /** + * 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(); + } + + /** + * Gets whether the Add-on Manager web API (`mozAddonManager`) is enabled. + * + * @return True when the web API is enabled, false otherwise. + */ + public boolean getExtensionsWebAPIEnabled() { + return mExtensionsWebAPIEnabled.get(); + } + + /** + * Get whether or not Global Privacy Control is currently enabled for normal tabs. + * + * @return True if GPC is enabled in normal tabs. + */ + public boolean getGlobalPrivacyControl() { + return mGlobalPrivacyControlEnabled.get(); + } + + /** + * Get whether or not Global Privacy Control is currently enabled for private tabs. + * + * @return True if GPC is enabled in private tabs. + */ + public boolean getGlobalPrivacyControlPrivateMode() { + return mGlobalPrivacyControlEnabledPrivateMode.get(); + } + + /** + * Sets whether the Add-on Manager web API (`mozAddonManager`) is enabled. + * + * @param flag True if the web API should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setExtensionsWebAPIEnabled(final boolean flag) { + mExtensionsWebAPIEnabled.commit(flag); + return this; + } + + 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(); + locales[0] = locale.toLanguageTag(); + 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 <meta> + * viewport tag and have been loaded in a session using {@link + * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic + * will attempt to increase font sizes for the main text content of the page only. + * + * <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(); + } + + /** + * Get the {@link ExperimentDelegate} instance set on this runtime, if any, + * + * @return The {@link ExperimentDelegate} set on this runtime. + */ + @AnyThread + public @Nullable ExperimentDelegate getExperimentDelegate() { + return mExperimentDelegate; + } + + /** + * 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 automatic popups should appear for offering translations on candidate pages. + * + * @param enabled A flag determining whether automatic offer popups should be enabled for + * translations. + * @return The builder instance. + */ + public @NonNull GeckoRuntimeSettings setTranslationsOfferPopup(final boolean enabled) { + mAutomaticallyOfferPopup.commit(enabled); + return this; + } + + /** + * Get whether automatic popups for translations is enabled. + * + * @return True if login automatic popups for translations are enabled. + */ + public boolean getTranslationsOfferPopup() { + return mAutomaticallyOfferPopup.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; + } + + /** The trusted recursive resolver (TRR) modes. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({TRR_MODE_OFF, TRR_MODE_FIRST, TRR_MODE_ONLY, TRR_MODE_DISABLED}) + public @interface TrustedRecursiveResolverMode {} + + /** Off (default). Use native DNS resolution by default. */ + public static final int TRR_MODE_OFF = 0; + + /** + * First. Use TRR first, and only if the name resolve fails use the native resolver as a fallback. + */ + public static final int TRR_MODE_FIRST = 2; + + /** Only. Only use TRR, never use the native resolver. */ + public static final int TRR_MODE_ONLY = 3; + + /** + * Off by choice. This is the same as 0 but marks it as done by choice and not done by default. + */ + public static final int TRR_MODE_DISABLED = 5; + + /** + * Get whether and how DNS-over-HTTPS (Trusted Recursive Resolver) is configured. + * + * @return One of the {@link GeckoRuntimeSettings#TRR_MODE_OFF TrustedRecursiveResolverMode} + * constants. + */ + public @TrustedRecursiveResolverMode int getTrustedRecusiveResolverMode() { + final int mode = mTrustedRecursiveResolverMode.get(); + switch (mode) { + case 2: + return TRR_MODE_FIRST; + case 3: + return TRR_MODE_ONLY; + case 5: + return TRR_MODE_DISABLED; + default: + case 0: + return TRR_MODE_OFF; + } + } + + /** + * Get the factor by which to increase the keepalive timeout when the NS_HTTP_LARGE_KEEPALIVE flag + * is used for a connection. + * + * @return An integer factor. + */ + public @NonNull int getLargeKeepaliveFactor() { + return mLargeKeepalivefactor.get(); + } + + /** + * Set whether and how DNS-over-HTTPS (Trusted Recursive Resolver) is configured. + * + * @param mode One of the {@link GeckoRuntimeSettings#TRR_MODE_OFF TrustedRecursiveResolverMode} + * constants. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setTrustedRecursiveResolverMode( + final @TrustedRecursiveResolverMode int mode) { + switch (mode) { + case TRR_MODE_OFF: + case TRR_MODE_FIRST: + case TRR_MODE_ONLY: + case TRR_MODE_DISABLED: + mTrustedRecursiveResolverMode.commit(mode); + break; + default: + throw new IllegalArgumentException("Invalid setting for setTrustedRecursiveResolverMode"); + } + return this; + } + + private static final int DEFAULT_LARGE_KEEPALIVE_FACTOR = 1; + + private int sanitizeLargeKeepaliveFactor(final int factor) { + if (factor < 1 || factor > 10) { + if (BuildConfig.DEBUG_BUILD) { + throw new IllegalArgumentException( + "largeKeepaliveFactor must be between 1 to 10 inclusive"); + } else { + Log.e(LOGTAG, "largeKeepaliveFactor must be between 1 to 10 inclusive"); + return DEFAULT_LARGE_KEEPALIVE_FACTOR; + } + } + + return factor; + } + + /** + * Set the factor by which to increase the keepalive timeout when the NS_HTTP_LARGE_KEEPALIVE flag + * is used for a connection. + * + * @param factor FACTOR by which to increase the keepalive timeout. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setLargeKeepaliveFactor(final int factor) { + final int newFactor = sanitizeLargeKeepaliveFactor(factor); + mLargeKeepalivefactor.commit(newFactor); + return this; + } + + /** + * Get the DNS-over-HTTPS (DoH) server URI. + * + * @return URI of the DoH server. + */ + public @NonNull String getTrustedRecursiveResolverUri() { + return mTrustedRecursiveResolverUri.get(); + } + + /** + * Set the DNS-over-HTTPS server URI. + * + * @param uri URI of the DNS-over-HTTPS server. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setTrustedRecursiveResolverUri(final @NonNull String uri) { + mTrustedRecursiveResolverUri.commit(uri); + 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..f8f7f858e3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java @@ -0,0 +1,8425 @@ +/* -*- 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_PRINT_DELEGATE; + +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.Locale; +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.GeckoDragAndDrop; +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; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt; + +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; + + 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; + private TranslationsController.SessionTranslation mTranslations = + new TranslationsController.SessionTranslation(this); + + /** {@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); + } + + @WrapForJNI(calledFrom = "ui") + private void startDragAndDrop(final Bitmap bitmap) { + GeckoSession.this.startDragAndDrop(bitmap); + } + + @WrapForJNI(calledFrom = "ui") + private void updateDragImage(final Bitmap bitmap) { + GeckoSession.this.updateDragImage(bitmap); + } + + @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", + "GeckoView:OnProductUrl", + }) { + @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)) { + if (getSelectionActionDelegate() != null) { + getSelectionActionDelegate().onDismissClipboardPermissionRequest(GeckoSession.this); + } + 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) { + if (callback != null) { + callback.sendError("Failed to create response"); + } + return; + } + result.accept( + response -> + ThreadUtils.runOnUiThread( + () -> delegate.onExternalResponse(GeckoSession.this, response)), + exception -> { + if (callback != null) { + callback.sendError("Failed to create response"); + } + }); + } else if ("GeckoView:OnProductUrl".equals(event)) { + delegate.onProductUrl(GeckoSession.this); + } + } + }; + + private final GeckoSessionHandler<NavigationDelegate> mNavigationHandler = + new GeckoSessionHandler<NavigationDelegate>( + "GeckoViewNavigation", + this, + new String[] {"GeckoView:LocationChange", "GeckoView:OnNewSession"}, + new String[] { + "GeckoView:OnLoadError", "GeckoView:OnLoadRequest", + }) { + // This needs to match nsIBrowserDOMWindow.idl + private int convertGeckoTarget(final int geckoTarget) { + switch (geckoTarget) { + case 0: // OPEN_DEFAULTWINDOW + case 1: // OPEN_CURRENTWINDOW + return NavigationDelegate.TARGET_WINDOW_CURRENT; + default: // OPEN_NEWWINDOW, OPEN_NEWTAB, OPEN_NEWTAB_BACKGROUND + 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"); + } + final String lowerCasedUri = url.toLowerCase(Locale.ROOT); + if (lowerCasedUri.startsWith("http") || lowerCasedUri.startsWith("https")) { + throw new IllegalArgumentException( + "Unsupported URI scheme for an error page"); + } + 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<ExperimentDelegate> mExperimentHandler = + new GeckoSessionHandler<ExperimentDelegate>( + "GeckoViewExperiment", + this, + new String[] { + "GeckoView:GetExperimentFeature", + "GeckoView:RecordExposure", + "GeckoView:RecordExperimentExposure", + "GeckoView:RecordMalformedConfig" + }) { + @Override + public void handleMessage( + final ExperimentDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + + if (delegate == null) { + if (callback != null) { + callback.sendError("No experiment delegate registered."); + } + Log.w(LOGTAG, "No experiment delegate registered."); + return; + } + final String feature = message.getString("feature", ""); + if ("GeckoView:GetExperimentFeature".equals(event) && callback != null) { + final GeckoResult<JSONObject> result = delegate.onGetExperimentFeature(feature); + result + .accept( + json -> { + try { + callback.sendSuccess(GeckoBundle.fromJSONObject(json)); + } catch (final JSONException e) { + callback.sendError("An error occured when serializing the feature data."); + } + }) + .exceptionally( + e -> { + callback.sendError("An error occurred while retrieving feature data."); + return null; + }); + + } else if ("GeckoView:RecordExposure".equals(event) && callback != null) { + final GeckoResult<Void> result = delegate.onRecordExposureEvent(feature); + result + .accept( + a -> { + callback.sendSuccess(true); + }) + .exceptionally( + e -> { + callback.sendError("An error occurred while recording feature."); + return null; + }); + + } else if ("GeckoView:RecordExperimentExposure".equals(event) && callback != null) { + final String slug = message.getString("slug", ""); + final GeckoResult<Void> result = + delegate.onRecordExperimentExposureEvent(feature, slug); + result + .accept( + a -> { + callback.sendSuccess(true); + }) + .exceptionally( + e -> { + callback.sendError("An error occurred while recording experiment feature."); + return null; + }); + + } else if ("GeckoView:RecordMalformedConfig".equals(event) && callback != null) { + final String part = message.getString("part", ""); + final GeckoResult<Void> result = + delegate.onRecordMalformedConfigurationEvent(feature, part); + result + .accept( + a -> { + callback.sendSuccess(true); + }) + .exceptionally( + e -> { + callback.sendError( + "An error occurred while recording malformed feature config."); + 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)) { + if (getSelectionActionDelegate() != null) { + getSelectionActionDelegate().onDismissClipboardPermissionRequest(GeckoSession.this); + } + 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); + private final TranslationsController.SessionTranslation.Handler mTranslationsHandler = + mTranslations.getHandler(); + + /* package */ int handlersCount; + + private final GeckoSessionHandler<?>[] mSessionHandlers = + new GeckoSessionHandler<?>[] { + mContentHandler, + mHistoryHandler, + mMediaHandler, + mNavigationHandler, + mPermissionHandler, + mPrintHandler, + mProcessHangHandler, + mProgressHandler, + mScrollHandler, + mSelectionActionDelegate, + mTranslationsHandler, + mContentBlockingHandler, + mMediaSessionHandler, + mExperimentHandler + }; + + 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 -> { + 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); + mExperimentHandler.setDelegate(getRuntimeExperimentDelegate(), this); + + 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) { + return Objects.equals(a, 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) { + mEventDispatcher.dispatch("GeckoView:DismissClipboardPermissionRequest", null); + + 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; + } + + return mIndex < mState.getHistoryEntries().length; + } + + @Override /* ListIterator */ + public boolean hasPrevious() { + return mIndex > 0; + } + + @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 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"); + } + + /** + * Request analysis of product's reviews for a given product URL. + * + * @param url The URL of the product page. + * @return a {@link GeckoResult} result of review analysis object. + */ + @AnyThread + public @NonNull GeckoResult<ReviewAnalysis> requestAnalysis(@NonNull final String url) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("url", url); + return mEventDispatcher + .queryBundle("GeckoView:RequestAnalysis", bundle) + .map(analysisBundle -> new ReviewAnalysis(analysisBundle.getBundle("analysis"))); + } + + /** + * Request the creation of an analysis of product's reviews for a given product URL. + * + * @param url The URL of the product page. + * @return a {@link GeckoResult} result of status of analysis. + */ + @AnyThread + public @NonNull GeckoResult<String> requestCreateAnalysis(@NonNull final String url) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("url", url); + return mEventDispatcher.queryString("GeckoView:RequestCreateAnalysis", bundle); + } + + /** + * Request the status of the current analysis of product's reviews for a given product URL. + * + * @param url The URL of the product page. + * @return a {@link GeckoResult} result of status of analysis. + */ + @AnyThread + public @NonNull GeckoResult<AnalysisStatusResponse> requestAnalysisStatus( + @NonNull final String url) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("url", url); + return mEventDispatcher + .queryBundle("GeckoView:RequestAnalysisStatus", bundle) + .map(statusBundle -> new AnalysisStatusResponse(statusBundle.getBundle("status"))); + } + + /** + * Poll for the status of the current analysis of product's reviews for a given product URL. + * + * @param url The URL of the product page. + * @return a {@link GeckoResult} result of status of analysis. + */ + @AnyThread + public @NonNull GeckoResult<String> pollForAnalysisCompleted(@NonNull final String url) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("url", url); + return mEventDispatcher.queryString("GeckoView:PollForAnalysisCompleted", bundle); + } + + /** + * Send a click event to the Ad Attribution API. + * + * @param aid Ad id of the recommended product. + * @return a {@link GeckoResult} result of whether or not sending the event was successful. + */ + @AnyThread + public @NonNull GeckoResult<Boolean> sendClickAttributionEvent(@NonNull final String aid) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("aid", aid); + return mEventDispatcher.queryBoolean("GeckoView:SendClickAttributionEvent", bundle); + } + + /** + * Send an impression event to the Ad Attribution API. + * + * @param aid Ad id of the recommended product. + * @return a {@link GeckoResult} result of whether or not sending the event was successful. + */ + @AnyThread + public @NonNull GeckoResult<Boolean> sendImpressionAttributionEvent(@NonNull final String aid) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("aid", aid); + return mEventDispatcher.queryBoolean("GeckoView:SendImpressionAttributionEvent", bundle); + } + + /** + * Send a placement event to the Ad Attribution API. + * + * @param aid Ad id of the recommended product. + * @return a {@link GeckoResult} result of whether or not sending the event was successful. + */ + @AnyThread + public @NonNull GeckoResult<Boolean> sendPlacementAttributionEvent(@NonNull final String aid) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("aid", aid); + return mEventDispatcher.queryBoolean("GeckoView:SendPlacementAttributionEvent", bundle); + } + + /** + * Request product recommendations given a specific product url. + * + * @param url The URL of the product page. + * @return a {@link GeckoResult} result of product recommendations. + */ + @AnyThread + public @NonNull GeckoResult<List<Recommendation>> requestRecommendations( + @NonNull final String url) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("url", url); + return mEventDispatcher + .queryBundle("GeckoView:RequestRecommendations", bundle) + .map( + recommendationsBundle -> { + final GeckoBundle[] bundles = recommendationsBundle.getBundleArray("recommendations"); + final ArrayList<Recommendation> recArray = new ArrayList<>(bundles.length); + if (recArray != null) { + for (final GeckoBundle b : bundles) { + recArray.add(new Recommendation(b)); + } + } + return recArray; + }); + } + + /** + * Report that a product is back in stock. + * + * @param url The URL of the product page. + * @return a {@link GeckoResult} result of whether reporting a product is back in stock was + * successful. + */ + @AnyThread + public @NonNull GeckoResult<String> reportBackInStock(@NonNull final String url) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("url", url); + return mEventDispatcher.queryString("GeckoView:ReportBackInStock", bundle); + } + + // 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(); + } + + /** + * The session translation object coordinates receiving and sending session messages with the + * translations toolkit. Notably, it can be used to request translations. + * + * @return The current translation session coordinator. + */ + @AnyThread + public @Nullable TranslationsController.SessionTranslation getSessionTranslation() { + return mTranslations; + } + + /** + * Set the translation delegate, which receives translations events. + * + * @param delegate An implementation of @link{TranslationsController.SessionTranslation.Delegate}. + */ + @AnyThread + public void setTranslationsSessionDelegate( + final @Nullable TranslationsController.SessionTranslation.Delegate delegate) { + mTranslationsHandler.setDelegate(delegate, this); + } + + /** + * Get the translations delegate. The application embedder must initially set the translations + * delegate for use. + * + * @return The current translations delegate. + */ + @AnyThread + public @Nullable TranslationsController.SessionTranslation.Delegate + getTranslationsSessionDelegate() { + return mTranslationsHandler.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. */ + 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 = ""; + } + } + + /** Contains information about the analysis of a product's reviews. */ + @AnyThread + public static class ReviewAnalysis { + /** Analysis URL. */ + @Nullable public final String analysisURL; + + /** Product identifier (ASIN/SKU). */ + @Nullable public final String productId; + + /** Reliability grade for the product's reviews. */ + @Nullable public final String grade; + + /** Product rating adjusted to exclude untrusted reviews. */ + @Nullable public final Double adjustedRating; + + /** Boolean indicating if the analysis is stale. */ + public final boolean needsAnalysis; + + /** Boolean indicating if the page is not supported. */ + public final boolean pageNotSupported; + + /** Boolean indicating if there are not enough reviews. */ + public final boolean notEnoughReviews; + + /** Object containing highlights for product. */ + @Nullable public final Highlight highlights; + + /** Time since the last analysis was performed. */ + public final long lastAnalysisTime; + + /** Boolean indicating if reported that this product has been deleted. */ + public final boolean deletedProductReported; + + /** Boolean indicating if this product is now deleted. */ + public final boolean deletedProduct; + + /* package */ ReviewAnalysis(final GeckoBundle message) { + analysisURL = message.getString("analysis_url"); + productId = message.getString("product_id"); + grade = message.getString("grade"); + adjustedRating = message.getDoubleObject("adjusted_rating"); + needsAnalysis = message.getBoolean("needs_analysis"); + pageNotSupported = message.getBoolean("page_not_supported"); + notEnoughReviews = message.getBoolean("not_enough_reviews"); + if (message.getBundle("highlights") == null) { + highlights = null; + } else { + highlights = new Highlight(message.getBundle("highlights")); + } + lastAnalysisTime = message.getLong("last_analysis_time"); + deletedProductReported = message.getBoolean("deleted_product_reported"); + deletedProduct = message.getBoolean("deleted_product"); + } + + /** + * Initialize a ReviewAnalysis object with a builder object + * + * @param builder A ReviewAnalysis.Builder instance + */ + protected ReviewAnalysis(final @NonNull Builder builder) { + analysisURL = builder.mAnalysisUrl; + productId = builder.mProductId; + grade = builder.mGrade; + adjustedRating = builder.mAdjustedRating; + needsAnalysis = builder.mNeedsAnalysis; + pageNotSupported = builder.mPageNotSupported; + notEnoughReviews = builder.mNotEnoughReviews; + highlights = builder.mHighlights; + lastAnalysisTime = builder.mLastAnalysisTime; + deletedProduct = builder.mDeletedProduct; + deletedProductReported = builder.mDeletedProductReported; + } + + /** This is a Builder used by ReviewAnalysis class */ + public static class Builder { + /* package */ String mAnalysisUrl = ""; + /* package */ String mProductId = ""; + /* package */ String mGrade = null; + /* package */ Double mAdjustedRating = 0.0; + /* package */ Boolean mNeedsAnalysis = false; + /* package */ Boolean mPageNotSupported = false; + /* package */ Boolean mNotEnoughReviews = false; + /* package */ Highlight mHighlights = new Highlight(); + /* package */ long mLastAnalysisTime = 0; + /* package */ Boolean mDeletedProductReported = false; + /* package */ Boolean mDeletedProduct = false; + + /** + * Construct a Builder instance with the specified product ID. + * + * @param productId A String with the product ID. + */ + public Builder(final @Nullable String productId) { + productId(productId); + } + + /** + * Set the analysis URL + * + * @param analysisUrl A URI String + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder analysisUrl(final @Nullable String analysisUrl) { + mAnalysisUrl = analysisUrl; + return this; + } + + /** + * Set the product identifier + * + * @param productId A product ID String + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder productId(final @Nullable String productId) { + mProductId = productId; + return this; + } + + /** + * Set the grade of the product + * + * @param grade A grade String + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder grade(final @Nullable String grade) { + mGrade = grade; + return this; + } + + /** + * Set the adjusted rating + * + * @param adjustedRating the adjusted rating of the product + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder adjustedRating(final @NonNull Double adjustedRating) { + mAdjustedRating = adjustedRating; + return this; + } + + /** + * Set the flag that indicates whether this product needs analysis + * + * @param needsAnalysis indicates whether this product needs analysis + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder needsAnalysis(final @NonNull Boolean needsAnalysis) { + mNeedsAnalysis = needsAnalysis; + return this; + } + + /** + * Set the flag that indicates whether this product page is supported + * + * @param pageNotSupported indicates whether this product page is supported + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder pageNotSupported( + final @NonNull Boolean pageNotSupported) { + mPageNotSupported = pageNotSupported; + return this; + } + + /** + * Set the flag that indicates whether there are not enough reviews + * + * @param notEnoughReviews indicates whether there are not enough reviews + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder notEnoughReviews( + final @NonNull Boolean notEnoughReviews) { + mNotEnoughReviews = notEnoughReviews; + return this; + } + + /** + * Set an empty highlights object for the product + * + * @param highlight A Highlight object (can be null) to overwrite the default empty Highlight + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder highlights(final @Nullable Highlight highlight) { + mHighlights = highlight; + return this; + } + + /** + * Set the time of the analysis + * + * @param lastAnalysisTime The time of the analysis + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder lastAnalysisTime(final long lastAnalysisTime) { + mLastAnalysisTime = lastAnalysisTime; + return this; + } + + /** + * Set the flag that indicates whether this deleted product was reported + * + * @param deletedProductReported Boolean to indicate whether this deleted product was reported + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder deletedProductReported( + final @NonNull Boolean deletedProductReported) { + mDeletedProductReported = deletedProductReported; + return this; + } + + /** + * Set the flag that indicates whether the product is deleted + * + * @param deletedProduct Boolean to indicate whether the product is deleted + * @return This Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis.Builder deletedProduct(final @NonNull Boolean deletedProduct) { + mDeletedProduct = deletedProduct; + return this; + } + + /** + * @return A {@link ReviewAnalysis} constructed with the values from this Builder instance. + */ + @AnyThread + public @NonNull ReviewAnalysis build() { + return new ReviewAnalysis(this); + } + } + + /** Contains information about highlights of a product's reviews. */ + public static class Highlight { + /** Highlights about the quality of a product. */ + @Nullable public final String[] quality; + + /** Highlights about the price of a product. */ + @Nullable public final String[] price; + + /** Highlights about the shipping of a product. */ + @Nullable public final String[] shipping; + + /** Highlights about the appearance of a product. */ + @Nullable public final String[] appearance; + + /** Highlights about the competitiveness of a product. */ + @Nullable public final String[] competitiveness; + + /* package */ Highlight(final GeckoBundle message) { + quality = message.getStringArray("quality"); + price = message.getStringArray("price"); + shipping = message.getStringArray("shipping"); + appearance = message.getStringArray("packaging/appearance"); + competitiveness = message.getStringArray("competitiveness"); + } + + /** Empty constructor for tests. */ + protected Highlight() { + quality = null; + price = null; + shipping = null; + appearance = null; + competitiveness = null; + } + } + } + + /** Contains information about a product recommendation. */ + @AnyThread + public static class Recommendation { + /** Analysis URL. */ + @NonNull public final String analysisUrl; + + /** Adjusted rating. */ + @NonNull public final Double adjustedRating; + + /** Whether or not it is a sponsored recommendation. */ + @NonNull public final Boolean sponsored; + + /** Url of product recommendation image. */ + @NonNull public final String imageUrl; + + /** Unique identifier for the ad entity. */ + @NonNull public final String aid; + + /** Url of recommended product. */ + @NonNull public final String url; + + /** Name of recommended product. */ + @NonNull public final String name; + + /** Grade of recommended product. */ + @NonNull public final String grade; + + /** Price of recommended product. */ + @NonNull public final String price; + + /** Currency of recommended product. */ + @NonNull public final String currency; + + /* package */ Recommendation(@NonNull final GeckoBundle message) { + analysisUrl = message.getString("analysis_url"); + adjustedRating = message.getDouble("adjusted_rating"); + sponsored = message.getBoolean("sponsored"); + imageUrl = message.getString("image_url"); + aid = message.getString("aid"); + url = message.getString("url"); + name = message.getString("name"); + grade = message.getString("grade"); + price = message.getString("price"); + currency = message.getString("currency"); + } + + /** + * Initialize Recommendation with a builder object + * + * @param builder A Recommendation.Builder instance + */ + protected Recommendation(final @NonNull Builder builder) { + url = builder.mUrl; + analysisUrl = builder.mAnalysisUrl; + adjustedRating = builder.mAdjustedRating; + sponsored = builder.mSponsored; + imageUrl = builder.mImageUrl; + aid = builder.mAid; + name = builder.mName; + grade = builder.mGrade; + price = builder.mPrice; + currency = builder.mCurrency; + } + + /** This is a Builder used by Recommendation class */ + public static class Builder { + /* package */ String mAnalysisUrl = ""; + /* package */ Double mAdjustedRating = 0.0; + /* package */ Boolean mSponsored = false; + /* package */ String mImageUrl = ""; + /* package */ String mAid = ""; + /* package */ String mUrl = ""; + /* package */ String mName = ""; + /* package */ String mGrade = ""; + /* package */ String mPrice = ""; + /* package */ String mCurrency = ""; + + /** + * Construct a Builder instance with the specified recommendation URL. + * + * @param recommendationUrl A URI String. + */ + public Builder(final @NonNull String recommendationUrl) { + url(recommendationUrl); + } + + /** + * Set the analysis URL + * + * @param analysisUrl A URI String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder analysisUrl(final @NonNull String analysisUrl) { + mAnalysisUrl = analysisUrl; + return this; + } + + /** + * Set the adjusted rating + * + * @param adjustedRating the adjusted rating of the product + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder adjustedRating(final @NonNull Double adjustedRating) { + mAdjustedRating = adjustedRating; + return this; + } + + /** + * Set the flag that indicates whether this recommendation is sponsored or not + * + * @param sponsored indicates whether this recommendation is sponsored + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder sponsored(final @NonNull Boolean sponsored) { + mSponsored = sponsored; + return this; + } + + /** + * Set the image URL + * + * @param imageUrl An image URL String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder imageUrl(final @NonNull String imageUrl) { + mImageUrl = imageUrl; + return this; + } + + /** + * Set the ad identifier + * + * @param aid The id String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder aid(final @NonNull String aid) { + mAid = aid; + return this; + } + + /** + * Set the recommendation URL + * + * @param url A URI String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder url(final @NonNull String url) { + mUrl = url; + return this; + } + + /** + * Set the name of the recommended product + * + * @param name A name String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder name(final @NonNull String name) { + mName = name; + return this; + } + + /** + * Set the grade of the recommended product + * + * @param grade A grade String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder grade(final @NonNull String grade) { + mGrade = grade; + return this; + } + + /** + * Set the price of the recommended product + * + * @param price A price String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder price(final @NonNull String price) { + mPrice = price; + return this; + } + + /** + * Set the currency of the price of the recommended product + * + * @param currency A currency String + * @return This Builder instance. + */ + @AnyThread + public @NonNull Recommendation.Builder currency(final @NonNull String currency) { + mCurrency = currency; + return this; + } + + /** + * @return A {@link Recommendation} constructed with the values from this Builder instance. + */ + @AnyThread + public @NonNull Recommendation build() { + return new Recommendation(this); + } + } + } + + /** Contains information about a product's analysis status response. */ + @AnyThread + public static class AnalysisStatusResponse { + /** Status of the analysis. */ + @NonNull public final String status; + + /** Indicates the progress of the analysis. */ + @NonNull public final Double progress; + + /* package */ AnalysisStatusResponse(@NonNull final GeckoBundle message) { + status = message.getString("status"); + progress = message.getDoubleObject("progress", 0.0); + } + + /** + * Initialize AnalysisStatusResponse with a builder object + * + * @param builder A AnalysisStatusResponse.Builder instance + */ + protected AnalysisStatusResponse(final @NonNull Builder builder) { + status = builder.mStatus; + progress = builder.mProgress; + } + + /** This is a Builder used by AnalysisStatusResponse class */ + public static class Builder { + /* package */ String mStatus = ""; + /* package */ Double mProgress = 0.0; + + /** + * Construct a Builder instance with the specified AnalysisStatusResponse status. + * + * @param status A status String. + */ + public Builder(final @NonNull String status) { + status(status); + } + + /** + * Set the status. + * + * @param status A status String. + * @return This Builder instance. + */ + @AnyThread + public @NonNull AnalysisStatusResponse.Builder status(final @NonNull String status) { + mStatus = status; + return this; + } + + /** + * Set the progress. + * + * @param progress Indicates the progress of the analysis. + * @return This Builder instance. + */ + @AnyThread + public @NonNull AnalysisStatusResponse.Builder progress(final @NonNull Double progress) { + mProgress = progress; + return this; + } + + /** + * @return A {@link AnalysisStatusResponse} constructed with the values from this Builder + * instance. + */ + @AnyThread + public @NonNull AnalysisStatusResponse build() { + return new AnalysisStatusResponse(this); + } + } + } + + 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) {} + + /** + * Session is on a product url. + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onProductUrl(@NonNull final GeckoSession session) {} + + /** Element details for onContextMenu callbacks. */ + 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) {} + } + + public interface SelectionActionDelegate { + /** The selection is collapsed at a single position. */ + int FLAG_IS_COLLAPSED = 1 << 0; + + /** + * The selection is inside editable content such as an input element or contentEditable node. + */ + int FLAG_IS_EDITABLE = 1 << 1; + + /** The selection is inside a password field. */ + int FLAG_IS_PASSWORD = 1 << 2; + + /** Hide selection actions and cause {@link #onHideAction} to be called. */ + String ACTION_HIDE = "org.mozilla.geckoview.HIDE"; + + /** Copy onto the clipboard then delete the selected content. Selection must be editable. */ + String ACTION_CUT = "org.mozilla.geckoview.CUT"; + + /** Copy the selected content onto the clipboard. */ + String ACTION_COPY = "org.mozilla.geckoview.COPY"; + + /** Delete the selected content. Selection must be editable. */ + String ACTION_DELETE = "org.mozilla.geckoview.DELETE"; + + /** Replace the selected content with the clipboard content. Selection must be editable. */ + String ACTION_PASTE = "org.mozilla.geckoview.PASTE"; + + /** + * Replace the selected content with the clipboard content as plain text. Selection must be + * editable. + */ + String ACTION_PASTE_AS_PLAIN_TEXT = "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT"; + + /** Select the entire content of the document or editor. */ + String ACTION_SELECT_ALL = "org.mozilla.geckoview.SELECT_ALL"; + + /** Clear the current selection. Selection must not be editable. */ + String ACTION_UNSELECT = "org.mozilla.geckoview.UNSELECT"; + + /** Collapse the current selection to its start position. Selection must be editable. */ + String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START"; + + /** Collapse the current selection to its end position. Selection must be editable. */ + 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. */ + 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. + */ + 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. + */ + 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. + */ + 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. */ + 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) {} + + int TARGET_WINDOW_NONE = 0; + int TARGET_WINDOW_CURRENT = 1; + int TARGET_WINDOW_NEW = 2; + + // Match with nsIWebNavigation.idl. + /** The load request was triggered by an HTTP redirect. */ + int LOAD_REQUEST_IS_REDIRECT = 0x800000; + + /** Load request details. */ + 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, OPEN_NEWTAB_BACKGROUND + 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 (cannot be http/https). Returning null or http/https URL + * 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. */ + 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 <option> elements are updated when using <select> + * 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. + 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. + */ + 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; + } + } + + /** Contains all the Identity credential prompts (FedCM) */ + final class IdentityCredential { + /** + * ProviderSelectorPrompt contains the information necessary to represent a prompt that allows + * the user to select the identity credential provider they would like to use. + */ + public static class ProviderSelectorPrompt extends BasePrompt { + /** The providers from which the user could select. */ + public final @NonNull Provider[] providers; + + /** + * Creates a new {@link ProviderSelectorPrompt} with the given parameters. + * + * @param id The identification for this prompt. + * @param providers The providers from which the user could select. + * @param observer A callback to notify when the prompt has been completed. + */ + protected ProviderSelectorPrompt( + @NonNull final String id, + @NonNull final Provider[] providers, + @NonNull final Observer observer) { + super(id, null, observer); + this.providers = providers; + } + + /** + * Confirms the prompt and passes the provider index back to content. + * + * @param providerIndex providerIndex An integer representing the index of the provider + * chosen by the user 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(final int providerIndex) { + ensureResult().putInt("providerIndex", providerIndex); + return super.confirm(); + } + + /** A representation of an Identity Credential Provider. */ + public static class Provider { + /** A base64 string for given icon for the provider; may be null. */ + public final @Nullable String icon; + + /** The name of the provider. */ + public final @NonNull String name; + + /** The id of the provider. */ + public final int id; + + /** The domain of the provider */ + public final @NonNull String domain; + + /** + * Creates a new {@link Provider} with the given parameters. + * + * @param id The identification for this prompt. + * @param icon A string base64 icon. + * @param name The name of the {@link Provider}. + * @param domain The domain of the {@link Provider}. + */ + public Provider( + final int id, + final @NonNull String name, + final @Nullable String icon, + final @NonNull String domain) { + this.id = id; + this.icon = icon; + this.name = name; + this.domain = domain; + } + + /* package */ + static @NonNull Provider fromBundle(final @NonNull GeckoBundle bundle) { + final int id = bundle.getInt("providerIndex"); + final String icon = bundle.getString("icon"); + final String name = bundle.getString("name"); + final String domain = bundle.getString("domain"); + return new Provider(id, name, icon, domain); + } + } + } + + /** + * AccountSelectorPrompt contains the information necessary to represent a prompt that allows + * the user to select the account they would like to use. + */ + public static class AccountSelectorPrompt extends BasePrompt { + /** The accounts from which the user could select. */ + public final @NonNull Account[] accounts; + + /** The name of the provider the user is trying to login with */ + public final @NonNull Provider provider; + + /** + * Creates a new {@link AccountSelectorPrompt} with the given parameters. + * + * @param id The identification for this prompt. + * @param accounts The accounts from which the user could select. + * @param provider The provider on which the user is trying to log in. + * @param observer A callback to notify when the prompt has been completed. + */ + public AccountSelectorPrompt( + @NonNull final String id, + @NonNull final Account[] accounts, + @NonNull final Provider provider, + final Observer observer) { + super(id, null, observer); + this.accounts = accounts; + this.provider = provider; + } + + /** + * Confirms the prompt and passes the account index back to content. + * + * @param accountIndex An integer representing the index of the account chosen by the user + * 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 int accountIndex) { + ensureResult().putInt("accountIndex", accountIndex); + return super.confirm(); + } + + /** A representation of an Identity Credential Provider Accounts. */ + public static class ProviderAccounts { + /** The name of the provider. */ + public final @Nullable Provider provider; + + /** The accounts available for this provider. */ + public final @NonNull Account[] accounts; + + /** The id of this prompt. */ + public final int id; + + /** + * Creates a new {@link ProviderAccounts} with the given parameters + * + * @param id The identification for this prompt. + * @param provider The name of the provider. + * @param accounts The list of {@link Account}s available for this provider. + */ + public ProviderAccounts( + final int id, @Nullable final Provider provider, @NonNull final Account[] accounts) { + this.id = id; + this.provider = provider; + this.accounts = accounts; + } + + /* package */ + static @NonNull ProviderAccounts fromBundle(final @NonNull GeckoBundle bundle) { + final int id = bundle.getInt("accountIndex"); + final Provider provider = Provider.fromBundle(bundle.getBundle("provider")); + + final GeckoBundle[] accountsBundle = bundle.getBundleArray("accounts"); + if (accountsBundle == null) { + return new ProviderAccounts(id, provider, new Account[0]); + } + + final Account[] accounts = new Account[accountsBundle.length]; + for (int i = 0; i < accountsBundle.length; i++) { + accounts[i] = Account.fromBundle(accountsBundle[i]); + } + return new ProviderAccounts(id, provider, accounts); + } + } + + /** A representation of an Identity Credential Account. */ + public static class Account { + /** The id of the account. */ + public final int id; + + /** The email associated to this account. */ + public final @NonNull String email; + + /** The name of this account. */ + public final @NonNull String name; + + /** A base64 string for given icon for the account; may be null. */ + public final @Nullable String icon; + + /** + * Creates a new {@link Account} with the given parameters. + * + * @param id The identification for this account. + * @param email The email of this account. + * @param name The name of this account. + * @param icon A string base64 icon. + */ + public Account( + final int id, + @NonNull final String email, + @NonNull final String name, + @Nullable final String icon) { + this.email = email; + this.name = name; + this.icon = icon; + this.id = id; + } + + /* package */ + static @NonNull Account fromBundle(final @NonNull GeckoBundle bundle) { + final int id = bundle.getInt("id"); + final String icon = bundle.getString("icon"); + final String name = bundle.getString("name"); + final String email = bundle.getString("email"); + return new Account(id, email, name, icon); + } + } + + /** A representation of an Identity Credential Provider for an Account Selector Prompt */ + public static class Provider { + /** The name of the provider */ + public final @NonNull String name; + + /** The domain of the provider */ + public final @NonNull String domain; + + /** A base64 string for given icon for the provider; may be null. */ + public final @Nullable String icon; + + /** + * Creates a new {@link Provider} with the given parameters + * + * @param name the name of the Provider + * @param favicon A string base64 icon for the provider + * @param domain A string base64 icon for the provider + */ + public Provider( + @NonNull final String name, + @NonNull final String domain, + @Nullable final String favicon) { + this.name = name; + this.domain = domain; + this.icon = favicon; + } + + /* package */ + static @NonNull Provider fromBundle(final @NonNull GeckoBundle bundle) { + final String name = bundle.getString("name"); + final String domain = bundle.getString("domain"); + final String icon = bundle.getString("icon"); + return new Provider(name, domain, icon); + } + } + } + + /** + * PrivacyPolicyPrompt contains the information necessary to represent a prompt that allows + * the user to indicate if agrees or not with the privacy policy of the identity credential + * provider. + */ + public static class PrivacyPolicyPrompt extends BasePrompt { + /** The URL where the policy for using this provider is hosted. */ + public final @NonNull String privacyPolicyUrl; + + /** The URL where the terms of service for using this provider are hosted. */ + public final @NonNull String termsOfServiceUrl; + + /** The domain of the provider. */ + public final @NonNull String providerDomain; + + /** The host of the provider. */ + public final @NonNull String host; + + /** A base64 string for given icon for the provider; may be null. */ + public final @Nullable String icon; + + /** + * Creates a new {@link IdentityCredential.ProviderSelectorPrompt} with the given + * parameters. + * + * @param id The identification for this prompt. + * @param privacyPolicyUrl The URL where the policy for using this provider is hosted. + * @param termsOfServiceUrl The URL where the terms of service for using this provider are + * hosted. + * @param providerDomain The domain of the provider. + * @param host The host of the provider. + * @param icon A base64 string for given icon for the provider; may be null. + * @param observer A callback to notify when the prompt has been completed. + */ + protected PrivacyPolicyPrompt( + @NonNull final String id, + @NonNull final String privacyPolicyUrl, + @NonNull final String termsOfServiceUrl, + @NonNull final String providerDomain, + @NonNull final String host, + @Nullable final String icon, + @NonNull final Observer observer) { + super(id, null, observer); + this.privacyPolicyUrl = privacyPolicyUrl; + this.termsOfServiceUrl = termsOfServiceUrl; + this.providerDomain = providerDomain; + this.host = host; + this.icon = icon; + } + + /** + * Confirms the prompt and passes the provider accept value back to content. + * + * @param accept A boolean indicating if the user accepts or not the Privacy Policy of the + * provider. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final boolean accept) { + ensureResult().putBoolean("accept", accept); + return super.confirm(); + } + } + } + + /** + * ButtonPrompt contains the information necessary to represent a JavaScript confirm() call from + * content. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + class ColorPrompt extends BasePrompt { + /** The default value supplied by content. */ + public final @Nullable String defaultValue; + + /** The predefined values by <datalist> element */ + public final @Nullable String[] predefinedValues; + + protected ColorPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String defaultValue, + @Nullable final String[] predefinedValues, + @NonNull final Observer observer) { + super(id, title, observer); + this.defaultValue = defaultValue; + this.predefinedValues = predefinedValues; + } + + /** + * Confirms the prompt and passes the color value back to content. + * + * @param color A String representing the color to be returned to content. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String color) { + ensureResult().putString("color", color); + return super.confirm(); + } + } + + /** + * DateTimePrompt contains the information necessary to represent a prompt for date and/or time + * input generated by content. + */ + 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. + */ + 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. */ + 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) { + final boolean res = AllowOrDeny.ALLOW == response; + ensureResult().putBoolean("response", res); + return super.confirm(); + } + } + + /** SharePrompt contains the information necessary to represent a (v1) WebShare request. */ + 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. */ + 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 an Identity Credential Provider selection prompt request. This is triggered by the + * user focusing on selecting a provider for authenticating. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param prompt The {@link ProviderSelectorPrompt} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onSelectIdentityCredentialProvider( + @NonNull final GeckoSession session, @NonNull final ProviderSelectorPrompt prompt) { + return null; + } + + /** + * Handle an Identity Credential Account selection prompt request. This is triggered by the user + * focusing on selecting a provider for authenticating. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param prompt The {@link ProviderSelectorPrompt} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onSelectIdentityCredentialAccount( + @NonNull final GeckoSession session, @NonNull final AccountSelectorPrompt prompt) { + return null; + } + + /** + * Handle an Identity Credential privacy policy prompt request. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param prompt The {@link PrivacyPolicyPrompt} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onShowPrivacyPolicyIdentityCredential( + @NonNull final GeckoSession session, @NonNull final PrivacyPolicyPrompt prompt) { + 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 <a + * href="https://developer.android.com/guide/topics/large-screens/multi-window-support#window_metrics">...</a> + */ + @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. 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 if (permission.startsWith("3rdPartyFrameStorage^")) { + // Storage access permissions may also be stored with the key + // "3rdPartyFrameStorage^https://foo.com" where the third party + // origin is "https://foo.com". + this.thirdPartyOrigin = permission.substring(21); + } 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^") + || type.startsWith("3rdPartyFrameStorage^")) { + 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); + } + } + + /* package */ void startDragAndDrop(final Bitmap bitmap) { + ThreadUtils.assertOnUiThread(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return; + } + final View view = getTextInput().getView(); + if (view == null) { + return; + } + + GeckoDragAndDrop.startDragAndDrop(view, bitmap); + } + + /* package */ void updateDragImage(final Bitmap bitmap) { + ThreadUtils.assertOnUiThread(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return; + } + final View view = getTextInput().getView(); + if (view == null) { + return; + } + + GeckoDragAndDrop.updateDragImage(view, bitmap); + } + + /** 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. */ + 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`. + */ + 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. */ + int VISIT_TOP_LEVEL = 1 << 0; + + /** The URL is the target of a temporary redirect. */ + int VISIT_REDIRECT_TEMPORARY = 1 << 1; + + /** The URL is the target of a permanent redirect. */ + int VISIT_REDIRECT_PERMANENT = 1 << 2; + + /** The URL is temporarily redirected to another URL. */ + int VISIT_REDIRECT_SOURCE = 1 << 3; + + /** The URL is permanently redirected to another URL. */ + int VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4; + + /** The URL failed to load due to a client or server error. */ + 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<>(); + if (browsingContextId == null) { + // Ensures the canonical browsing context is available + setFocused(true); + this.mWindow.printToPdf(geckoResult); + } else { + this.mWindow.printToPdf(geckoResult, browsingContextId); + } + 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."); + } + } + + /** + * Prints the currently displayed page and provides dialog finished status or if an exception + * occured. + * + * @return if the printing dialog finished or an exception. + */ + @AnyThread + public @NonNull GeckoResult<Boolean> didPrintPageContent() { + final PrintDelegate delegate = getPrintDelegate(); + final GeckoResult<Boolean> result = new GeckoResult<>(); + if (delegate == null) { + result.completeExceptionally(new GeckoPrintException(ERROR_NO_PRINT_DELEGATE)); + return result; + } + return saveAsPdfByBrowsingContext(null) + .then(pdfStream -> delegate.onPrintWithStatus(pdfStream)); + } + + 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); + } + + /** + * Gets the experiment delegate for this session. + * + * @return The current {@link ExperimentDelegate} for this session, if any. + */ + @AnyThread + public @Nullable ExperimentDelegate getExperimentDelegate() { + return mExperimentHandler.getDelegate(); + } + + /** + * Gets the experiment delegate from the runtime. + * + * @return The current {@link ExperimentDelegate} for the runtime or null. + */ + @AnyThread + private @Nullable ExperimentDelegate getRuntimeExperimentDelegate() { + final GeckoRuntime runtime = this.getRuntime(); + if (runtime != null) { + final GeckoRuntimeSettings runtimeSettings = runtime.getSettings(); + if (runtimeSettings != null) { + return runtimeSettings.getExperimentDelegate(); + } + } + Log.w(LOGTAG, "Could not retrieve experiment delegate from runtime."); + return null; + } + + /** + * Sets the experiment delegate for this session. Default is set to the runtime experiment + * delegate. + * + * @param delegate An instance of {@link ExperimentDelegate}. + */ + @AnyThread + public void setExperimentDelegate(final @Nullable ExperimentDelegate delegate) { + mExperimentHandler.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; + + /** An error happened while trying to find the print delegate */ + public static final int ERROR_NO_PRINT_DELEGATE = -6; + + @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, + ERROR_NO_PRINT_DELEGATE + }) + 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 <meta> viewport + * tag. All other pages will be rendered using a special desktop mode viewport, which has a width + * of 980 CSS px. + */ + public static final int VIEWPORT_MODE_MOBILE = 0; + + /** + * All pages will be rendered using the special desktop mode viewport, which has a width of 980 + * CSS px, regardless of whether the page has a <meta> viewport tag specified or not. + */ + public static final int VIEWPORT_MODE_DESKTOP = 1; + + public static class Key<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..74eccaeb15 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java @@ -0,0 +1,1246 @@ +/* -*- 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.DragEvent; +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(); + } + + 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() { + GeckoView.this.postInvalidateOnAnimation(); + } + }); + + 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.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); + } + }); + } + + /** Handle drag and drop event */ + @Override + public boolean onDragEvent(final DragEvent event) { + if (mSession == null) { + return false; + } + return mSession.getPanZoomController().onDragEvent(event); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.java new file mode 100644 index 0000000000..97b14f628d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewInputStream.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.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.Channels; +import java.nio.channels.ReadableByteChannel; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** This class provides a Gecko nsIInputStream wrapper for a Java {@link InputStream}. */ +@WrapForJNI +@AnyThread +/* package */ class GeckoViewInputStream { + private static final String LOGTAG = "GeckoViewInputStream"; + private static final int BUFFER_SIZE = 4096; + + protected final ByteBuffer mBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE); + private ReadableByteChannel mChannel; + private InputStream mIs = null; + private boolean mMustGetData = true; + private int mPos = 0; + private int mSize; + + /** + * Set an input stream. + * + * @param is the {@link InputStream} to set. + */ + protected void setInputStream(final @NonNull InputStream is) { + mIs = is; + mChannel = Channels.newChannel(is); + } + + /** + * Check if there is a stream. + * + * @return true if there is no stream. + */ + public boolean isClosed() { + return mIs == null; + } + + /** + * Called by native code to get the number of available bytes in the underlying stream. + * + * @return the number of available bytes. + */ + public int available() { + if (mIs == null || mSize == -1) { + return 0; + } + try { + return Math.max(mIs.available(), mMustGetData ? 0 : mSize - mPos); + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot get the number of available bytes", e); + return 0; + } + } + + /** Close the underlying stream. */ + public void close() { + if (mIs == null) { + return; + } + try { + mChannel.close(); + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot close the channel", e); + } finally { + mChannel = null; + } + + try { + mIs.close(); + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot close the stream", e); + } finally { + mIs = null; + } + } + + /** + * Called by native code to notify that the data have been consumed. + * + * @param length the number of consumed bytes. + * @return the position in the buffer. + */ + public long consumedData(final int length) { + mPos += length; + if (mPos >= mSize) { + mPos = 0; + mMustGetData = true; + } + return mPos; + } + + /** + * Check that the underlying stream starts with one of the given headers. + * + * @param headers the headers to check. + * @return true if one of the headers is found. + */ + protected boolean checkHeaders(final @NonNull byte[][] headers) throws IOException { + read(0); + for (final byte[] header : headers) { + final int headerLength = header.length; + if (mSize < headerLength) { + continue; + } + int i = 0; + for (; i < headerLength; i++) { + if (mBuffer.get(i) != header[i]) { + break; + } + } + if (i == headerLength) { + return true; + } + } + return false; + } + + /** + * Called by native code to read some bytes in the underlying stream. + * + * @param aCount the number of bytes to read. + * @return the number of read bytes, -1 in case of EOF. + * @throws IOException if an error occurs. + */ + @WrapForJNI(exceptionMode = "nsresult") + public int read(final long aCount) throws IOException { + if (mIs == null) { + Log.e(LOGTAG, "The stream is closed."); + throw new IllegalStateException("Stream is closed"); + } + + if (!mMustGetData) { + return (int) Math.min((long) (mSize - mPos), aCount); + } + + mMustGetData = false; + mBuffer.clear(); + + try { + mSize = mChannel.read(mBuffer); + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot read some bytes", e); + throw e; + } + if (mSize == -1) { + return -1; + } + + return (int) Math.min((long) mSize, aCount); + } +} 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..806343a637 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoViewPrintDocumentAdapter.java @@ -0,0 +1,233 @@ +/* -*- 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 static final String PRINT_NAME_DEFAULT = "Document"; + private String mPrintName = PRINT_NAME_DEFAULT; + private File mPdfFile; + private GeckoResult<File> mGeneratedPdfFile; + 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.mDoDeleteTmpPdf = true; + this.mGeneratedPdfFile = pdfInputStreamToFile(pdfInputStream, context); + } + + /** + * 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.mDoDeleteTmpPdf = true; + this.mGeneratedPdfFile = pdfInputStreamToFile(pdfInputStream, context); + 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; + } + + /** + * Utility to make a PDF file from the input stream in the background. + * + * @param pdfInputStream - InputStream containing a PDF + * @param context context that should be used for making a temporary file + * @return gecko result with the file + */ + private @NonNull GeckoResult<File> pdfInputStreamToFile( + final @NonNull InputStream pdfInputStream, final @NonNull Context context) { + final GeckoResult<File> result = new GeckoResult<>(); + ThreadUtils.postToBackgroundThread( + () -> { + result.complete(makeTempPdfFile(pdfInputStream, context)); + }); + return result; + } + + @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); + } + + /** + * Handles onWrite functionality. Recommend running on a background thread as onWrite is on the + * main thread. + * + * @param pdfFile - PDF file to generate print preview with. + * @param parcelFileDescriptor - onWrite parcelFileDescriptor + * @param writeResultCallback - onWrite writeResultCallback + */ + private void onWritePdf( + final @Nullable File pdfFile, + final @NonNull ParcelFileDescriptor parcelFileDescriptor, + final @NonNull WriteResultCallback writeResultCallback) { + InputStream input = null; + OutputStream output = null; + try { + input = new FileInputStream(pdfFile); + 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 onWrite( + final PageRange[] pageRanges, + final ParcelFileDescriptor parcelFileDescriptor, + final CancellationSignal cancellationSignal, + final WriteResultCallback writeResultCallback) { + + ThreadUtils.postToBackgroundThread( + () -> { + if (mGeneratedPdfFile != null) { + mGeneratedPdfFile.then( + file -> { + if (mPrintName == PRINT_NAME_DEFAULT) { + mPrintName = file.getName(); + } + onWritePdf(file, parcelFileDescriptor, writeResultCallback); + return null; + }); + } else { + onWritePdf(mPdfFile, parcelFileDescriptor, writeResultCallback); + } + }); + } + + @Override + public void onFinish() { + // Remove the temporary file when the printing system is finished. + try { + if (mDoDeleteTmpPdf) { + if (mPdfFile != null) { + mPdfFile.delete(); + } + if (mGeneratedPdfFile != null) { + mGeneratedPdfFile.then( + file -> { + file.delete(); + return null; + }); + } + } + } 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<WebResponse> result = executor.fetch( + * new WebRequest.Builder("https://example.org/json") + * .header("Accept", "application/json") + * .build()); + * + * result.then(response -> { + * // 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..a662b3a82d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java @@ -0,0 +1,645 @@ +/* -*- 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. + return 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); + } + } + + 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..877e0e34a6 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java @@ -0,0 +1,982 @@ +/* -*- 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.Build; +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; +import android.view.DragEvent; +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.GeckoDragAndDrop; +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(calledFrom = "ui", dispatchTo = "gecko") + private native void handleDragEvent( + int action, long time, float x, float y, GeckoDragAndDrop.DropData data); + + @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); + } + } + + /** + * Process a drag event. + * + * @param event DragEvent to process. + * @return true if this event is accepted. + */ + public boolean onDragEvent(@NonNull final DragEvent event) { + ThreadUtils.assertOnUiThread(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return false; + } + + if (!GeckoDragAndDrop.onDragEvent(event)) { + return false; + } + + mNative.handleDragEvent( + event.getAction(), + SystemClock.uptimeMillis(), + GeckoDragAndDrop.getLocationX(), + GeckoDragAndDrop.getLocationY(), + GeckoDragAndDrop.createDropData(event)); + return true; + } + + 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..2c4e7238be --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java @@ -0,0 +1,746 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.IdentityCredential.AccountSelectorPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt; +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 IdentityCredentialSelectProviderHandler + implements PromptHandler<ProviderSelectorPrompt> { + @Override + public ProviderSelectorPrompt newPrompt(final GeckoBundle info, final Observer observer) { + final GeckoBundle[] providerBundles = info.getBundleArray("providers"); + if (providerBundles == null) { + return null; + } + + final ProviderSelectorPrompt.Provider[] providers = + new ProviderSelectorPrompt.Provider[providerBundles.length]; + + for (int i = 0; i < providerBundles.length; ++i) { + providers[i] = ProviderSelectorPrompt.Provider.fromBundle(providerBundles[i]); + } + + return new ProviderSelectorPrompt(info.getString("id"), providers, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final ProviderSelectorPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onSelectIdentityCredentialProvider(session, prompt); + } + } + + private static final class IdentityCredentialSelectAccountHandler + implements PromptHandler<AccountSelectorPrompt> { + @Override + public AccountSelectorPrompt newPrompt(final GeckoBundle info, final Observer observer) { + final GeckoBundle providerBundle = info.getBundle("accounts"); + if (providerBundle == null) { + return null; + } + final GeckoBundle[] accountBundles = providerBundle.getBundleArray("accounts"); + if (accountBundles == null) { + return null; + } + + final AccountSelectorPrompt.Account[] accounts = + new AccountSelectorPrompt.Account[accountBundles.length]; + + for (int i = 0; i < accountBundles.length; ++i) { + accounts[i] = AccountSelectorPrompt.Account.fromBundle(accountBundles[i]); + } + + final AccountSelectorPrompt.Provider provider = + AccountSelectorPrompt.Provider.fromBundle(providerBundle.getBundle("provider")); + + return new AccountSelectorPrompt(info.getString("id"), accounts, provider, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AccountSelectorPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onSelectIdentityCredentialAccount(session, prompt); + } + } + + private static final class IdentityCredentialShowPrivacyPolicyHandler + implements PromptHandler<PrivacyPolicyPrompt> { + @Override + public PrivacyPolicyPrompt newPrompt(final GeckoBundle info, final Observer observer) { + final String privacyPolicyUrl = info.getString("privacyPolicyUrl"); + final String termsOfServiceUrl = info.getString("termsOfServiceUrl"); + final String providerDomain = info.getString("providerDomain"); + final String host = info.getString("host"); + final String icon = info.getString("icon"); + + return new PrivacyPolicyPrompt( + info.getString("id"), + privacyPolicyUrl, + termsOfServiceUrl, + providerDomain, + host, + icon, + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final PrivacyPolicyPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onShowPrivacyPolicyIdentityCredential(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 IdentityCredentialSelectProviderHandler(), "IdentityCredential:Select:Provider"); + sPromptHandlers.register( + new IdentityCredentialShowPrivacyPolicyHandler(), "IdentityCredential:Show:Policy"); + sPromptHandlers.register( + new IdentityCredentialSelectAccountHandler(), "IdentityCredential:Select:Account"); + 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..de98131908 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java @@ -0,0 +1,331 @@ +/* -*- 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 boolean hasDefault() { + return true; + } + + 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); + } + } + } + + /** + * Used to handle pref-based settings that should not have a default value, so that they will be + * controlled by GeckoView only when they are set. + * + * <p>When no value is set for a PrefWithoutDefault, its value on the GeckoView side is expected + * to be null, and the value set on the Gecko side to stay set to the either the prefs file + * included in the GeckoView build, or the user prefs file created by the xpcshell and mochitest + * test harness. + */ + /* package */ class PrefWithoutDefault<T> extends Pref<T> { + public PrefWithoutDefault(@NonNull final String name) { + super(name, null); + } + + public boolean hasDefault() { + return false; + } + + public @Nullable T get() { + if (!isSet()) { + return null; + } + return super.get(); + } + + public void commit() { + if (!isSet()) { + // Only add to the bundle prefs and + // propagate to Gecko when explicitly set. + return; + } + super.commit(); + } + + private void addToBundle(final GeckoBundle bundle) { + if (!isSet()) { + return; + } + super.addToBundle(bundle); + } + } + + 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 -> { + // Do not reset prefs that don't have a default value + // and are not set. + if (!pref.hasDefault() && !pref.isSet()) { + return; + } + 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) { + if (pref.hasDefault()) { + // We know this is safe. + @SuppressWarnings("unchecked") + final Pref<Object> uncheckedPref = (Pref<Object>) pref; + uncheckedPref.commit(source.readValue(getClass().getClassLoader())); + } else { + // Don't commit PrefWithoutDefault instances where the value read + // from the Parcel is null. + @SuppressWarnings("unchecked") + final PrefWithoutDefault<Object> uncheckedPref = (PrefWithoutDefault<Object>) pref; + final Object sourceValue = source.readValue(getClass().getClassLoader()); + if (sourceValue != null) { + uncheckedPref.commit(sourceValue); + } + } + } + } +} 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..88ed0139df --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java @@ -0,0 +1,884 @@ +/* -*- 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 (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; + return 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(AccessibilityNodeInfo.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 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 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()); + + 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; + 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(); + 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 = "current") + public native boolean 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 (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); + } + + 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); + } + + 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); + } + + 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 = + CollectionInfo.obtain(rowCount, columnCount, isHierarchical, selectionMode); + 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..3d92b11e81 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionPdfFileSaver.java @@ -0,0 +1,99 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.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.startsWith("content://") ? url : 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..079d1c0160 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java @@ -0,0 +1,461 @@ +/* -*- 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.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 int ONE_SHOT = 1; + // START_MONITOR start the monitor for composing character rects. If is is + // updaed, call updateCompositionRects() + @WrapForJNI int START_MONITOR = 2; + // ENDT_MONITOR stops the monitor for composing character rects. + @WrapForJNI 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 int NOTIFY_IME_OF_TOKEN = -3; + @WrapForJNI int NOTIFY_IME_OPEN_VKB = -2; + @WrapForJNI int NOTIFY_IME_REPLY_EVENT = -1; + @WrapForJNI int NOTIFY_IME_OF_FOCUS = 1; + @WrapForJNI int NOTIFY_IME_OF_BLUR = 2; + @WrapForJNI int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8; + @WrapForJNI 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 {} + + int IME_STATE_UNKNOWN = -1; + int IME_STATE_DISABLED = 0; + int IME_STATE_ENABLED = 1; + 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 int IME_FLAG_PRIVATE_BROWSING = 1 << 0; + @WrapForJNI int IME_FLAG_USER_ACTION = 1 << 1; + @WrapForJNI 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); + } + } + + @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> + * @Override + * public Handler getHandler() { + * if (Build.VERSION.SDK_INT >= 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/TranslationsController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TranslationsController.java new file mode 100644 index 0000000000..37e5e7139a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/TranslationsController.java @@ -0,0 +1,1358 @@ +/* -*- 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.StringDef; +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 org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * The translations controller coordinates the session and runtime messaging between GeckoView and + * the translations toolkit. + */ +public class TranslationsController { + private static final boolean DEBUG = false; + private static final String LOGTAG = "TranslationsController"; + + /** + * Runtime translation coordinates runtime messaging between the translations toolkit actor and + * GeckoView. + * + * <p>Performs translations actions that are not dependent on the page. Typical usage is for + * setting preferences, managing downloads, and getting information on language models available. + */ + public static class RuntimeTranslation { + + // Events Dispatched to Toolkit Translations + private static final String ENGINE_SUPPORTED_EVENT = + "GeckoView:Translations:IsTranslationEngineSupported"; + + private static final String PREFERRED_LANGUAGES_EVENT = + "GeckoView:Translations:PreferredLanguages"; + + private static final String MANAGE_MODEL_EVENT = "GeckoView:Translations:ManageModel"; + + private static final String TRANSLATION_INFORMATION_EVENT = + "GeckoView:Translations:TranslationInformation"; + private static final String MODEL_INFORMATION_EVENT = "GeckoView:Translations:ModelInformation"; + + private static final String GET_LANGUAGE_SETTING_EVENT = + "GeckoView:Translations:GetLanguageSetting"; + + private static final String GET_LANGUAGE_SETTINGS_EVENT = + "GeckoView:Translations:GetLanguageSettings"; + + private static final String SET_LANGUAGE_SETTINGS_EVENT = + "GeckoView:Translations:SetLanguageSettings"; + + private static final String GET_SPECIFIED_SITES_SETTINGS_EVENT = + "GeckoView:Translations:GetNeverTranslateSpecifiedSites"; + + private static final String SET_SPECIFIED_SITE_SETTINGS_EVENT = + "GeckoView:Translations:SetNeverTranslateSpecifiedSite"; + + private static final String GET_TRANSLATE_PAIR_DOWNLOAD_SIZE = + "GeckoView:Translations:GetTranslateDownloadSize"; + + /** + * Checks if the device can use the supplied model binary files for translations. + * + * <p>Use to check if translations are ever possible. + * + * @return true if translations are supported on the device, or false if not. + */ + @AnyThread + public static @NonNull GeckoResult<Boolean> isTranslationsEngineSupported() { + if (DEBUG) { + Log.d(LOGTAG, "Requesting if the translations engine supports the device."); + } + return EventDispatcher.getInstance() + .queryBoolean(ENGINE_SUPPORTED_EVENT) + .map( + result -> result, + exception -> + new TranslationsException(TranslationsException.ERROR_ENGINE_NOT_SUPPORTED)); + } + + /** + * Returns the preferred languages of the user in the following order: 1. App languages 2. Web + * requested languages 3. OS language + * + * @return a GeckoResult with a user's preferred language(s) or null or an exception + */ + @AnyThread + public static @NonNull GeckoResult<List<String>> preferredLanguages() { + if (DEBUG) { + Log.d(LOGTAG, "Requesting the user's preferred languages."); + } + return EventDispatcher.getInstance() + .queryBundle(PREFERRED_LANGUAGES_EVENT) + .map( + bundle -> { + try { + final String[] languages = bundle.getStringArray("preferredLanguages"); + if (languages != null) { + return Arrays.asList(languages); + } + } catch (final Exception e) { + Log.w(LOGTAG, "Could not deserialize preferredLanguages: " + e); + return null; + } + return null; + }, + exception -> + new TranslationsException(TranslationsException.ERROR_COULD_NOT_LOAD_LANGUAGES)); + } + + /** + * Manage the language model or models. Options are to download or delete a BCP 47 language or + * all or cache. + * + * <p>Bug 1869404 will add an option for deleting translations model "cache". + * + * @param options contain language, operation, and operation level to perform on the model + * @return the request proceeded as expected or an exception. + */ + @AnyThread + public static @NonNull GeckoResult<Void> manageLanguageModel( + final @NonNull ModelManagementOptions options) { + if (DEBUG) { + Log.d(LOGTAG, "Requesting management of the language model."); + } + return EventDispatcher.getInstance() + .queryVoid(MANAGE_MODEL_EVENT, options.toBundle()) + .map( + result -> result, + exception -> { + final String exceptionData = + ((EventDispatcher.QueryException) exception).data.toString(); + if (exceptionData.contains("COULD_NOT_DELETE")) { + return new TranslationsException( + TranslationsException.ERROR_MODEL_COULD_NOT_DELETE); + } else if (exceptionData.contains("LANGUAGE_REQUIRED")) { + return new TranslationsException( + TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED); + } else if (exceptionData.contains("COULD_NOT_DOWNLOAD")) { + return new TranslationsException( + TranslationsException.ERROR_MODEL_COULD_NOT_DOWNLOAD); + } + return new TranslationsException(TranslationsException.ERROR_UNKNOWN); + }); + } + + /** + * List languages that can be translated to and from. Use is populating language selection. + * + * @return a GeckoResult with a TranslationSupport object with "to" and "from" languages or an + * exception. + */ + @AnyThread + public static @NonNull GeckoResult<TranslationSupport> listSupportedLanguages() { + if (DEBUG) { + Log.d(LOGTAG, "Requesting information on the language options."); + } + return EventDispatcher.getInstance() + .queryBundle(TRANSLATION_INFORMATION_EVENT) + .map( + bundle -> TranslationSupport.fromBundle(bundle), + exception -> + new TranslationsException(TranslationsException.ERROR_COULD_NOT_LOAD_LANGUAGES)); + } + + /** + * When `translate()` is called on a given pair, then the system will downloaded the necessary + * models to complete the translation. This method is to check the exact size of those + * downloads. Typical case is informing the user of the download size for users in a low-data + * mode. + * + * <p>If no download is detected, it will return 0. Note, if the model is not present, this will + * also result in a value of 0 bytes. + * + * @param fromLanguage from BCP 47 code + * @param toLanguage from BCP 47 code + * @return The size of the file size in bytes. If no download is required, will return 0. + */ + @AnyThread + public static @NonNull GeckoResult<Long> checkPairDownloadSize( + @NonNull final String fromLanguage, @NonNull final String toLanguage) { + if (DEBUG) { + Log.d(LOGTAG, "Requesting information on the language pair download size."); + } + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("fromLanguage", fromLanguage); + bundle.putString("toLanguage", toLanguage); + + return EventDispatcher.getInstance() + .queryBundle(GET_TRANSLATE_PAIR_DOWNLOAD_SIZE, bundle) + .map( + resultBundle -> { + return resultBundle.getLong("bytes", 0L); + }); + } + + /** + * Convenience method for {@link #checkPairDownloadSize(String, String)}. + * + * @param pair language pair that will be used by `translate()` + * @return The size of the necessary file size in bytes. If no download is required, will return + * 0. + */ + @AnyThread + public static @NonNull GeckoResult<Long> checkPairDownloadSize( + @NonNull final SessionTranslation.TranslationPair pair) { + return checkPairDownloadSize(pair.fromLanguage, pair.toLanguage); + } + + /** + * Creates a list of all of the available language models, their size for a full download, and + * download state. Expected use is for displaying model state for user management. + * + * @return A GeckoResult with a list of the available language model's and their states or an + * exception. + */ + @AnyThread + public static @NonNull GeckoResult<List<LanguageModel>> listModelDownloadStates() { + if (DEBUG) { + Log.d(LOGTAG, "Requesting information on the language model."); + } + return EventDispatcher.getInstance() + .queryBundle(MODEL_INFORMATION_EVENT) + .map( + bundle -> { + try { + final GeckoBundle[] models = bundle.getBundleArray("models"); + if (models != null) { + final List<LanguageModel> list = new ArrayList<>(); + for (final var item : models) { + list.add(LanguageModel.fromBundle(item)); + } + return list; + } + } catch (final Exception e) { + Log.d(LOGTAG, "Could not deserialize the model states."); + return null; + } + return null; + }, + exception -> + new TranslationsException(TranslationsException.ERROR_MODEL_COULD_NOT_RETRIEVE)); + } + + /** + * Returns the given language setting for the corresponding language. + * + * @param languageCode The BCP 47 language portion of the code to check the settings for. For + * example, es, en, de, etc. + * @return The {@link LanguageSetting} string for the language. + */ + @AnyThread + public static @NonNull GeckoResult<String> getLanguageSetting( + @NonNull final String languageCode) { + if (DEBUG) { + Log.d(LOGTAG, "Requesting language setting for " + languageCode + "."); + } + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("language", languageCode); + return EventDispatcher.getInstance().queryString(GET_LANGUAGE_SETTING_EVENT, bundle); + } + + /** + * Creates a map of known language codes with their corresponding language setting. + * + * @return A GeckoResult with a map of each BCP 47 language portion of the code (key) and its + * corresponding {@link LanguageSetting} string (value). + */ + @AnyThread + public static @NonNull GeckoResult<Map<String, String>> getLanguageSettings() { + if (DEBUG) { + Log.d(LOGTAG, "Requesting language settings."); + } + return EventDispatcher.getInstance() + .queryBundle(GET_LANGUAGE_SETTINGS_EVENT) + .map( + bundle -> { + final Map<String, String> languageSettings = new HashMap<>(); + try { + final GeckoBundle[] fromBundle = bundle.getBundleArray("settings"); + for (final var item : fromBundle) { + final var languageCode = item.getString("langTag"); + final @LanguageSetting String setting = item.getString("setting", "offer"); + if (languageCode != null) { + languageSettings.put(languageCode, setting); + } + } + return languageSettings; + + } catch (final Exception e) { + Log.w( + LOGTAG, + "An issue occurred while deserializing translation language settings: " + e); + } + return null; + }); + } + + /** + * Sets the language state for a given language. + * + * @param languageCode - The specified BCP 47 language portion of the code to update. For + * example, es, en, de, etc. + * @param languageSetting - The specified setting for a given language. + * @return A GeckoResult that will return void if successful or else will complete + * exceptionally. + */ + @AnyThread + public static @NonNull GeckoResult<Void> setLanguageSettings( + final @NonNull String languageCode, + final @NonNull @LanguageSetting String languageSetting) { + if (DEBUG) { + Log.d(LOGTAG, "Requesting setting language setting."); + } + + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("language", languageCode); + bundle.putString("languageSetting", String.valueOf(languageSetting)); + return EventDispatcher.getInstance().queryVoid(SET_LANGUAGE_SETTINGS_EVENT, bundle); + } + + /** + * Gets the list of sites that have a never translate site preference set. Should be used for + * retrieving a list for global preference setting outside of a specific site. + * + * <p>Recommend using: {@link SessionTranslation#getNeverTranslateSiteSetting()} to query the + * current session's site's never translate preferences. + * + * @return A list of display ready site URIs to set preferences for. + */ + @AnyThread + public static @NonNull GeckoResult<List<String>> getNeverTranslateSiteList() { + if (DEBUG) { + Log.d(LOGTAG, "Retrieving specified never translate site settings"); + } + return EventDispatcher.getInstance() + .queryBundle(GET_SPECIFIED_SITES_SETTINGS_EVENT) + .map( + bundle -> { + try { + final String[] neverTranslateSites = bundle.getStringArray("sites"); + if (neverTranslateSites != null) { + return Arrays.asList(neverTranslateSites); + } + } catch (final Exception e) { + Log.d(LOGTAG, "Could not deserialize the sites."); + return null; + } + return null; + }); + } + + /** + * Sets whether the specified site should be translated or not. This function should be used for + * global updates to the never translate list. + * + * <p>Please use: {@link SessionTranslation#setNeverTranslateSiteSetting(Boolean)} when the + * session is currently on the site to adjust the permissions for. + * + * @param origin A site origin URI that will have the specified never translate permission set. + * Recommend using URI values returned from {@link #getNeverTranslateSiteList()} and using + * the session to set a given site to ensure proper scope when possible. + * @param neverTranslate Should be set to true if the site should never be translated or false + * if it should be translated. + * @return Void if the operation to set the value completed or exceptionally if an issue + * occurred. + */ + @AnyThread + public static @NonNull GeckoResult<Void> setNeverTranslateSpecifiedSite( + final @NonNull Boolean neverTranslate, final @NonNull String origin) { + if (DEBUG) { + Log.d(LOGTAG, "Setting never translate for specified site uri origin: " + origin); + } + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBoolean("neverTranslate", neverTranslate); + bundle.putString("origin", origin); + return EventDispatcher.getInstance().queryVoid(SET_SPECIFIED_SITE_SETTINGS_EVENT, bundle); + } + + /** Options for managing the translation language models. */ + @AnyThread + public static class ModelManagementOptions { + /** BCP 47 language or null for global operations. */ + public final @Nullable String language; + + /** Operation to perform on the language model. */ + public final @NonNull @ModelOperation String operation; + + /** Level of operation */ + public final @NonNull @OperationLevel String operationLevel; + + /** + * Options for managing the toolkit provided language model binaries. + * + * @param builder model management options builder + */ + protected ModelManagementOptions( + final @NonNull RuntimeTranslation.ModelManagementOptions.Builder builder) { + this.language = builder.mLanguage; + this.operation = builder.mOperation; + this.operationLevel = builder.mOperationLevel; + } + + /** Serializer for Model Management Options */ + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + if (language != null) { + bundle.putString("language", language); + } + bundle.putString("operation", operation.toString()); + bundle.putString("operationLevel", operationLevel.toString()); + + return bundle; + } + + /** Builder for Model Management Options */ + @AnyThread + public static class Builder { + /* package */ String mLanguage = null; + /* package */ @ModelOperation String mOperation; + /* package */ @OperationLevel String mOperationLevel = ALL; + + /** + * Language builder setter. + * + * @param language that should be managed. No need to set in the case of a global operation + * level. + * @return the language parameter for the constructor + */ + public @NonNull RuntimeTranslation.ModelManagementOptions.Builder languageToManage( + final @NonNull String language) { + mLanguage = language; + return this; + } + + /** + * Operation builder setter. + * + * @param operation that should be performed + * @return the operation parameter for the constructor + */ + public @NonNull RuntimeTranslation.ModelManagementOptions.Builder operation( + final @NonNull @ModelOperation String operation) { + mOperation = operation; + return this; + } + + /** + * Operation level builder setter. + * + * @param operationLevel the level of the operation, e.g., language, all, or cache Default + * is to operate on all. + * @return the operation level parameter for the constructor + */ + public @NonNull RuntimeTranslation.ModelManagementOptions.Builder operationLevel( + final @NonNull @OperationLevel String operationLevel) { + mOperationLevel = operationLevel; + return this; + } + + /** + * Builder for Model Management Options. + * + * @return a constructed ModelManagementOptions populated from builder options + */ + @AnyThread + public @NonNull ModelManagementOptions build() { + return new ModelManagementOptions(this); + } + } + } + + /** Operations toolkit can perform on the language models. */ + @Retention(RetentionPolicy.SOURCE) + @StringDef(value = {DOWNLOAD, DELETE}) + public @interface ModelOperation {} + + /** The download operation is for downloading models. */ + public static final String DOWNLOAD = "download"; + + /** The delete operation is for deleting models. */ + public static final String DELETE = "delete"; + + /** Operation type for toolkit to operate on. */ + @Retention(RetentionPolicy.SOURCE) + @StringDef(value = {LANGUAGE, CACHE, ALL}) + public @interface OperationLevel {} + + /** + * The language type indicates the operation should be performed only on the specified language. + */ + public static final String LANGUAGE = "language"; + + /** + * The cache type indicates that the operation should be performed on model files that do not + * make up a suit. + */ + public static final String CACHE = "cache"; + + /** The all type indicates that the operation should be performed on all model files */ + public static final String ALL = "all"; + + /** Language translation options. */ + public static class TranslationSupport { + /** Languages we can translate from. */ + public final @Nullable List<Language> fromLanguages; + + /** Languages we can translate to. */ + public final @Nullable List<Language> toLanguages; + + /** + * Construction for translation support, will usually be constructed from deserialize toolkit + * information. + * + * @param fromLanguages list of from languages to list as translation options + * @param toLanguages list of to languages to list as translation options + */ + public TranslationSupport( + @Nullable final List<Language> fromLanguages, + @Nullable final List<Language> toLanguages) { + this.fromLanguages = fromLanguages; + this.toLanguages = toLanguages; + } + + @Override + public String toString() { + return "TranslationSupport {" + + "fromLanguages=" + + fromLanguages + + ", toLanguages=" + + toLanguages + + '}'; + } + + /** + * Convenience method for deserializing support information. + * + * @param bundle contains language support information + * @return support object + */ + /* package */ + static @Nullable TranslationSupport fromBundle(final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + final List<Language> fromLanguages = new ArrayList<>(); + final List<Language> toLanguages = new ArrayList<>(); + try { + final GeckoBundle[] fromBundle = bundle.getBundleArray("fromLanguages"); + for (final var item : fromBundle) { + final var result = Language.fromBundle(item); + if (result != null) { + fromLanguages.add(result); + } + } + + final GeckoBundle[] toBundle = bundle.getBundleArray("toLanguages"); + for (final var item : toBundle) { + final var result = Language.fromBundle(item); + if (result != null) { + toLanguages.add(result); + } + } + } catch (final Exception e) { + Log.w( + LOGTAG, + "An issue occurred while deserializing translation support information: " + e); + } + + return new TranslationSupport(fromLanguages, toLanguages); + } + } + + /** Information about a language model. */ + public static class LanguageModel { + /** Display language. */ + public final @Nullable Language language; + + /** Model download state */ + public final @NonNull Boolean isDownloaded; + + /** Size in bytes for displaying download information. */ + public final long size; + + /** + * Constructor for the language model. + * + * @param language the language the model is for. + * @param isDownloaded if the model is currently downloaded or not. + * @param size the size in bytes of the model. + */ + public LanguageModel( + @Nullable final Language language, final Boolean isDownloaded, final long size) { + this.language = language; + this.isDownloaded = isDownloaded; + this.size = size; + } + + @Override + public String toString() { + return "LanguageModel {" + + "language=" + + language + + ", isDownloaded=" + + isDownloaded + + ", size=" + + size + + '}'; + } + + /** + * Convenience method for deserializing language model information. + * + * @param bundle contains language model information + * @return language object + */ + /* package */ + static @Nullable LanguageModel fromBundle(final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + try { + final var language = Language.fromBundle(bundle); + final var isDownloaded = bundle.getBoolean("isDownloaded"); + final var size = bundle.getLong("size"); + return new LanguageModel(language, isDownloaded, size); + } catch (final Exception e) { + Log.w(LOGTAG, "Could not deserialize LanguageModel object: " + e); + return null; + } + } + } + + /** + * The runtime language settings a given language may have that dictates the app's translation + * offering behavior. + */ + @Retention(RetentionPolicy.SOURCE) + @StringDef(value = {ALWAYS, OFFER, NEVER}) + public @interface LanguageSetting {} + + /** + * The translations engine should always expect this language to be translated and automatically + * translate on page load. + */ + public static final String ALWAYS = "always"; + + /** + * The translations engine should offer this language to be translated. This is the default + * state, i.e., no user selection was made. + */ + public static final String OFFER = "offer"; + + /** The translations engine should never offer to translate this language. */ + public static final String NEVER = "never"; + } + + /** + * Session translation coordinates session messaging between the translations toolkit actor and + * GeckoView. + * + * <p>Performs translations actions that are dependent on the page. + */ + public static class SessionTranslation { + + // Events Dispatched to Toolkit Translations + private static final String TRANSLATE_EVENT = "GeckoView:Translations:Translate"; + private static final String RESTORE_PAGE_EVENT = "GeckoView:Translations:RestorePage"; + + private static final String GET_NEVER_TRANSLATE_SITE = + "GeckoView:Translations:GetNeverTranslateSite"; + + private static final String SET_NEVER_TRANSLATE_SITE = + "GeckoView:Translations:SetNeverTranslateSite"; + + // Events Dispatched from Toolkit Translations + private static final String ON_OFFER_EVENT = "GeckoView:Translations:Offer"; + private static final String ON_STATE_CHANGE_EVENT = "GeckoView:Translations:StateChange"; + + private final GeckoSession mSession; + private final SessionTranslation.Handler mHandler; + + /** + * Construct a new translations session. + * + * @param session that will be dispatching and receiving events. + */ + public SessionTranslation(final GeckoSession session) { + mSession = session; + mHandler = new SessionTranslation.Handler(mSession); + } + + /** + * Handler for receiving messages about translations. + * + * @return associated session handler + */ + @AnyThread + public @NonNull Handler getHandler() { + return mHandler; + } + + /** + * Translates the session's current page based on given language and criteria specified in the + * options. + * + * @param fromLanguage BCP 47 language tag that the page should be translated from. Usually will + * be the suggested detected language or user specified. + * @param toLanguage BCP 47 language tag that the page should be translated to. Usually will be + * the suggested preference language or user specified. + * @param options If downloadModel is set to true, then any background downloads will occur + * automatically. If downloadModel is set to false, then if any background downloads are + * required, then the request will fail with an exception, but will continue if the model is + * already present. + * @return Void if the translate process begins or exceptionally if an issue occurs. + */ + @AnyThread + public @NonNull GeckoResult<Void> translate( + @NonNull final String fromLanguage, + @NonNull final String toLanguage, + @Nullable final TranslationOptions options) { + if (DEBUG) { + Log.d( + LOGTAG, + "Translate page requested - fromLanguage: " + + fromLanguage + + " toLanguage: " + + toLanguage + + " options: " + + options); + } + + if (options != null && options.downloadModel == false) { + final var translateResult = new GeckoResult<Void>(); + TranslationsController.RuntimeTranslation.checkPairDownloadSize(fromLanguage, toLanguage) + .then( + (GeckoResult.OnValueListener<Long, Void>) + downloadBytes -> { + if (downloadBytes > 0) { + translateResult.completeExceptionally( + new TranslationsException( + TranslationsException.ERROR_MODEL_DOWNLOAD_REQUIRED)); + } else { + // No download required + translateResult.completeFrom(this.baseTranslate(fromLanguage, toLanguage)); + } + return null; + }); + return translateResult; + } + + return this.baseTranslate(fromLanguage, toLanguage); + } + + /** + * Convenience method for calling {@link #translate(String, String, TranslationOptions)} with a + * translation pair. + * + * @param translationPair the object with a from and to language + * @param options If downloadModel is set to true, then any background downloads will occur + * automatically. If downloadModel is set to false, then if any background downloads are + * required, then the request will fail, but will continue if the model is already present. + * @return Void if the translate process begins or exceptionally if an issue occurs. + */ + @AnyThread + public @NonNull GeckoResult<Void> translate( + @NonNull final TranslationPair translationPair, + @Nullable final TranslationOptions options) { + return translate(translationPair.fromLanguage, translationPair.toLanguage, options); + } + + /** + * This will complete a translation using defaults. Before translating, any required models will + * be downloaded by the toolkit engine. + * + * @param fromLanguage BCP 47 language tag that the page should be translated from. Usually will + * be the suggested detected language or user specified. + * @param toLanguage BCP 47 language tag that the page should be translated to. Usually will be + * the suggested preference language or user specified. + * @return Void if the translate process begins or exceptionally if an issue occurs. + */ + @AnyThread + private @NonNull GeckoResult<Void> baseTranslate( + @NonNull final String fromLanguage, @NonNull final String toLanguage) { + + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("fromLanguage", fromLanguage); + bundle.putString("toLanguage", toLanguage); + return mSession + .getEventDispatcher() + .queryVoid(TRANSLATE_EVENT, bundle) + .map( + result -> result, + exception -> + new TranslationsException(TranslationsException.ERROR_COULD_NOT_TRANSLATE)); + } + + /** + * Restores a page to the original or pre-translated state. + * + * @return if page restoration process begins or exceptionally if an issue occurs. + */ + @AnyThread + public @NonNull GeckoResult<Void> restoreOriginalPage() { + if (DEBUG) { + Log.d(LOGTAG, "Restore translated page requested"); + } + return mSession + .getEventDispatcher() + .queryVoid(RESTORE_PAGE_EVENT) + .map( + result -> result, + exception -> + new TranslationsException(TranslationsException.ERROR_COULD_NOT_RESTORE)); + } + + /** + * Gets the setting of the site for whether it should be translated or not. + * + * @return The site setting for the page or exceptionally if an issue occurs. + */ + @AnyThread + public @NonNull GeckoResult<Boolean> getNeverTranslateSiteSetting() { + if (DEBUG) { + Log.d(LOGTAG, "Retrieving never translate site setting."); + } + return mSession.getEventDispatcher().queryBoolean(GET_NEVER_TRANSLATE_SITE); + } + + /** + * Sets whether the site should be translated or not. + * + * @param neverTranslate Should be set to true if the site should never be translated or false + * if it should be translated. + * @return Void if the operation to set the value completed or exceptionally if an issue + * occurred. + */ + @AnyThread + public @NonNull GeckoResult<Void> setNeverTranslateSiteSetting( + final @NonNull Boolean neverTranslate) { + if (DEBUG) { + Log.d(LOGTAG, "Setting never translate site."); + } + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBoolean("neverTranslate", neverTranslate); + return mSession.getEventDispatcher().queryVoid(SET_NEVER_TRANSLATE_SITE, bundle); + } + + /** + * Options available for translating. + * + * <p>Options (default): + * + * <p>downloadModel (true) - Downloads any models automatically that are needed for translation. + */ + @AnyThread + public static class TranslationOptions { + /** If the model should be automatically downloaded or stopped. */ + public final @NonNull boolean downloadModel; + + /** + * Options for translation. + * + * @param builder that populated the translation options + */ + protected TranslationOptions(final @NonNull Builder builder) { + this.downloadModel = builder.mDownloadModel; + } + + /** Builder for making translation options. */ + @AnyThread + public static class Builder { + /* package */ boolean mDownloadModel = true; + + /** + * Build setter for the option for downloading a model. + * + * @param downloadModel should the model be automatically download or not + * @return the model to download for the translation options + */ + public @NonNull Builder downloadModel(final @NonNull boolean downloadModel) { + mDownloadModel = downloadModel; + return this; + } + + /** + * Final call to build the specified options. + * + * @return a constructed translation options + */ + @AnyThread + public @NonNull TranslationOptions build() { + return new TranslationOptions(this); + } + } + } + + /** + * The translations session delegate is used for receiving translation events and information. + */ + @AnyThread + public interface Delegate { + /** + * onOfferTranslate occurs when a page should be offered for translation. + * + * <p>An offer should occur when all conditions are met: + * + * <p>* The page is not in the user's preferred language + * + * <p>* The page language is eligible for translation + * + * <p>* The host hasn't been offered for translation in this session + * + * <p>* No user preferences indicate that translation shouldn't be offered + * + * <p>* It is possible to translate + * + * <p>Usual use-case is to show a pop-up recommending a translation. + * + * @param session The associated GeckoSession. + */ + default void onOfferTranslate(@NonNull final GeckoSession session) {} + + /** + * onExpectedTranslate occurs when it is likely the user will want to translate and it is + * feasible. For example, if the page is in a different language than the user preferred + * language or languages. + * + * <p>Usual use-case is to add a toolbar option for translate. + * + * @param session The associated GeckoSession. + */ + default void onExpectedTranslate(@NonNull final GeckoSession session) {} + + /** + * onTranslationStateChange occurs when new information about the translation state is + * available. This includes information when first visiting the page and after calls to + * translate. + * + * @param session The associated GeckoSession. + * @param translationState The state of the translation as reported by the translation engine. + */ + default void onTranslationStateChange( + @NonNull final GeckoSession session, @Nullable TranslationState translationState) {} + } + + /** Translation pair is the from language and to language set on the translation state. */ + public static class TranslationPair { + /** Language the page is translated from originally. */ + public final @Nullable String fromLanguage; + + /** Language the page is translated to. */ + public final @Nullable String toLanguage; + + /** + * Requested translation pair constructor. + * + * @param fromLanguage original language of page (detected or specified) + * @param toLanguage translated to language of page (detected or specified) + */ + public TranslationPair( + @Nullable final String fromLanguage, @Nullable final String toLanguage) { + this.fromLanguage = fromLanguage; + this.toLanguage = toLanguage; + } + + @Override + public String toString() { + return "TranslationPair {" + + "fromLanguage='" + + fromLanguage + + '\'' + + ", toLanguage='" + + toLanguage + + '\'' + + '}'; + } + + /** + * Convenience method for deserializing translation state information. + * + * @param bundle contains translation pair information. + * @return translation pair + */ + /* package */ + static @Nullable TranslationPair fromBundle(final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + return new TranslationPair( + bundle.getString("fromLanguage"), bundle.getString("toLanguage")); + } + } + + /** DetectedLanguages is information that was detected about the page or user preferences. */ + public static class DetectedLanguages { + + /** The user's preferred language tag */ + public final @Nullable String userLangTag; + + /** If the engine supports the document language. */ + public final @NonNull Boolean isDocLangTagSupported; + + /** Detected language tag of page. */ + public final @Nullable String docLangTag; + + /** + * DetectedLanguages constructor. + * + * @param userLangTag - the user's preferred language tag + * @param isDocLangTagSupported - if the engine supports the document language for translation + * @param docLangTag - the document's detected language tag + */ + public DetectedLanguages( + @Nullable final String userLangTag, + @NonNull final Boolean isDocLangTagSupported, + @Nullable final String docLangTag) { + this.userLangTag = userLangTag; + this.isDocLangTagSupported = isDocLangTagSupported; + this.docLangTag = docLangTag; + } + + @Override + public String toString() { + return "DetectedLanguages {" + + "userLangTag='" + + userLangTag + + '\'' + + ", isDocLangTagSupported=" + + isDocLangTagSupported + + ", docLangTag='" + + docLangTag + + '\'' + + '}'; + } + + /** + * Convenience method for deserializing detected language state information. + * + * @param bundle contains detected language information. + * @return detected language information + */ + /* package */ + static @Nullable DetectedLanguages fromBundle(final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + return new DetectedLanguages( + bundle.getString("userLangTag"), + bundle.getBoolean("isDocLangTagSupported", false), + bundle.getString("docLangTag")); + } + } + + /** The representation of the translation state. */ + public static class TranslationState { + /** The language pair to translate. */ + public final @Nullable TranslationPair requestedTranslationPair; + + /** If an error state occurred. */ + public final @Nullable String error; + + /** Detected information about preferences and page information. */ + public final @Nullable DetectedLanguages detectedLanguages; + + /** If the translation engine is ready for use or will need to be loaded. */ + public final @NonNull Boolean isEngineReady; + + /** + * Translation State constructor. + * + * @param requestedTranslationPair the language pair to translate + * @param error if an error occurred + * @param detectedLanguages detected language + * @param isEngineReady if the engine is ready for translations + */ + public TranslationState( + final @Nullable TranslationPair requestedTranslationPair, + final @Nullable String error, + final @Nullable DetectedLanguages detectedLanguages, + final @NonNull Boolean isEngineReady) { + this.requestedTranslationPair = requestedTranslationPair; + this.error = error; + this.detectedLanguages = detectedLanguages; + this.isEngineReady = isEngineReady; + } + + @Override + public String toString() { + return "TranslationState {" + + "requestedTranslationPair=" + + requestedTranslationPair + + ", error='" + + error + + '\'' + + ", detectedLanguages=" + + detectedLanguages + + ", isEngineReady=" + + isEngineReady + + '}'; + } + + /** + * Convenience method for deserializing translation state information. + * + * @param bundle contains information about translation state. + * @return translation state + */ + /* package */ + static @Nullable TranslationState fromBundle(final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + return new TranslationState( + TranslationPair.fromBundle(bundle.getBundle("requestedTranslationPair")), + bundle.getString("error"), + DetectedLanguages.fromBundle(bundle.getBundle("detectedLanguages")), + bundle.getBoolean("isEngineReady", false)); + } + } + + /* package */ static class Handler extends GeckoSessionHandler<SessionTranslation.Delegate> { + + private final GeckoSession mSession; + + private Handler(final GeckoSession session) { + super( + "GeckoViewTranslations", + session, + new String[] { + ON_OFFER_EVENT, ON_STATE_CHANGE_EVENT, + }); + mSession = 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 (delegate == null) { + Log.w(LOGTAG, "The translations session delegate is not set."); + return; + } + if (ON_OFFER_EVENT.equals(event)) { + delegate.onOfferTranslate(mSession); + return; + } else if (ON_STATE_CHANGE_EVENT.equals(event)) { + final GeckoBundle data = message.getBundle("data"); + final TranslationState translationState = TranslationState.fromBundle(data); + if (DEBUG) { + Log.d(LOGTAG, "received translation state: " + translationState); + } + delegate.onTranslationStateChange(mSession, translationState); + if (translationState != null + && translationState.detectedLanguages != null + && translationState.detectedLanguages.docLangTag != null + && translationState.detectedLanguages.userLangTag != null + && translationState.detectedLanguages.isDocLangTagSupported) { + TranslationsController.RuntimeTranslation.isTranslationsEngineSupported() + .then( + (GeckoResult.OnValueListener<Boolean, Void>) + value -> { + if (value) { + delegate.onExpectedTranslate(mSession); + } + return null; + }); + return; + } + } + } + } + } + + /** Language display information. */ + public static class Language implements Comparable<Language> { + /** Language BCP 47 code. */ + public final @NonNull String code; + + /** Language localized display name. */ + public final @Nullable String localizedDisplayName; + + /** + * Language constructor. + * + * @param code BCP 47 language code + * @param localizedDisplayName how the language should be referred to in the UI. + */ + public Language(@NonNull final String code, @Nullable final String localizedDisplayName) { + this.code = code; + this.localizedDisplayName = localizedDisplayName; + } + + @Override + public String toString() { + if (localizedDisplayName != null) { + return localizedDisplayName; + } + return code; + } + + /** + * Comparator for sorting language objects is based on alphabetizing display language {@link + * #localizedDisplayName}. + * + * @param otherLanguage other language being compared + * @return 1 if this object is earlier, 0 if equal, -1 if this object should be later for + * sorting + */ + @Override + @AnyThread + public int compareTo(@Nullable final Language otherLanguage) { + return this.localizedDisplayName.compareTo(otherLanguage.localizedDisplayName); + } + + /** + * Equality checker for language objects is based on BCP 47 code equality {@link #code}. + * + * @param otherLanguage other language being compared + * @return true if the BCP 47 codes match, false if they do not + */ + @Override + public boolean equals(@Nullable final Object otherLanguage) { + if (otherLanguage instanceof Language) { + return this.code.equals(((Language) otherLanguage).code); + } + return false; + } + + /** + * Required for overriding equals. + * + * @return object hash. + */ + @Override + public int hashCode() { + return Objects.hash(code); + } + + /** + * Convenience method for deserializing language information. + * + * @param bundle contains language information + * @return language for display + */ + /* package */ + static @Nullable Language fromBundle(final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + try { + final String code = bundle.getString("langTag", ""); + if (code.equals("")) { + Log.w(LOGTAG, "Deserialized an empty language code."); + } + return new Language(code, bundle.getString("displayName")); + } catch (final Exception e) { + Log.w(LOGTAG, "Could not deserialize language object: " + e); + return null; + } + } + } + + /** + * An exception to be used when there is an issue retrieving or sending information to the + * translations toolkit engine. + */ + public static class TranslationsException extends Exception { + + /** + * Construct a [TranslationsException] + * + * @param code Error code the given exception corresponds to. + */ + public TranslationsException(final @Code int code) { + this.code = code; + } + + /** Default error for unexpected issues. */ + public static final int ERROR_UNKNOWN = -1; + + /** Translations engine does not work on the device architecture. */ + public static final int ERROR_ENGINE_NOT_SUPPORTED = -2; + + /** Generic could not compete a translation error. */ + public static final int ERROR_COULD_NOT_TRANSLATE = -3; + + /** Generic could not restore the page after a translation error. */ + public static final int ERROR_COULD_NOT_RESTORE = -4; + + /** Could not load language options error. */ + public static final int ERROR_COULD_NOT_LOAD_LANGUAGES = -5; + + /** The language is not supported for translation. */ + public static final int ERROR_LANGUAGE_NOT_SUPPORTED = -6; + + /** Could not retrieve information on the language model. */ + public static final int ERROR_MODEL_COULD_NOT_RETRIEVE = -7; + + /** Could not delete the language model. */ + public static final int ERROR_MODEL_COULD_NOT_DELETE = -8; + + /** Could not download the language model. */ + public static final int ERROR_MODEL_COULD_NOT_DOWNLOAD = -9; + + /** A language is required for language scoped requests. */ + public static final int ERROR_MODEL_LANGUAGE_REQUIRED = -10; + + /** A download is required and the translate request specified do not download. */ + public static final int ERROR_MODEL_DOWNLOAD_REQUIRED = -11; + + /** Translation exception error codes. */ + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + ERROR_UNKNOWN, + ERROR_ENGINE_NOT_SUPPORTED, + ERROR_COULD_NOT_TRANSLATE, + ERROR_COULD_NOT_RESTORE, + ERROR_COULD_NOT_LOAD_LANGUAGES, + ERROR_LANGUAGE_NOT_SUPPORTED, + ERROR_MODEL_COULD_NOT_RETRIEVE, + ERROR_MODEL_COULD_NOT_DELETE, + ERROR_MODEL_COULD_NOT_DOWNLOAD, + ERROR_MODEL_LANGUAGE_REQUIRED, + ERROR_MODEL_DOWNLOAD_REQUIRED + }) + public @interface Code {} + + /** {@link Code} that provides more information about this exception. */ + public final @Code int code; + + @Override + public String toString() { + return "TranslationsException: " + code; + } + } +} 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..6fae35f320 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java @@ -0,0 +1,598 @@ +/* -*- 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 dom/webauthn/WebAuthnTransportIdentifiers.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 byte AUTHENTICATOR_TRANSPORT_INTERNAL = 8; + + 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); + } + if ((transports & AUTHENTICATOR_TRANSPORT_INTERNAL) == AUTHENTICATOR_TRANSPORT_INTERNAL) { + result.add(Transport.INTERNAL); + } + 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 final String[] transports; + + public MakeCredentialResponse( + final byte[] clientDataJson, + final byte[] keyHandle, + final byte[] attestationObject, + final String[] transports) { + this.clientDataJson = clientDataJson; + this.keyHandle = keyHandle; + this.attestationObject = attestationObject; + this.transports = transports; + } + } + + 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", ""), + /* deprecated userIcon field */ "", + 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", ""), + /* deprecated rpIcon field */ ""); + + 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)); + + Log.d( + LOGTAG, "transports: " + String.join(", ", responseData.getTransports())); + + result.complete( + new WebAuthnTokenManager.MakeCredentialResponse( + responseData.getClientDataJSON(), + responseData.getKeyHandle(), + responseData.getAttestationObject(), + responseData.getTransports())); + } + }, + 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..d553a1aa3f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java @@ -0,0 +1,2894 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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 => { + * 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 is blocklisted. */ + public static final int ERROR_BLOCKLISTED = -10; + + /** The extension is incompatible. */ + public static final int ERROR_INCOMPATIBLE = -11; + + /** The extension type is not supported by the platform. */ + public static final int ERROR_UNSUPPORTED_ADDON_TYPE = -12; + + /** 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 + && bundle.getBoolean("cancelledByUser")) { + 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_BLOCKLISTED, + ErrorCodes.ERROR_INCOMPATIBLE, + ErrorCodes.ERROR_USER_CANCELED, + ErrorCodes.ERROR_POSTPONED, + ErrorCodes.ERROR_UNSUPPORTED_ADDON_TYPE, + }) + public @interface Codes {} + + /** One of {@link ErrorCodes} that provides more information about this exception. */ + public final @Codes int code; + + /** An optional name of the extension that caused the exception. */ + public final @Nullable String extensionName; + + /** For testing */ + protected InstallException() { + this.code = ErrorCodes.ERROR_NETWORK_FAILURE; + this.extensionName = null; + } + + @Override + public String toString() { + return "InstallException: " + code; + } + + /* package */ InstallException(final @Codes int code, final @Nullable String extensionName) { + this.code = code; + this.extensionName = extensionName; + } + + /* package */ InstallException(final @Codes int code) { + this.code = code; + this.extensionName = null; + } + } + + /** + * 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; + + /** The extension has been disabled because it is not correctly signed. */ + public static final int SIGNATURE = 1 << 4; + + /** + * The extension has been disabled because it is not compatible with the application version. + */ + public static final int APP_VERSION = 1 << 5; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + DisabledFlags.USER, + DisabledFlags.BLOCKLIST, + DisabledFlags.APP, + DisabledFlags.SIGNATURE, + DisabledFlags.APP_VERSION, + }) + 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; + + /** The full description of this extension. See: `AddonWrapper.fullDescription`. */ + public final @Nullable String fullDescription; + + /** The average rating of this extension. See: `AddonWrapper.averageRating`. */ + public final double averageRating; + + /** The review count for this extension. See: `AddonWrapper.reviewCount`. */ + public final int reviewCount; + + /** The link to the review page for this extension. See `AddonWrapper.reviewURL`. */ + public final @Nullable String reviewUrl; + + /** + * The string representation of the date that this extension was most recently updated + * (simplified ISO 8601 format). See `AddonWrapper.updateDate`. + */ + public final @Nullable String updateDate; + + /** The URL used to install this extension. See: `AddonInternal.sourceURI`. */ + public final @Nullable String downloadUrl; + + /** + * 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 + * & DisabledFlags.USER > 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; + + /** The link to the AMO detail page for this extension. See `AddonWrapper.amoListingURL`. */ + public final @Nullable String amoListingUrl; + + /** + * Indicates how the extension works with private browsing windows. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/incognito"> + * manifest.json/incognito </a> + */ + public final @Nullable String incognito; + + /** 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; + fullDescription = null; + averageRating = 0; + reviewCount = 0; + reviewUrl = null; + updateDate = null; + downloadUrl = null; + amoListingUrl = null; + incognito = null; + } + + /* 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); + fullDescription = bundle.getString("fullDescription"); + averageRating = bundle.getDouble("averageRating"); + reviewCount = bundle.getInt("reviewCount"); + reviewUrl = bundle.getString("reviewURL"); + updateDate = bundle.getString("updateDate"); + downloadUrl = bundle.getString("downloadUrl"); + amoListingUrl = bundle.getString("amoListingURL"); + incognito = bundle.getString("incognito"); + + 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 if (flag.equals("signatureDisabled")) { + disabledFlags |= DisabledFlags.SIGNATURE; + } else if (flag.equals("appVersionDisabled")) { + disabledFlags |= DisabledFlags.APP_VERSION; + } 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"); + + return new Builder(mainRequest) + .filename(optionsBundle.getString("filename")) + .downloadFlags(downloadFlags) + .conflictAction(conflictActionFlags) + .saveAs(saveAs) + .allowHttpErrors(allowHttpErrors) + .build(); + } + + /* 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..7e936f84f7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java @@ -0,0 +1,1752 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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.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.StringDef; +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; +import org.mozilla.geckoview.WebExtension.InstallException; + +public class WebExtensionController { + private static final String LOGTAG = "WebExtension"; + + private AddonManagerDelegate mAddonManagerDelegate; + private ExtensionProcessDelegate mExtensionProcessDelegate; + 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); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Get", bundle) + .map( + extensionBundle -> { + final WebExtension ext = mObserver.onNewExtension(extensionBundle); + mData.put(ext.id, ext); + return ext; + }); + } + + 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) {} + + /** + * Called whenever an error happened when installing a WebExtension. + * + * @param extension {@link WebExtension} which failed to be installed. + * @param installException {@link InstallException} indicates which type of error happened. + */ + @UiThread + default void onInstallationFailed( + final @Nullable WebExtension extension, final @NonNull InstallException installException) {} + + /** + * Called whenever an extension startup has been completed (and relative urls to assets packaged + * with the extension can be resolved into a full moz-extension url, e.g. optionsPageUrl is + * going to be empty until the extension has reached this callback). + * + * @param extension The {@link WebExtension} that has been fully started. + */ + @UiThread + default void onReady(final @NonNull WebExtension extension) {} + } + + /** This delegate is used to notify of extension process state changes. */ + public interface ExtensionProcessDelegate { + /** Called when extension process spawning has been disabled. */ + @UiThread + default void onDisabledProcessSpawning() {} + } + + /** + * @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:OnInstallationFailed", + "GeckoView:WebExtension:OnInstalled", + "GeckoView:WebExtension:OnReady"); + } 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:OnInstallationFailed", + "GeckoView:WebExtension:OnInstalled", + "GeckoView:WebExtension:OnReady"); + } + + mAddonManagerDelegate = delegate; + } + + /** + * Set the {@link ExtensionProcessDelegate} for this instance. This delegate will be used to + * notify when the state of the extension process has changed. + * + * @param delegate the extension process delegate + * @see ExtensionProcessDelegate + */ + @UiThread + public void setExtensionProcessDelegate(final @Nullable ExtensionProcessDelegate delegate) { + if (delegate == null && mExtensionProcessDelegate != null) { + EventDispatcher.getInstance() + .unregisterUiThreadListener( + mInternals, "GeckoView:WebExtension:OnDisabledProcessSpawning"); + } else if (delegate != null && mExtensionProcessDelegate == null) { + EventDispatcher.getInstance() + .registerUiThreadListener(mInternals, "GeckoView:WebExtension:OnDisabledProcessSpawning"); + } + + mExtensionProcessDelegate = delegate; + } + + /** + * Enable extension process spawning. + * + * <p>Extension process spawning can be disabled when the extension process has been killed or + * crashed beyond the threshold set for Gecko. This method can be called to reset the threshold + * count and allow the spawning again. If the threshold is reached again, {@link + * ExtensionProcessDelegate#onDisabledProcessSpawning()} will still be called. + * + * @see ExtensionProcessDelegate#onDisabledProcessSpawning() + */ + @AnyThread + public void enableExtensionProcessSpawning() { + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:EnableProcessSpawning", null); + } + + /** + * Disable extension process spawning. + * + * <p>Extension process spawning can be re-enabled with {@link + * WebExtensionController#enableExtensionProcessSpawning()}. This method does the opposite and + * stops the extension process. This method can be called when we no longer want to run extensions + * for the rest of the session. + * + * @see ExtensionProcessDelegate#onDisabledProcessSpawning() + */ + @AnyThread + public void disableExtensionProcessSpawning() { + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:DisableProcessSpawning", null); + } + + 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. + * @param installationMethod The method used by the embedder to install the {@link WebExtension}. + * @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 @Nullable @InstallationMethod String installationMethod) { + final InstallCanceller canceller = new InstallCanceller(); + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("locationUri", uri); + bundle.putString("installId", canceller.installId); + bundle.putString("installMethod", installationMethod); + + 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; + } + + /** + * 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. If + * you are looking to provide an {@link InstallationMethod}, please use {@link + * WebExtensionController#install(String, String)} + * + * @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) { + return install(uri, null); + } + + /** The method used by the embedder to install the {@link WebExtension}. */ + @Retention(RetentionPolicy.SOURCE) + @StringDef({INSTALLATION_METHOD_MANAGER, INSTALLATION_METHOD_FROM_FILE}) + public @interface InstallationMethod {}; + + /** Indicates the {@link WebExtension} was installed using from the embedder's add-ons manager. */ + public static final String INSTALLATION_METHOD_MANAGER = "manager"; + + /** Indicates the {@link WebExtension} was installed from a file. */ + public static final String INSTALLATION_METHOD_FROM_FILE = "install-from-file"; + + /** + * 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; + } else if ("GeckoView:WebExtension:OnDisabledProcessSpawning".equals(event)) { + onDisabledProcessSpawning(); + return; + } else if ("GeckoView:WebExtension:OnInstallationFailed".equals(event)) { + onInstallationFailed(bundle); + return; + } else if ("GeckoView:WebExtension:OnReady".equals(event)) { + onReady(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 onInstallationFailed(final GeckoBundle bundle) { + if (mAddonManagerDelegate == null) { + Log.e(LOGTAG, "no AddonManager delegate registered"); + return; + } + + final int errorCode = bundle.getInt("error"); + final GeckoBundle extensionBundle = bundle.getBundle("extension"); + WebExtension extension = null; + final String extensionName = bundle.getString("addonName"); + + if (extensionBundle != null) { + extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + } + mAddonManagerDelegate.onInstallationFailed( + extension, new InstallException(errorCode, extensionName)); + } + + 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); + } + + private void onReady(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.onReady(extension); + } + + private void onDisabledProcessSpawning() { + if (mExtensionProcessDelegate == null) { + Log.e(LOGTAG, "no extension process delegate registered"); + return; + } + + mExtensionProcessDelegate.onDisabledProcessSpawning(); + } + + @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) { + return Objects.equals(a, 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..4b081483e5 --- /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 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..10a6eb16cd --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md @@ -0,0 +1,1522 @@ +--- +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 + +## v124 + +- Added [`GeckoRuntimeSettings#setTrustedRecursiveResolverMode`][124.1] to enable DNS-over-HTTPS using different resolver modes ([bug 1591533]({{bugzilla}}1591533)). +- Added [`GeckoRuntimeSettings#setTrustedRecursiveResolverUri`][124.2] to specify the DNS-over-HTTPS server to be used if DoH is enabled ([bug 1591533]({{bugzilla}}1591533)). +- Added [`GeckoRuntimeSettings#setLargeKeepaliveFactor`][124.3] to increase the keepalive timeout used for a connection ([bug 1591533]({{bugzilla}}1591533)). +- Added [`PanZoomController.onDragEvent`][124.4] to support drag and drop. + ([bug 1586471]({{bugzilla}}1586471)) +- Added [`WebExtension.MetaData.incognito`][124.5] property. ([bug 1875229]({{bugzilla}}1875229)) + +[124.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setTrustedRecursiveResolverMode-int- +[124.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setTrustedRecursiveResolverUri-java.lang.String- +[124.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLargeKeepaliveFactor-int- +[124.4]: {{javadoc_uri}}/PanZoomController.html#onDragEvent(android.view.DragEvent) +[124.5]: {{javadoc_uri}}/WebExtension.MetaData.html#incognito + +## v123 +- For Translations, added [`checkPairDownloadSize`][123.1] and [`TranslationsException.ERROR_MODEL_LANGUAGE_REQUIRED`][123.2] as an error state. +- ⚠️ Deprecated [`GeckoSession.requestAnalysisCreationStatus`][119.2] by 124, please use [`GeckoSession.requestCreateAnalysis`][122.2] instead. +- ⚠️ Removed deprecated [`GeckoSession.requestAnalysisCreationStatus`][119.2] +- Added [`GeckoSession.sendPlacementAttributionEvent`][123.3] for sending placement attribution event for a given product recommendation. + +[123.1]: {{javadoc_uri}}/TranslationsController.RuntimeTranslation.html#checkPairDownloadSize(java.lang.String,java.lang.String) +[123.2]: {{javadoc_uri}}/TranslationsController.TranslationsException.html#ERROR_MODEL_LANGUAGE_REQUIRED +[121.3]: {{javadoc_uri}}/GeckoSession.html#sendPlacementAttributionEvent(String) + +## v122 +- ⚠️ Removed [`onGetNimbusFeature`][115.5], please use `ExperimentDelegate.onGetExperimentFeature` instead. +- Added [`GeckoSession.reportBackInStock`][122.1] for reporting a Shopping product is back in stock.([bug 1858945]({{bugzilla}}1858945)) +- Added [`GeckoSession.requestCreateAnalysis`][122.2] to return a `AnalysisStatusResponse` that contains a status and a progress field. ([bug 1866112]({{bugzilla}}1866112)) +- Added support for controlling `privacy.globalprivacycontrol.enabled` and `privacy.globalprivacycontrol.pbmode.enabled` and `privacy.globalprivacycontrol.functionality.enabled` via [`GeckoRuntimeSettings.Builder.globalPrivacyControlEnabled`][122.3] +- Added named translations exceptions via [`TranslationsException`][122.4]. +- Added [`ERROR_UNSUPPORTED_ADDON_TYPE`][122.5] to `WebExtension.InstallException.ErrorCodes`. ([bug 1867873]({{bugzilla}}1867873)) +- Added [`WebExtensionController.install`][122.6] requires `WebExtensionController.InstallationMethod`. +- Added runtime options to set and get specific "never translate this site" preferences on [`RuntimeTranslation`][121.1]. +- Added APIs for toggling `privacy.trackingprotection.emailtracking.pbmode.enabled`. ([bug 1866927]({{bugzilla}}1866927). + +[122.1]: {{javadoc_uri}}/GeckoSession.html#reportBackInStock(String) +[122.2]: {{javadoc_uri}}/GeckoSession.html#requestCreateAnalysis(String) +[122.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#globalPrivacyControlEnabled(boolean) +[122.4]: {{javadoc_uri}}/TranslationsController.TranslationsException.html +[122.5]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_UNSUPPORTED_ADDON_TYPE +[122.6]: {{javadoc_uri}}/WebExtensionController.WebExtensionController.html#install(java.lang.String,java.lang.String,org.mozilla.geckoview.WebExtensionController.InstallationMethod) + +## v121 +- Added runtime controller functions. [`RuntimeTranslation`][121.1] has options for retrieving translation languages and managing language models. +- Added support for controlling `cookiebanners.service.enableGlobalRules` and `cookiebanners.service.enableGlobalRules.subFrames` via [`GeckoSession.ContentDelegate.cookieBannerGlobalRulesEnabled`][121.2] and [`GeckoSession.ContentDelegate.cookieBannerGlobalRulesSubFramesEnabled`][121.3]. +- Added [`GeckoSession.sendClickAttributionEvent`][121.4] for sending click attribution event for a given product recommendation. +- Added [`GeckoSession.sendImpressionAttributionEvent`][121.5] for sending impression attribution event for a given product recommendation. +- Added support for controlling `privacy.query_stripping.enabled` and `privacy.query_stripping.enabled.pbmode` via [`GeckoSession.ContentDelegate.queryParameterStrippingEnabled`][121.6] and [`GeckoSession.ContentDelegate.queryParameterStrippingPrivateBrowsingEnabled`][121.7]. +- Added support for controlling `privacy.query_stripping.allow_list` and `privacy.query_stripping.strip_list` via [`GeckoSession.ContentDelegate.queryParameterStrippingAllowList`][121.8] and [`GeckoSession.ContentDelegate.queryParameterStrippingStripList`][121.9]. +- Add [`WebExtensionController.AddonManagerDelegate.onReady`][121.10] ([bug 1859585]({{bugzilla}}1859585). +- ⚠️ `WebExtensionController.install` method will not be implicitly awaiting for the installed extension to be fully started anymore, callers of the install method should now expect the `WebExtension.MetaData` properties `baseUrl` and `optionsPageUrl` to be not be + defined yet until the `WebExtensionController.AddonManagerDelegate.onReady` delegated method has been called ([bug 1859585]({{bugzilla}}1859585). +- Added additional support for translation settings such as: `getLanguageSetting`, `setLanguageSetting`, `getNeverTranslateSiteSetting`,`setNeverTranslateSiteSetting`, on the Translations Controller [121.11], and `getTranslationsOfferPopup`, `setTranslationsOfferPopup` on the Runtime Settings [121.12]. +- Added `privacy.trackingprotection.emailtracking.enabled` to strict mode for email tracker blocking in GeckoView. Removed unnecessary string manipulation on STP Pref string. [121.13] ([bug 1856634]({{bugzilla}}1856634). + +[121.1]: {{javadoc_uri}}/TranslationsController.RuntimeTranslation.html +[121.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerGlobalRulesEnabled(boolean) +[121.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerGlobalRulesSubFramesEnabled(boolean) +[121.4]: {{javadoc_uri}}/GeckoSession.html#sendClickAttributionEvent(String) +[121.5]: {{javadoc_uri}}/GeckoSession.html#sendImpressionAttributionEvent(String) +[121.6]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingEnabled(boolean) +[121.7]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingPrivateBrowsingEnabled(boolean) +[121.8]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingAllowList(String) +[121.9]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#queryParameterStrippingStripList(boolean) +[121.10]: {{javadoc_uri}}/WebExtensionController.AddonManagerDelegate.html#onReady +[121.11]: {{javadoc_uri}}/TranslationsController.html +[121.12]: {{javadoc_uri}}/GeckoRuntimeSettings.html +[121.13]: {{javadoc_uri}}/Contentblocking.AntiTracking.html#EMAIL + +## v120 +- Added [`disableExtensionProcessSpawning`][120.1] for disabling the extension process spawning. ([bug 1855405]({{bugzilla}}1855405)) +- Added `DisabledFlags.SIGNATURE` for extensions disabled because they aren't correctly signed. ([bug 1847266]({{bugzilla}}1847266)) +- Added `Builder` pattern constructors for [`ReviewAnalysis`][120.2] and [`Recommendation`][120.3] (part of [bug 1846341]({{bugzilla}}1846341)) +- Added `DisabledFlags.APP_VERSION` for extensions disabled because they aren't compatible with the application version. ([bug 1847266]({{bugzilla}}1847266)) +- Added more metadata to the [WebExtension][120.4] class. ([bug 1850674]({{bugzilla}}1850674), [bug 1858925]({{bugzilla}}1858925)) +- Added session and translations controller. Includes [`TranslationsController`][120.5], [`TranslationsController.SessionTranslation`][120.6] (notably [translate][120.7]), and a [translations delegate][120.8]. + +[120.1]: {{javadoc_uri}}/WebExtensionController.html#disableExtensionProcessSpawning +[120.2]: {{javadoc_uri}}/GeckoSession.html#ReviewAnalysis.Builder.html +[120.3]: {{javadoc_uri}}/GeckoSession.html#Recommendation.Builder.html +[120.4]: {{javadoc_uri}}/WebExtension.html) +[120.5]: {{javadoc_uri}}/TranslationsController.html +[120.6]: {{javadoc_uri}}/TranslationsController.SessionTranslation.html +[120.7]: {{javadoc_uri}}/TranslationsController.SessionTranslation.html#translate(java.lang.String,java.lang.String,org.mozilla.geckoview.TranslationsController.SessionTranslation.TranslationOptions) +[120.8]: {{javadoc_uri}}/TranslationsController.SessionTranslation.Delegate.html + +## v119 +- Added `remoteType` to GeckoView child crash intent. ([bug 1851518]({{bugzilla}}1851518)) + +[119.1]: {{javadoc_uri}}/GeckoSession.html#requestCreateAnalysis(String) +[119.2]: {{javadoc_uri}}/GeckoSession.html#requestAnalysisCreationStatus(String) +[119.3]: {{javadoc_uri}}/GeckoSession.html#pollForAnalysisCompleted(String) + +## v118 +- Added [`ExperimentDelegate`][118.1] to allow GeckoView to send and retrieve experiment information from an embedder. +- Added [`ERROR_BLOCKLISTED`][118.2] to `WebExtension.InstallException.ErrorCodes`. ([bug 1845745]({{bugzilla}}1845745)) +- Added [`ContentDelegate.onProductUrl`][118.3] to notify the app when on a supported product page. +- Added [`GeckoSession.requestAnalysis`][118.4] for requesting product review analysis. +- Added [`GeckoSession.requestRecommendations`][118.5] for requesting product recommendations given a specific product url. +- Added [`ERROR_INCOMPATIBLE`][118.6] to `WebExtension.InstallException.ErrorCodes`. ([bug 1845749]({{bugzilla}}1845749)) +- Added [`GeckoRuntimeSettings.Builder.extensionsWebAPIEnabled`][118.7]. ([bug 1847173]({{bugzilla}}1847173)) +- Changed [`GeckoSession.AccountSelectorPrompt`][118.8]: added the Provider to which the Account belongs ([bug 1847059]({{bugzilla}}1847059)) +- Added [`getExperimentDelegate`][118.9] and [`setExperimentDelegate`][118.10] to the GeckoSession allow GeckoView to get and set the experiment delegate for the session. Default is to use the runtime delegate. +- ⚠️ Deprecated [`onGetNimbusFeature`][115.5] by 122, please use `ExperimentDelegate.onGetExperimentFeature` instead. +- Added [`GeckoRuntimeSettings.Builder.extensionsProcessEnabled`][118.11] for setting whether extensions process is enabled. ([bug 1843926]({{bugzilla}}1843926)) +- Added [`ExtensionProcessDelegate`][118.12] to allow GeckoView to notify disabling of the extension process spawning due to excessive crash/kill. ([bug 1819737]({{bugzilla}}1819737)) +- Added [`enableExtensionProcessSpawning`][118.13] for enabling the extension process spawning +- Add [`WebExtensionController.AddonManagerDelegate.onInstallationFailed`][118.14] ([bug 1848100]({{bugzilla}}1848100). +- Add [`InstallException.extensionName`][118.15] which indicates the name of the extension that caused the exception. + +[118.1]: {{javadoc_uri}}/ExperimentDelegate.html +[118.2]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_BLOCKLISTED +[118.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onProductUrl(org.mozilla.geckoview.GeckoSession) +[118.4]: {{javadoc_uri}}/GeckoSession.html#requestAnalysis(String) +[118.5]: {{javadoc_uri}}/GeckoSession.html#requestRecommendations(String) +[118.6]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INCOMPATIBLE +[118.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#extensionsWebAPIEnabled(boolean) +[118.8]: {{javadoc_uri}}/GeckoSession.html#AccountSelectorPrompt +[118.9]: {{javadoc_uri}}/GeckoSession.html#getExperimentDelegate() +[118.10]: {{javadoc_uri}}/GeckoSession.html#setExperimentDelegate(org.mozilla.geckoview.ExperimentDelegate) +[118.11]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#extensionsProcessEnabled(Boolean) +[118.12]: {{javadoc_uri}}/WebExtensionController.ExtensionProcessDelegate.html +[118.13]: {{javadoc_uri}}/WebExtensionController.html#enableExtensionProcessSpawning +[118.14]: {{javadoc_uri}}/WebExtensionController.AddonManagerDelegate.html#onInstallationFailed +[118.15]: {{javadoc_uri}}/WebExtension.InstallException.html#extensionName + +## v116 +- Added [`GeckoSession.didPrintPageContent`][116.1] to included extra print status for a standard print and new `GeckoPrintException.ERROR_NO_PRINT_DELEGATE` +- Added [`PromptInstanceDelegate.onSelectIdentityCredentialProvider`][116.2] to allow the user to choose an Identity Credential provider (FedCM) to be used when authenticating. + ([bug 1836356]({{bugzilla}}1836356)) +- Changed [`Gecko.CrashHandler`] location to [`GeckoView.CrashHandler`][116.3] ([bug 1550206]({{bugzilla}}1550206)) +- Added [`PromptInstanceDelegate.onSelectIdentityCredentialAccount`][116.4] to allow the user to choose an account on the Identity Credential Provider (FedCM) they previously chose to be used when authenticating. + ([bug 1836363]({{bugzilla}}1836363)) +- Added [`PromptInstanceDelegate.onShowPrivacyPolicyIdentityCredential`][116.5] to allow the user to indicate if agrees or not with the privacy policy of the Identity Credential provider. + ([bug 1836358]({{bugzilla}}1836358)) + +[116.1]: {{javadoc_uri}}/GeckoSession.html#didPrintPageContent +[116.2]:{{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSelectIdentityCredentialProvider(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.ProviderSelectorPrompt) +[116.3]:{{javadoc_uri}}/CrashHandler.html +[116.4]:{{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSelectIdentityCredentialAccount(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.AccountSelectorPrompt) +[116.5]:{{javadoc_uri}}/GeckoSession.PromptDelegate.html#onShowPrivacyPolicyIdentityCredential(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.IdentityCredential.PrivacyPolicyPrompt) + +## 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]: ff5a513251f19534bbf4ebe0084909665d00a227 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)); + } +} diff --git a/mobile/android/geckoview_example/build.gradle b/mobile/android/geckoview_example/build.gradle new file mode 100644 index 0000000000..1ca7127839 --- /dev/null +++ b/mobile/android/geckoview_example/build.gradle @@ -0,0 +1,65 @@ +buildDir "${topobjdir}/gradle/build/mobile/android/geckoview_example" + +apply plugin: 'com.android.application' + +apply from: "${topsrcdir}/mobile/android/gradle/product_flavors.gradle" + +android { + buildToolsVersion project.ext.buildToolsVersion + compileSdkVersion project.ext.compileSdkVersion + + defaultConfig { + targetSdkVersion project.ext.targetSdkVersion + minSdkVersion project.ext.minSdkVersion + manifestPlaceholders = project.ext.manifestPlaceholders + + applicationId "org.mozilla.geckoview_example" + versionCode project.ext.versionCode + versionName project.ext.versionName + + multiDexEnabled true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + // By default the android plugins ignores folders that start with `_`, but + // we need those in web extensions. + // See also: + // - https://issuetracker.google.com/issues/36911326 + // - https://stackoverflow.com/questions/9206117/how-to-workaround-autoomitting-fiiles-folders-starting-with-underscore-in + aaptOptions { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + noCompress 'ja' + } + + project.configureProductFlavors.delegate = it + project.configureProductFlavors() + + buildFeatures { + buildConfig true + } + + namespace 'org.mozilla.geckoview_example' +} + +dependencies { + implementation "androidx.annotation:annotation:1.6.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.preference:preference:1.1.1" + + implementation project(path: ':geckoview') + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.google.android.material:material:1.9.0' + + implementation 'androidx.multidex:multidex:2.0.1' +} diff --git a/mobile/android/geckoview_example/proguard-rules.pro b/mobile/android/geckoview_example/proguard-rules.pro new file mode 100644 index 0000000000..46fbee5497 --- /dev/null +++ b/mobile/android/geckoview_example/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /Users/nalexander/.mozbuild/android-sdk-macosx/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/mobile/android/geckoview_example/src/main/AndroidManifest.xml b/mobile/android/geckoview_example/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..1aa1ba4abf --- /dev/null +++ b/mobile/android/geckoview_example/src/main/AndroidManifest.xml @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> + <uses-permission android:name="android.permission.RECORD_AUDIO"/> + <uses-permission android:name="android.permission.CAMERA"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" /> + + <application + android:allowBackup="true" + android:label="@string/app_name" + android:supportsRtl="true" + android:usesCleartextTraffic="true" + android:icon="@drawable/logo" + android:name="androidx.multidex.MultiDexApplication"> + <uses-library android:name="android.test.runner" + android:required="false"/> + + <activity + android:configChanges="keyboardHidden|orientation|screenSize" + android:name=".GeckoViewActivity" + android:label="GeckoView Example" + android:launchMode="singleTop" + android:theme="@style/Theme.AppCompat.Light.NoActionBar" + android:exported="true" + android:windowSoftInputMode="stateUnspecified|adjustResize"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + + <category android:name="android.intent.category.LAUNCHER"/> + <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER"/> + <category android:name="android.intent.category.APP_BROWSER"/> + <category android:name="android.intent.category.DEFAULT"/> + </intent-filter> + <intent-filter> + <action android:name="android.intent.action.VIEW"/> + + <category android:name="android.intent.category.DEFAULT"/> + <category android:name="android.intent.category.BROWSABLE"/> + + <data android:scheme="http"/> + <data android:scheme="https"/> + <data android:scheme="about"/> + <data android:scheme="javascript"/> + </intent-filter> + </activity> + <activity + android:name=".SessionActivity" + android:label="GeckoView Example" + android:theme="@style/Theme.AppCompat.Light.NoActionBar" + android:exported="false" + android:windowSoftInputMode="stateUnspecified|adjustResize"> + </activity> + <activity + android:name=".SettingsActivity" + android:label="Settings" + android:exported="false" + android:theme="@style/Theme.AppCompat.Light.NoActionBar"> + </activity> + + <service + android:name=".ExampleCrashHandler" + android:exported="false" + android:foregroundServiceType="specialUse" + android:process=":crash"> + <property + android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" + android:value="This foreground service allows users to report crashes" /> + </service> + </application> + +</manifest> diff --git a/mobile/android/geckoview_example/src/main/assets/error.html b/mobile/android/geckoview_example/src/main/assets/error.html new file mode 100644 index 0000000000..4534e9d222 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/assets/error.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> + <head> + <title>Boom!</title> + <meta charset="utf-8" /> + <meta + name="viewport" + content="width=device-width,initial-scale=1,user-scalable=no" + /> + <style> + body { + background-color: red; + color: white; + font-family: sans; + } + + div.container { + width: 75%; + margin: auto; + text-align: center; + } + </style> + </head> + <body> + <div class="container"> + <h1>Boom!</h1> + <p>Something bad happened...</p> + <p>$ERROR</p> + </div> + </body> +</html> diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ActionButton.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ActionButton.java new file mode 100644 index 0000000000..729fb6a61d --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ActionButton.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.geckoview_example; + +import android.graphics.Bitmap; + +public class ActionButton { + final Bitmap icon; + final String text; + final Integer textColor; + final Integer backgroundColor; + + public ActionButton( + final Bitmap icon, + final String text, + final Integer textColor, + final Integer backgroundColor) { + this.icon = icon; + this.text = text; + this.textColor = textColor; + this.backgroundColor = backgroundColor; + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/BasicGeckoViewPrompt.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/BasicGeckoViewPrompt.java new file mode 100644 index 0000000000..c0faeb3809 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/BasicGeckoViewPrompt.java @@ -0,0 +1,1127 @@ +/* -*- 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_example; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.net.Uri; +import android.os.Build; +import android.text.InputType; +import android.text.format.DateFormat; +import android.util.Log; +import android.view.InflateException; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckedTextView; +import android.widget.DatePicker; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.ListView; +import android.widget.ScrollView; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.TimePicker; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +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.PermissionDelegate.MediaSource; +import org.mozilla.geckoview.SlowScriptResponse; + +final class BasicGeckoViewPrompt implements GeckoSession.PromptDelegate { + protected static final String LOGTAG = "BasicGeckoViewPrompt"; + + private final Activity mActivity; + public int filePickerRequestCode = 1; + private int mFileType; + private GeckoResult<PromptResponse> mFileResponse; + private FilePrompt mFilePrompt; + + public BasicGeckoViewPrompt(final Activity activity) { + mActivity = activity; + } + + @Override + public GeckoResult<PromptResponse> onAlertPrompt( + final GeckoSession session, final AlertPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = + new AlertDialog.Builder(activity) + .setTitle(prompt.title) + .setMessage(prompt.message) + .setPositiveButton(android.R.string.ok, /* onClickListener */ null); + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + createStandardDialog(builder, prompt, res).show(); + return res; + } + + @Override + public GeckoResult<PromptResponse> onButtonPrompt( + final GeckoSession session, final ButtonPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = + new AlertDialog.Builder(activity).setTitle(prompt.title).setMessage(prompt.message); + + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + + final DialogInterface.OnClickListener listener = + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + if (which == DialogInterface.BUTTON_POSITIVE) { + res.complete(prompt.confirm(ButtonPrompt.Type.POSITIVE)); + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + res.complete(prompt.confirm(ButtonPrompt.Type.NEGATIVE)); + } else { + res.complete(prompt.dismiss()); + } + } + }; + + builder.setPositiveButton(android.R.string.ok, listener); + builder.setNegativeButton(android.R.string.cancel, listener); + + createStandardDialog(builder, prompt, res).show(); + return res; + } + + @Override + public GeckoResult<PromptResponse> onSharePrompt( + final GeckoSession session, final SharePrompt prompt) { + return GeckoResult.fromValue(prompt.dismiss()); + } + + @Nullable + @Override + public GeckoResult<PromptResponse> onRepostConfirmPrompt( + final GeckoSession session, final RepostConfirmPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = + new AlertDialog.Builder(activity) + .setTitle(R.string.repost_confirm_title) + .setMessage(R.string.repost_confirm_message); + + GeckoResult<PromptResponse> res = new GeckoResult<>(); + + final DialogInterface.OnClickListener listener = + (dialog, which) -> { + if (which == DialogInterface.BUTTON_POSITIVE) { + res.complete(prompt.confirm(AllowOrDeny.ALLOW)); + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + res.complete(prompt.confirm(AllowOrDeny.DENY)); + } else { + res.complete(prompt.dismiss()); + } + }; + + builder.setPositiveButton(R.string.repost_confirm_resend, listener); + builder.setNegativeButton(R.string.repost_confirm_cancel, listener); + + createStandardDialog(builder, prompt, res).show(); + return res; + } + + @Nullable + @Override + public GeckoResult<PromptResponse> onCreditCardSave( + @NonNull GeckoSession session, + @NonNull AutocompleteRequest<Autocomplete.CreditCardSaveOption> request) { + Log.i(LOGTAG, "onCreditCardSave " + request.options[0].value); + return null; + } + + @Nullable + @Override + public GeckoResult<PromptResponse> onLoginSave( + @NonNull GeckoSession session, + @NonNull AutocompleteRequest<Autocomplete.LoginSaveOption> request) { + Log.i(LOGTAG, "onLoginSave"); + return GeckoResult.fromValue(request.confirm(request.options[0])); + } + + @Nullable + @Override + public GeckoResult<PromptResponse> onBeforeUnloadPrompt( + final GeckoSession session, final BeforeUnloadPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = + new AlertDialog.Builder(activity) + .setTitle(R.string.before_unload_title) + .setMessage(R.string.before_unload_message); + + GeckoResult<PromptResponse> res = new GeckoResult<>(); + + final DialogInterface.OnClickListener listener = + (dialog, which) -> { + if (which == DialogInterface.BUTTON_POSITIVE) { + res.complete(prompt.confirm(AllowOrDeny.ALLOW)); + } else if (which == DialogInterface.BUTTON_NEGATIVE) { + res.complete(prompt.confirm(AllowOrDeny.DENY)); + } else { + res.complete(prompt.dismiss()); + } + }; + + builder.setPositiveButton(R.string.before_unload_leave_page, listener); + builder.setNegativeButton(R.string.before_unload_stay, listener); + + createStandardDialog(builder, prompt, res).show(); + return res; + } + + private int getViewPadding(final AlertDialog.Builder builder) { + final TypedArray attr = + builder + .getContext() + .obtainStyledAttributes(new int[] {android.R.attr.listPreferredItemPaddingLeft}); + final int padding = attr.getDimensionPixelSize(0, 1); + attr.recycle(); + return padding; + } + + private LinearLayout addStandardLayout( + final AlertDialog.Builder builder, final String title, final String msg) { + final ScrollView scrollView = new ScrollView(builder.getContext()); + final LinearLayout container = new LinearLayout(builder.getContext()); + final int horizontalPadding = getViewPadding(builder); + final int verticalPadding = (msg == null || msg.isEmpty()) ? horizontalPadding : 0; + container.setOrientation(LinearLayout.VERTICAL); + container.setPadding( + /* left */ horizontalPadding, /* top */ verticalPadding, + /* right */ horizontalPadding, /* bottom */ verticalPadding); + scrollView.addView(container); + builder.setTitle(title).setMessage(msg).setView(scrollView); + return container; + } + + private AlertDialog createStandardDialog( + final AlertDialog.Builder builder, + final BasePrompt prompt, + final GeckoResult<PromptResponse> response) { + final AlertDialog dialog = builder.create(); + dialog.setOnDismissListener( + new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(final DialogInterface dialog) { + if (!prompt.isComplete()) { + response.complete(prompt.dismiss()); + } + } + }); + return dialog; + } + + @Override + public GeckoResult<PromptResponse> onTextPrompt( + final GeckoSession session, final TextPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final LinearLayout container = addStandardLayout(builder, prompt.title, prompt.message); + final EditText editText = new EditText(builder.getContext()); + editText.setText(prompt.defaultValue); + container.addView(editText); + + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + + builder + .setNegativeButton(android.R.string.cancel, /* listener */ null) + .setPositiveButton( + android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + res.complete(prompt.confirm(editText.getText().toString())); + } + }); + + createStandardDialog(builder, prompt, res).show(); + return res; + } + + @Override + public GeckoResult<PromptResponse> onAuthPrompt( + final GeckoSession session, final AuthPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final LinearLayout container = addStandardLayout(builder, prompt.title, prompt.message); + + final int flags = prompt.authOptions.flags; + final int level = prompt.authOptions.level; + final EditText username; + if ((flags & AuthPrompt.AuthOptions.Flags.ONLY_PASSWORD) == 0) { + username = new EditText(builder.getContext()); + username.setHint(R.string.username); + username.setText(prompt.authOptions.username); + container.addView(username); + } else { + username = null; + } + + final EditText password = new EditText(builder.getContext()); + password.setHint(R.string.password); + password.setText(prompt.authOptions.password); + password.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); + container.addView(password); + + if (level != AuthPrompt.AuthOptions.Level.NONE) { + final ImageView secure = new ImageView(builder.getContext()); + secure.setImageResource(android.R.drawable.ic_lock_lock); + container.addView(secure); + } + + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + + builder + .setNegativeButton(android.R.string.cancel, /* listener */ null) + .setPositiveButton( + android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + if ((flags & AuthPrompt.AuthOptions.Flags.ONLY_PASSWORD) == 0) { + res.complete( + prompt.confirm(username.getText().toString(), password.getText().toString())); + + } else { + res.complete(prompt.confirm(password.getText().toString())); + } + } + }); + createStandardDialog(builder, prompt, res).show(); + + return res; + } + + private static class ModifiableChoice { + public boolean modifiableSelected; + public String modifiableLabel; + public final ChoicePrompt.Choice choice; + + public ModifiableChoice(ChoicePrompt.Choice c) { + choice = c; + modifiableSelected = choice.selected; + modifiableLabel = choice.label; + } + } + + private void addChoiceItems( + final int type, + final ArrayAdapter<ModifiableChoice> list, + final ChoicePrompt.Choice[] items, + final String indent) { + if (type == ChoicePrompt.Type.MENU) { + for (final ChoicePrompt.Choice item : items) { + list.add(new ModifiableChoice(item)); + } + return; + } + + for (final ChoicePrompt.Choice item : items) { + final ModifiableChoice modItem = new ModifiableChoice(item); + + final ChoicePrompt.Choice[] children = item.items; + + if (indent != null && children == null) { + modItem.modifiableLabel = indent + modItem.modifiableLabel; + } + list.add(modItem); + + if (children != null) { + final String newIndent; + if (type == ChoicePrompt.Type.SINGLE || type == ChoicePrompt.Type.MULTIPLE) { + newIndent = (indent != null) ? indent + '\t' : "\t"; + } else { + newIndent = null; + } + addChoiceItems(type, list, children, newIndent); + } + } + } + + private void onChoicePromptImpl( + final GeckoSession session, + final String title, + final String message, + final int type, + final ChoicePrompt.Choice[] choices, + final ChoicePrompt prompt, + final GeckoResult<PromptResponse> res) { + final Activity activity = mActivity; + if (activity == null) { + res.complete(prompt.dismiss()); + return; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + addStandardLayout(builder, title, message); + + final ListView list = new ListView(builder.getContext()); + if (type == ChoicePrompt.Type.MULTIPLE) { + list.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + } + + final ArrayAdapter<ModifiableChoice> adapter = + new ArrayAdapter<ModifiableChoice>( + builder.getContext(), android.R.layout.simple_list_item_1) { + private static final int TYPE_MENU_ITEM = 0; + private static final int TYPE_MENU_CHECK = 1; + private static final int TYPE_SEPARATOR = 2; + private static final int TYPE_GROUP = 3; + private static final int TYPE_SINGLE = 4; + private static final int TYPE_MULTIPLE = 5; + private static final int TYPE_COUNT = 6; + + private LayoutInflater mInflater; + private View mSeparator; + + @Override + public int getViewTypeCount() { + return TYPE_COUNT; + } + + @Override + public int getItemViewType(final int position) { + final ModifiableChoice item = getItem(position); + if (item.choice.separator) { + return TYPE_SEPARATOR; + } else if (type == ChoicePrompt.Type.MENU) { + return item.modifiableSelected ? TYPE_MENU_CHECK : TYPE_MENU_ITEM; + } else if (item.choice.items != null) { + return TYPE_GROUP; + } else if (type == ChoicePrompt.Type.SINGLE) { + return TYPE_SINGLE; + } else if (type == ChoicePrompt.Type.MULTIPLE) { + return TYPE_MULTIPLE; + } else { + throw new UnsupportedOperationException(); + } + } + + @Override + public boolean isEnabled(final int position) { + final ModifiableChoice item = getItem(position); + return !item.choice.separator + && !item.choice.disabled + && ((type != ChoicePrompt.Type.SINGLE && type != ChoicePrompt.Type.MULTIPLE) + || item.choice.items == null); + } + + @Override + public View getView(final int position, View view, final ViewGroup parent) { + final int itemType = getItemViewType(position); + final int layoutId; + if (itemType == TYPE_SEPARATOR) { + if (mSeparator == null) { + mSeparator = new View(getContext()); + mSeparator.setLayoutParams( + new ListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 2, itemType)); + final TypedArray attr = + getContext().obtainStyledAttributes(new int[] {android.R.attr.listDivider}); + mSeparator.setBackgroundResource(attr.getResourceId(0, 0)); + attr.recycle(); + } + return mSeparator; + } else if (itemType == TYPE_MENU_ITEM) { + layoutId = android.R.layout.simple_list_item_1; + } else if (itemType == TYPE_MENU_CHECK) { + layoutId = android.R.layout.simple_list_item_checked; + } else if (itemType == TYPE_GROUP) { + layoutId = android.R.layout.preference_category; + } else if (itemType == TYPE_SINGLE) { + layoutId = android.R.layout.simple_list_item_single_choice; + } else if (itemType == TYPE_MULTIPLE) { + layoutId = android.R.layout.simple_list_item_multiple_choice; + } else { + throw new UnsupportedOperationException(); + } + + if (view == null) { + if (mInflater == null) { + mInflater = LayoutInflater.from(builder.getContext()); + } + view = mInflater.inflate(layoutId, parent, false); + } + + final ModifiableChoice item = getItem(position); + final TextView text = (TextView) view; + text.setEnabled(!item.choice.disabled); + text.setText(item.modifiableLabel); + if (view instanceof CheckedTextView) { + final boolean selected = item.modifiableSelected; + if (itemType == TYPE_MULTIPLE) { + list.setItemChecked(position, selected); + } else { + ((CheckedTextView) view).setChecked(selected); + } + } + return view; + } + }; + addChoiceItems(type, adapter, choices, /* indent */ null); + + list.setAdapter(adapter); + builder.setView(list); + + final AlertDialog dialog; + if (type == ChoicePrompt.Type.SINGLE || type == ChoicePrompt.Type.MENU) { + dialog = createStandardDialog(builder, prompt, res); + list.setOnItemClickListener( + new AdapterView.OnItemClickListener() { + @Override + public void onItemClick( + final AdapterView<?> parent, final View v, final int position, final long id) { + final ModifiableChoice item = adapter.getItem(position); + if (type == ChoicePrompt.Type.MENU) { + final ChoicePrompt.Choice[] children = item.choice.items; + if (children != null) { + // Show sub-menu. + dialog.setOnDismissListener(null); + dialog.dismiss(); + onChoicePromptImpl( + session, item.modifiableLabel, /* msg */ null, type, children, prompt, res); + return; + } + } + res.complete(prompt.confirm(item.choice)); + dialog.dismiss(); + } + }); + } else if (type == ChoicePrompt.Type.MULTIPLE) { + list.setOnItemClickListener( + new AdapterView.OnItemClickListener() { + @Override + public void onItemClick( + final AdapterView<?> parent, final View v, final int position, final long id) { + final ModifiableChoice item = adapter.getItem(position); + item.modifiableSelected = ((CheckedTextView) v).isChecked(); + } + }); + builder + .setNegativeButton(android.R.string.cancel, /* listener */ null) + .setPositiveButton( + android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + final int len = adapter.getCount(); + ArrayList<String> items = new ArrayList<>(len); + for (int i = 0; i < len; i++) { + final ModifiableChoice item = adapter.getItem(i); + if (item.modifiableSelected) { + items.add(item.choice.id); + } + } + res.complete(prompt.confirm(items.toArray(new String[items.size()]))); + } + }); + dialog = createStandardDialog(builder, prompt, res); + } else { + throw new UnsupportedOperationException(); + } + dialog.show(); + + prompt.setDelegate( + new PromptInstanceDelegate() { + @Override + public void onPromptDismiss(final BasePrompt prompt) { + dialog.dismiss(); + } + + @Override + public void onPromptUpdate(final BasePrompt prompt) { + dialog.setOnDismissListener(null); + dialog.dismiss(); + final ChoicePrompt newPrompt = (ChoicePrompt) prompt; + onChoicePromptImpl( + session, + newPrompt.title, + newPrompt.message, + newPrompt.type, + newPrompt.choices, + newPrompt, + res); + } + }); + } + + @Override + public GeckoResult<PromptResponse> onChoicePrompt( + final GeckoSession session, final ChoicePrompt prompt) { + final GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + onChoicePromptImpl( + session, prompt.title, prompt.message, prompt.type, prompt.choices, prompt, res); + return res; + } + + private static int parseColor(final String value, final int def) { + try { + return Color.parseColor(value); + } catch (final IllegalArgumentException e) { + return def; + } + } + + @Override + public GeckoResult<PromptResponse> onColorPrompt( + final GeckoSession session, final ColorPrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + addStandardLayout(builder, prompt.title, /* msg */ null); + + final int initial = parseColor(prompt.defaultValue, /* def */ 0); + final ArrayAdapter<Integer> adapter = + new ArrayAdapter<Integer>(builder.getContext(), android.R.layout.simple_list_item_1) { + private LayoutInflater mInflater; + + @Override + public int getViewTypeCount() { + return 2; + } + + @Override + public int getItemViewType(final int position) { + return (getItem(position) == initial) ? 1 : 0; + } + + @Override + public View getView(final int position, View view, final ViewGroup parent) { + if (mInflater == null) { + mInflater = LayoutInflater.from(builder.getContext()); + } + final int color = getItem(position); + if (view == null) { + view = + mInflater.inflate( + (color == initial) + ? android.R.layout.simple_list_item_checked + : android.R.layout.simple_list_item_1, + parent, + false); + } + view.setBackgroundResource(android.R.drawable.editbox_background); + view.getBackground().setColorFilter(color, PorterDuff.Mode.MULTIPLY); + return view; + } + }; + + adapter.addAll( + 0xffff4444 /* holo_red_light */, + 0xffcc0000 /* holo_red_dark */, + 0xffffbb33 /* holo_orange_light */, + 0xffff8800 /* holo_orange_dark */, + 0xff99cc00 /* holo_green_light */, + 0xff669900 /* holo_green_dark */, + 0xff33b5e5 /* holo_blue_light */, + 0xff0099cc /* holo_blue_dark */, + 0xffaa66cc /* holo_purple */, + 0xffffffff /* white */, + 0xffaaaaaa /* lighter_gray */, + 0xff555555 /* darker_gray */, + 0xff000000 /* black */); + + final ListView list = new ListView(builder.getContext()); + list.setAdapter(adapter); + builder.setView(list); + + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + + final AlertDialog dialog = createStandardDialog(builder, prompt, res); + list.setOnItemClickListener( + new AdapterView.OnItemClickListener() { + @Override + public void onItemClick( + final AdapterView<?> parent, final View v, final int position, final long id) { + res.complete( + prompt.confirm(String.format("#%06x", 0xffffff & adapter.getItem(position)))); + dialog.dismiss(); + } + }); + dialog.show(); + + return res; + } + + private static Date parseDate( + final SimpleDateFormat formatter, final String value, final boolean defaultToNow) { + try { + if (value != null && !value.isEmpty()) { + return formatter.parse(value); + } + } catch (final ParseException e) { + } + return defaultToNow ? new Date() : null; + } + + @SuppressWarnings("deprecation") + private static void setTimePickerTime(final TimePicker picker, final Calendar cal) { + if (Build.VERSION.SDK_INT >= 23) { + picker.setHour(cal.get(Calendar.HOUR_OF_DAY)); + picker.setMinute(cal.get(Calendar.MINUTE)); + } else { + picker.setCurrentHour(cal.get(Calendar.HOUR_OF_DAY)); + picker.setCurrentMinute(cal.get(Calendar.MINUTE)); + } + } + + @SuppressWarnings("deprecation") + private static void setCalendarTime(final Calendar cal, final TimePicker picker) { + if (Build.VERSION.SDK_INT >= 23) { + cal.set(Calendar.HOUR_OF_DAY, picker.getHour()); + cal.set(Calendar.MINUTE, picker.getMinute()); + } else { + cal.set(Calendar.HOUR_OF_DAY, picker.getCurrentHour()); + cal.set(Calendar.MINUTE, picker.getCurrentMinute()); + } + } + + @Override + public GeckoResult<PromptResponse> onDateTimePrompt( + final GeckoSession session, final DateTimePrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + final String format; + if (prompt.type == DateTimePrompt.Type.DATE) { + format = "yyyy-MM-dd"; + } else if (prompt.type == DateTimePrompt.Type.MONTH) { + format = "yyyy-MM"; + } else if (prompt.type == DateTimePrompt.Type.WEEK) { + format = "yyyy-'W'ww"; + } else if (prompt.type == DateTimePrompt.Type.TIME) { + format = "HH:mm"; + } else if (prompt.type == DateTimePrompt.Type.DATETIME_LOCAL) { + format = "yyyy-MM-dd'T'HH:mm"; + } else { + throw new UnsupportedOperationException(); + } + + final SimpleDateFormat formatter = new SimpleDateFormat(format, Locale.ROOT); + final Date minDate = parseDate(formatter, prompt.minValue, /* defaultToNow */ false); + final Date maxDate = parseDate(formatter, prompt.maxValue, /* defaultToNow */ false); + final Date date = parseDate(formatter, prompt.defaultValue, /* defaultToNow */ true); + final Calendar cal = formatter.getCalendar(); + cal.setTime(date); + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final LayoutInflater inflater = LayoutInflater.from(builder.getContext()); + final DatePicker datePicker; + if (prompt.type == DateTimePrompt.Type.DATE + || prompt.type == DateTimePrompt.Type.MONTH + || prompt.type == DateTimePrompt.Type.WEEK + || prompt.type == DateTimePrompt.Type.DATETIME_LOCAL) { + final int resId = + builder + .getContext() + .getResources() + .getIdentifier("date_picker_dialog", "layout", "android"); + DatePicker picker = null; + if (resId != 0) { + try { + picker = (DatePicker) inflater.inflate(resId, /* root */ null); + } catch (final ClassCastException | InflateException e) { + } + } + if (picker == null) { + picker = new DatePicker(builder.getContext()); + } + picker.init( + cal.get(Calendar.YEAR), + cal.get(Calendar.MONTH), + cal.get(Calendar.DAY_OF_MONTH), /* listener */ + null); + if (minDate != null) { + picker.setMinDate(minDate.getTime()); + } + if (maxDate != null) { + picker.setMaxDate(maxDate.getTime()); + } + datePicker = picker; + } else { + datePicker = null; + } + + final TimePicker timePicker; + if (prompt.type == DateTimePrompt.Type.TIME + || prompt.type == DateTimePrompt.Type.DATETIME_LOCAL) { + final int resId = + builder + .getContext() + .getResources() + .getIdentifier("time_picker_dialog", "layout", "android"); + TimePicker picker = null; + if (resId != 0) { + try { + picker = (TimePicker) inflater.inflate(resId, /* root */ null); + } catch (final ClassCastException | InflateException e) { + } + } + if (picker == null) { + picker = new TimePicker(builder.getContext()); + } + setTimePickerTime(picker, cal); + picker.setIs24HourView(DateFormat.is24HourFormat(builder.getContext())); + timePicker = picker; + } else { + timePicker = null; + } + + final LinearLayout container = addStandardLayout(builder, prompt.title, /* msg */ null); + container.setPadding(/* left */ 0, /* top */ 0, /* right */ 0, /* bottom */ 0); + if (datePicker != null) { + container.addView(datePicker); + } + if (timePicker != null) { + container.addView(timePicker); + } + + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + + final DialogInterface.OnClickListener listener = + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + if (which == DialogInterface.BUTTON_NEUTRAL) { + // Clear + res.complete(prompt.confirm("")); + return; + } + if (datePicker != null) { + cal.set(datePicker.getYear(), datePicker.getMonth(), datePicker.getDayOfMonth()); + } + if (timePicker != null) { + setCalendarTime(cal, timePicker); + } + res.complete(prompt.confirm(formatter.format(cal.getTime()))); + } + }; + builder + .setNegativeButton(android.R.string.cancel, /* listener */ null) + .setNeutralButton(R.string.clear_field, listener) + .setPositiveButton(android.R.string.ok, listener); + + final AlertDialog dialog = createStandardDialog(builder, prompt, res); + dialog.show(); + + prompt.setDelegate( + new PromptInstanceDelegate() { + @Override + public void onPromptDismiss(final BasePrompt prompt) { + dialog.dismiss(); + } + }); + return res; + } + + @Override + @TargetApi(19) + public GeckoResult<PromptResponse> onFilePrompt(GeckoSession session, FilePrompt prompt) { + final Activity activity = mActivity; + if (activity == null) { + return GeckoResult.fromValue(prompt.dismiss()); + } + + // Merge all given MIME types into one, using wildcard if needed. + String mimeType = null; + String mimeSubtype = null; + if (prompt.mimeTypes != null) { + for (final String rawType : prompt.mimeTypes) { + final String normalizedType = rawType.trim().toLowerCase(Locale.ROOT); + final int len = normalizedType.length(); + int slash = normalizedType.indexOf('/'); + if (slash < 0) { + slash = len; + } + final String newType = normalizedType.substring(0, slash); + final String newSubtype = normalizedType.substring(Math.min(slash + 1, len)); + if (mimeType == null) { + mimeType = newType; + } else if (!mimeType.equals(newType)) { + mimeType = "*"; + } + if (mimeSubtype == null) { + mimeSubtype = newSubtype; + } else if (!mimeSubtype.equals(newSubtype)) { + mimeSubtype = "*"; + } + } + } + + final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.setType( + (mimeType != null ? mimeType : "*") + '/' + (mimeSubtype != null ? mimeSubtype : "*")); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); + if (prompt.type == FilePrompt.Type.MULTIPLE) { + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + if (prompt.mimeTypes.length > 0) { + intent.putExtra(Intent.EXTRA_MIME_TYPES, prompt.mimeTypes); + } + + GeckoResult<PromptResponse> res = new GeckoResult<PromptResponse>(); + + try { + mFileResponse = res; + mFilePrompt = prompt; + activity.startActivityForResult(intent, filePickerRequestCode); + } catch (final ActivityNotFoundException e) { + Log.e(LOGTAG, "Cannot launch activity", e); + return GeckoResult.fromValue(prompt.dismiss()); + } + + return res; + } + + public void onFileCallbackResult(final int resultCode, final Intent data) { + if (mFileResponse == null) { + return; + } + + final GeckoResult<PromptResponse> res = mFileResponse; + mFileResponse = null; + + final FilePrompt prompt = mFilePrompt; + mFilePrompt = null; + + if (resultCode != Activity.RESULT_OK || data == null) { + res.complete(prompt.dismiss()); + return; + } + + final Uri uri = data.getData(); + final ClipData clip = data.getClipData(); + + if (prompt.type == FilePrompt.Type.SINGLE + || (prompt.type == FilePrompt.Type.MULTIPLE && clip == null)) { + res.complete(prompt.confirm(mActivity, uri)); + } else if (prompt.type == FilePrompt.Type.MULTIPLE) { + if (clip == null) { + Log.w(LOGTAG, "No selected file"); + res.complete(prompt.dismiss()); + return; + } + final int count = clip.getItemCount(); + final ArrayList<Uri> uris = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + uris.add(clip.getItemAt(i).getUri()); + } + res.complete(prompt.confirm(mActivity, uris.toArray(new Uri[uris.size()]))); + } + } + + public GeckoResult<Integer> onPermissionPrompt( + final GeckoSession session, + final String title, + final GeckoSession.PermissionDelegate.ContentPermission perm) { + final Activity activity = mActivity; + final GeckoResult<Integer> res = new GeckoResult<>(); + if (activity == null) { + res.complete(GeckoSession.PermissionDelegate.ContentPermission.VALUE_PROMPT); + return res; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder + .setTitle(title) + .setNegativeButton( + android.R.string.cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + res.complete(GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY); + } + }) + .setPositiveButton( + android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + res.complete(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW); + } + }); + + final AlertDialog dialog = builder.create(); + dialog.show(); + return res; + } + + public void onSlowScriptPrompt( + GeckoSession geckoSession, String title, GeckoResult<SlowScriptResponse> reportAction) { + final Activity activity = mActivity; + if (activity == null) { + return; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder + .setTitle(title) + .setNegativeButton( + activity.getString(R.string.wait), + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + reportAction.complete(SlowScriptResponse.CONTINUE); + } + }) + .setPositiveButton( + activity.getString(R.string.stop), + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + reportAction.complete(SlowScriptResponse.STOP); + } + }); + + final AlertDialog dialog = builder.create(); + dialog.show(); + } + + private Spinner addMediaSpinner( + final Context context, + final ViewGroup container, + final MediaSource[] sources, + final String[] sourceNames) { + final ArrayAdapter<MediaSource> adapter = + new ArrayAdapter<MediaSource>(context, android.R.layout.simple_spinner_item) { + private View convertView(final int position, final View view) { + if (view != null) { + final MediaSource item = getItem(position); + ((TextView) view).setText(sourceNames != null ? sourceNames[position] : item.name); + } + return view; + } + + @Override + public View getView(final int position, View view, final ViewGroup parent) { + return convertView(position, super.getView(position, view, parent)); + } + + @Override + public View getDropDownView(final int position, final View view, final ViewGroup parent) { + return convertView(position, super.getDropDownView(position, view, parent)); + } + }; + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + adapter.addAll(sources); + + final Spinner spinner = new Spinner(context); + spinner.setAdapter(adapter); + spinner.setSelection(0); + container.addView(spinner); + return spinner; + } + + public void onMediaPrompt( + final GeckoSession session, + final String title, + final MediaSource[] video, + final MediaSource[] audio, + final String[] videoNames, + final String[] audioNames, + final GeckoSession.PermissionDelegate.MediaCallback callback) { + final Activity activity = mActivity; + if (activity == null || (video == null && audio == null)) { + callback.reject(); + return; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + final LinearLayout container = addStandardLayout(builder, title, /* msg */ null); + + final Spinner videoSpinner; + if (video != null) { + videoSpinner = addMediaSpinner(builder.getContext(), container, video, videoNames); + } else { + videoSpinner = null; + } + + final Spinner audioSpinner; + if (audio != null) { + audioSpinner = addMediaSpinner(builder.getContext(), container, audio, audioNames); + } else { + audioSpinner = null; + } + + builder + .setNegativeButton(android.R.string.cancel, /* listener */ null) + .setPositiveButton( + android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + final MediaSource video = + (videoSpinner != null) ? (MediaSource) videoSpinner.getSelectedItem() : null; + final MediaSource audio = + (audioSpinner != null) ? (MediaSource) audioSpinner.getSelectedItem() : null; + callback.grant(video, audio); + } + }); + + final AlertDialog dialog = builder.create(); + dialog.setOnDismissListener( + new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(final DialogInterface dialog) { + callback.reject(); + } + }); + dialog.show(); + } + + public void onMediaPrompt( + final GeckoSession session, + final String title, + final MediaSource[] video, + final MediaSource[] audio, + final GeckoSession.PermissionDelegate.MediaCallback callback) { + onMediaPrompt(session, title, video, audio, null, null, callback); + } + + @Override + public GeckoResult<PromptResponse> onPopupPrompt( + final GeckoSession session, final PopupPrompt prompt) { + return GeckoResult.fromValue(prompt.confirm(AllowOrDeny.ALLOW)); + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ExampleCrashHandler.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ExampleCrashHandler.java new file mode 100644 index 0000000000..1c66757483 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ExampleCrashHandler.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.geckoview_example; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.os.StrictMode; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.CrashReporter; +import org.mozilla.geckoview.GeckoRuntime; + +public class ExampleCrashHandler extends Service { + private static final String LOGTAG = "ExampleCrashHandler"; + + private static final String CHANNEL_ID = "geckoview_example_crashes"; + private static final int NOTIFY_ID = 42; + + private static final String ACTION_REPORT_CRASH = + "org.mozilla.geckoview_example.ACTION_REPORT_CRASH"; + private static final String ACTION_DISMISS = "org.mozilla.geckoview_example.ACTION_DISMISS"; + + private Intent mCrashIntent; + + public ExampleCrashHandler() {} + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { + stopSelf(); + return Service.START_NOT_STICKY; + } + + if (GeckoRuntime.ACTION_CRASHED.equals(intent.getAction())) { + mCrashIntent = intent; + + Log.d(LOGTAG, "Dump File: " + mCrashIntent.getStringExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH)); + Log.d(LOGTAG, "Extras File: " + mCrashIntent.getStringExtra(GeckoRuntime.EXTRA_EXTRAS_PATH)); + Log.d( + LOGTAG, + "Process Type: " + mCrashIntent.getStringExtra(GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE)); + + String id = createNotificationChannel(); + + int intentFlag = 0; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + intentFlag = PendingIntent.FLAG_IMMUTABLE; + } + + PendingIntent reportIntent = + PendingIntent.getService( + this, + 0, + new Intent(ACTION_REPORT_CRASH, null, this, ExampleCrashHandler.class), + intentFlag); + + PendingIntent dismissIntent = + PendingIntent.getService( + this, + 0, + new Intent(ACTION_DISMISS, null, this, ExampleCrashHandler.class), + intentFlag); + + Notification notification = + new NotificationCompat.Builder(this, id) + .setSmallIcon(R.drawable.ic_crash) + .setContentTitle(getResources().getString(R.string.crashed_title)) + .setContentText(getResources().getString(R.string.crashed_text)) + .setDefaults(Notification.DEFAULT_ALL) + .addAction(0, getResources().getString(R.string.crashed_ignore), dismissIntent) + .addAction(0, getResources().getString(R.string.crashed_report), reportIntent) + .setAutoCancel(true) + .setOngoing(false) + .build(); + + startForeground(NOTIFY_ID, notification); + } else if (ACTION_REPORT_CRASH.equals(intent.getAction())) { + StrictMode.ThreadPolicy oldPolicy = null; + if (BuildConfig.DEBUG_BUILD) { + oldPolicy = StrictMode.getThreadPolicy(); + + // We do some disk I/O and network I/O on the main thread, but it's fine. + StrictMode.setThreadPolicy( + new StrictMode.ThreadPolicy.Builder(oldPolicy) + .permitDiskReads() + .permitDiskWrites() + .permitNetwork() + .build()); + } + + try { + CrashReporter.sendCrashReport(this, mCrashIntent, "GeckoViewExample"); + } catch (Exception e) { + Log.e(LOGTAG, "Failed to send crash report", e); + } + + if (oldPolicy != null) { + StrictMode.setThreadPolicy(oldPolicy); + } + + stopSelf(); + } else if (ACTION_DISMISS.equals(intent.getAction())) { + stopSelf(); + } + + return Service.START_NOT_STICKY; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private String createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = + new NotificationChannel( + CHANNEL_ID, "GeckoView Example Crashes", NotificationManager.IMPORTANCE_LOW); + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + return CHANNEL_ID; + } + + return ""; + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java new file mode 100644 index 0000000000..168a238694 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewActivity.java @@ -0,0 +1,3066 @@ +/* -*- 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_example; + +import android.Manifest; +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.AlertDialog; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Environment; +import android.os.SystemClock; +import android.text.InputType; +import android.util.Log; +import android.util.LruCache; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; +import android.widget.Spinner; +import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; +import java.io.BufferedOutputStream; +import java.io.BufferedReader; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.json.JSONObject; +import org.mozilla.geckoview.AllowOrDeny; +import org.mozilla.geckoview.Autocomplete; +import org.mozilla.geckoview.BasicSelectionActionDelegate; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntimeSettings; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission; +import org.mozilla.geckoview.GeckoSessionSettings; +import org.mozilla.geckoview.GeckoView; +import org.mozilla.geckoview.GeckoWebExecutor; +import org.mozilla.geckoview.Image; +import org.mozilla.geckoview.MediaSession; +import org.mozilla.geckoview.OrientationController; +import org.mozilla.geckoview.RuntimeTelemetry; +import org.mozilla.geckoview.SlowScriptResponse; +import org.mozilla.geckoview.TranslationsController; +import org.mozilla.geckoview.WebExtension; +import org.mozilla.geckoview.WebExtensionController; +import org.mozilla.geckoview.WebNotification; +import org.mozilla.geckoview.WebNotificationDelegate; +import org.mozilla.geckoview.WebRequest; +import org.mozilla.geckoview.WebRequestError; +import org.mozilla.geckoview.WebResponse; + +interface WebExtensionDelegate { + default GeckoSession toggleBrowserActionPopup(boolean force) { + return null; + } + + default void onActionButton(ActionButton button) {} + + default TabSession getSession(GeckoSession session) { + return null; + } + + default TabSession getCurrentSession() { + return null; + } + + default void closeTab(TabSession session) {} + + default void updateTab(TabSession session, WebExtension.UpdateTabDetails details) {} + + default TabSession openNewTab(WebExtension.CreateTabDetails details) { + return null; + } +} + +class WebExtensionManager + implements WebExtension.ActionDelegate, + WebExtension.SessionTabDelegate, + WebExtension.TabDelegate, + WebExtensionController.PromptDelegate, + WebExtensionController.DebuggerDelegate, + TabSessionManager.TabObserver { + public WebExtension extension; + + private LruCache<Image, Bitmap> mBitmapCache = new LruCache<>(5); + private GeckoRuntime mRuntime; + private WebExtension.Action mDefaultAction; + private TabSessionManager mTabManager; + + private WeakReference<WebExtensionDelegate> mExtensionDelegate; + + @Nullable + @Override + public GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) { + return GeckoResult.allow(); + } + + @Nullable + @Override + public GeckoResult<AllowOrDeny> onUpdatePrompt( + @NonNull WebExtension currentlyInstalled, + @NonNull WebExtension updatedExtension, + @NonNull String[] newPermissions, + @NonNull String[] newOrigins) { + return GeckoResult.allow(); + } + + @Nullable + @Override + public GeckoResult<AllowOrDeny> onOptionalPrompt( + final @NonNull WebExtension extension, final String[] permissions, final String[] origins) { + return GeckoResult.allow(); + } + + @Override + public void onExtensionListUpdated() { + refreshExtensionList(); + } + + // We only support either one browserAction or one pageAction + private void onAction( + final WebExtension extension, final GeckoSession session, final WebExtension.Action action) { + WebExtensionDelegate delegate = mExtensionDelegate.get(); + if (delegate == null) { + return; + } + + WebExtension.Action resolved; + + if (session == null) { + // This is the default action + mDefaultAction = action; + resolved = actionFor(delegate.getCurrentSession()); + } else { + if (delegate.getSession(session) == null) { + return; + } + delegate.getSession(session).action = action; + if (delegate.getCurrentSession() != session) { + // This update is not for the session that we are currently displaying, + // no need to update the UI + return; + } + resolved = action.withDefault(mDefaultAction); + } + + updateAction(resolved); + } + + @Override + public GeckoResult<GeckoSession> onNewTab( + WebExtension source, WebExtension.CreateTabDetails details) { + WebExtensionDelegate delegate = mExtensionDelegate.get(); + if (delegate == null) { + return GeckoResult.fromValue(null); + } + return GeckoResult.fromValue(delegate.openNewTab(details)); + } + + @Override + public GeckoResult<AllowOrDeny> onCloseTab(WebExtension extension, GeckoSession session) { + final WebExtensionDelegate delegate = mExtensionDelegate.get(); + if (delegate == null) { + return GeckoResult.deny(); + } + + final TabSession tabSession = mTabManager.getSession(session); + if (tabSession != null) { + delegate.closeTab(tabSession); + } + + return GeckoResult.allow(); + } + + @Override + public GeckoResult<AllowOrDeny> onUpdateTab( + WebExtension extension, GeckoSession session, WebExtension.UpdateTabDetails updateDetails) { + final WebExtensionDelegate delegate = mExtensionDelegate.get(); + if (delegate == null) { + return GeckoResult.deny(); + } + + final TabSession tabSession = mTabManager.getSession(session); + if (tabSession != null) { + delegate.updateTab(tabSession, updateDetails); + } + + return GeckoResult.allow(); + } + + @Override + public void onPageAction( + final WebExtension extension, final GeckoSession session, final WebExtension.Action action) { + onAction(extension, session, action); + } + + @Override + public void onBrowserAction( + final WebExtension extension, final GeckoSession session, final WebExtension.Action action) { + onAction(extension, session, action); + } + + private GeckoResult<GeckoSession> togglePopup(boolean force) { + WebExtensionDelegate extensionDelegate = mExtensionDelegate.get(); + if (extensionDelegate == null) { + return null; + } + + GeckoSession session = extensionDelegate.toggleBrowserActionPopup(false); + if (session == null) { + return null; + } + + return GeckoResult.fromValue(session); + } + + @Override + public GeckoResult<GeckoSession> onTogglePopup( + final @NonNull WebExtension extension, final @NonNull WebExtension.Action action) { + return togglePopup(false); + } + + @Override + public GeckoResult<GeckoSession> onOpenPopup( + final @NonNull WebExtension extension, final @NonNull WebExtension.Action action) { + return togglePopup(true); + } + + private WebExtension.Action actionFor(TabSession session) { + if (session.action == null) { + return mDefaultAction; + } else { + return session.action.withDefault(mDefaultAction); + } + } + + private void updateAction(WebExtension.Action resolved) { + WebExtensionDelegate extensionDelegate = mExtensionDelegate.get(); + if (extensionDelegate == null) { + return; + } + + if (resolved == null || resolved.enabled == null || !resolved.enabled) { + extensionDelegate.onActionButton(null); + return; + } + + if (resolved.icon != null) { + if (mBitmapCache.get(resolved.icon) != null) { + extensionDelegate.onActionButton( + new ActionButton( + mBitmapCache.get(resolved.icon), + resolved.badgeText, + resolved.badgeTextColor, + resolved.badgeBackgroundColor)); + } else { + resolved + .icon + .getBitmap(100) + .accept( + bitmap -> { + mBitmapCache.put(resolved.icon, bitmap); + extensionDelegate.onActionButton( + new ActionButton( + bitmap, + resolved.badgeText, + resolved.badgeTextColor, + resolved.badgeBackgroundColor)); + }); + } + } else { + extensionDelegate.onActionButton(null); + } + } + + public void onClicked(TabSession session) { + WebExtension.Action action = actionFor(session); + if (action != null) { + action.click(); + } + } + + public void setExtensionDelegate(WebExtensionDelegate delegate) { + mExtensionDelegate = new WeakReference<>(delegate); + } + + @Override + public void onCurrentSession(TabSession session) { + if (mDefaultAction == null) { + // No action was ever defined, so nothing to do + return; + } + + if (session.action != null) { + updateAction(session.action.withDefault(mDefaultAction)); + } else { + updateAction(mDefaultAction); + } + } + + public GeckoResult<Void> unregisterExtension() { + if (extension == null) { + return GeckoResult.fromValue(null); + } + + mTabManager.unregisterWebExtension(); + + return mRuntime + .getWebExtensionController() + .uninstall(extension) + .accept( + (unused) -> { + extension = null; + mDefaultAction = null; + updateAction(null); + }); + } + + public GeckoResult<WebExtension> updateExtension() { + if (extension == null) { + return GeckoResult.fromValue(null); + } + + return mRuntime + .getWebExtensionController() + .update(extension) + .map( + newExtension -> { + registerExtension(newExtension); + return newExtension; + }); + } + + public void registerExtension(WebExtension extension) { + extension.setActionDelegate(this); + extension.setTabDelegate(this); + mTabManager.setWebExtensionDelegates(extension, this, this); + this.extension = extension; + } + + private void refreshExtensionList() { + mRuntime + .getWebExtensionController() + .list() + .accept( + extensions -> { + for (final WebExtension extension : extensions) { + registerExtension(extension); + } + }); + } + + public WebExtensionManager(GeckoRuntime runtime, TabSessionManager tabManager) { + mTabManager = tabManager; + mRuntime = runtime; + refreshExtensionList(); + } +} + +public class GeckoViewActivity extends AppCompatActivity + implements ToolbarLayout.TabListener, + WebExtensionDelegate, + SharedPreferences.OnSharedPreferenceChangeListener { + private static final String LOGTAG = "GeckoViewActivity"; + private static final String FULL_ACCESSIBILITY_TREE_EXTRA = "full_accessibility_tree"; + private static final String SEARCH_URI_BASE = "https://www.google.com/search?q="; + private static final String ACTION_SHUTDOWN = "org.mozilla.geckoview_example.SHUTDOWN"; + private static final String CHANNEL_ID = "GeckoViewExample"; + private static final int REQUEST_FILE_PICKER = 1; + private static final int REQUEST_PERMISSIONS = 2; + private static final int REQUEST_WRITE_EXTERNAL_STORAGE = 3; + + private static GeckoRuntime sGeckoRuntime; + + private static WebExtensionManager sExtensionManager; + + private TabSessionManager mTabSessionManager; + private GeckoView mGeckoView; + private boolean mFullAccessibilityTree; + private boolean mUsePrivateBrowsing; + private boolean mCollapsed; + private boolean mKillProcessOnDestroy; + private boolean mDesktopMode; + + private TabSession mPopupSession; + private View mPopupView; + private ContentPermission mTrackingProtectionPermission; + + private boolean mShowNotificationsRejected; + private ArrayList<String> mAcceptedPersistentStorage = new ArrayList<String>(); + + private ToolbarLayout mToolbarView; + private String mCurrentUri; + private boolean mCanGoBack; + private boolean mCanGoForward; + private boolean mFullScreen; + private boolean mExpectedTranslate = false; + private boolean mTranslateRestore = false; + + private String mDetectedLanguage = null; + + private HashMap<String, Integer> mNotificationIDMap = new HashMap<>(); + private int mLastID = 100; + + private ProgressBar mProgressView; + + private LinkedList<WebResponse> mPendingDownloads = new LinkedList<>(); + + private int mNextActivityResultCode = 10; + private HashMap<Integer, GeckoResult<Intent>> mPendingActivityResult = new HashMap<>(); + + private LocationView.CommitListener mCommitListener = + new LocationView.CommitListener() { + @Override + public void onCommit(String text) { + if (text.startsWith("data:") + || ((text.contains(".") || text.contains(":")) && !text.contains(" "))) { + mTabSessionManager.getCurrentSession().loadUri(text); + } else { + mTabSessionManager.getCurrentSession().loadUri(SEARCH_URI_BASE + text); + } + mGeckoView.requestFocus(); + } + }; + + @Override + public TabSession openNewTab(WebExtension.CreateTabDetails details) { + final TabSession newSession = createSession(details.cookieStoreId); + mToolbarView.updateTabCount(); + if (details.active == Boolean.TRUE) { + setGeckoViewSession(newSession, false); + } + return newSession; + } + + private final List<Setting<?>> SETTINGS = new ArrayList<>(); + + private abstract class Setting<T> { + private int mKey; + private int mDefaultKey; + private final boolean mReloadCurrentSession; + private T mValue; + + public Setting(final int key, final int defaultValueKey, final boolean reloadCurrentSession) { + mKey = key; + mDefaultKey = defaultValueKey; + mReloadCurrentSession = reloadCurrentSession; + + SETTINGS.add(this); + } + + public void onPrefChange(SharedPreferences pref) { + final T defaultValue = getDefaultValue(mDefaultKey, getResources()); + final String key = getResources().getString(this.mKey); + final T value = getValue(key, defaultValue, pref); + if (!value().equals(value)) { + setValue(value); + } + } + + private void setValue(final T newValue) { + mValue = newValue; + for (final TabSession session : mTabSessionManager.getSessions()) { + setValue(session.getSettings(), value()); + } + if (sGeckoRuntime != null) { + setValue(sGeckoRuntime.getSettings(), value()); + if (sExtensionManager != null) { + setValue(sGeckoRuntime.getWebExtensionController(), value()); + } + } + + final GeckoSession current = mTabSessionManager.getCurrentSession(); + if (mReloadCurrentSession && current != null) { + current.reload(); + } + } + + public T value() { + return mValue == null ? getDefaultValue(mDefaultKey, getResources()) : mValue; + } + + protected abstract T getDefaultValue(final int key, final Resources res); + + protected abstract T getValue( + final String key, final T defaultValue, final SharedPreferences preferences); + + /** Override one of these to define the behavior when this setting changes. */ + protected void setValue(final GeckoSessionSettings settings, final T value) {} + + protected void setValue(final GeckoRuntimeSettings settings, final T value) {} + + protected void setValue(final WebExtensionController controller, final T value) {} + } + + private class StringSetting extends Setting<String> { + public StringSetting(final int key, final int defaultValueKey) { + this(key, defaultValueKey, false); + } + + public StringSetting( + final int key, final int defaultValueKey, final boolean reloadCurrentSession) { + super(key, defaultValueKey, reloadCurrentSession); + } + + @Override + protected String getDefaultValue(int key, final Resources res) { + return res.getString(key); + } + + @Override + public String getValue( + final String key, final String defaultValue, final SharedPreferences preferences) { + return preferences.getString(key, defaultValue); + } + } + + private class BooleanSetting extends Setting<Boolean> { + public BooleanSetting(final int key, final int defaultValueKey) { + this(key, defaultValueKey, false); + } + + public BooleanSetting( + final int key, final int defaultValueKey, final boolean reloadCurrentSession) { + super(key, defaultValueKey, reloadCurrentSession); + } + + @Override + protected Boolean getDefaultValue(int key, Resources res) { + return res.getBoolean(key); + } + + @Override + public Boolean getValue( + final String key, final Boolean defaultValue, final SharedPreferences preferences) { + return preferences.getBoolean(key, defaultValue); + } + } + + private class IntSetting extends Setting<Integer> { + public IntSetting(final int key, final int defaultValueKey) { + this(key, defaultValueKey, false); + } + + public IntSetting( + final int key, final int defaultValueKey, final boolean reloadCurrentSession) { + super(key, defaultValueKey, reloadCurrentSession); + } + + @Override + protected Integer getDefaultValue(int key, Resources res) { + return res.getInteger(key); + } + + @Override + public Integer getValue( + final String key, final Integer defaultValue, final SharedPreferences preferences) { + return Integer.parseInt(preferences.getString(key, Integer.toString(defaultValue))); + } + } + + private final IntSetting mDisplayMode = + new IntSetting(R.string.key_display_mode, R.integer.display_mode_default) { + @Override + public void setValue(final GeckoSessionSettings settings, final Integer value) { + settings.setDisplayMode(value); + } + }; + + private final IntSetting mPreferredColorScheme = + new IntSetting( + R.string.key_preferred_color_scheme, + R.integer.preferred_color_scheme_default, + /* reloadCurrentSession */ true) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Integer value) { + settings.setPreferredColorScheme(value); + } + }; + + private final StringSetting mUserAgent = + new StringSetting( + R.string.key_user_agent_override, + R.string.user_agent_override_default, + /* reloadCurrentSession */ true) { + @Override + public void setValue(final GeckoSessionSettings settings, final String value) { + settings.setUserAgentOverride(value.isEmpty() ? null : value); + } + }; + + private final BooleanSetting mRemoteDebugging = + new BooleanSetting(R.string.key_remote_debugging, R.bool.remote_debugging_default) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + settings.setRemoteDebuggingEnabled(value); + } + }; + + private final BooleanSetting mJavascriptEnabled = + new BooleanSetting( + R.string.key_javascript_enabled, + R.bool.javascript_enabled_default, + /* reloadCurrentSession */ true) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + settings.setJavaScriptEnabled(value); + } + }; + + private final BooleanSetting mGlobalPrivacyControlEnabled = + new BooleanSetting( + R.string.key_global_privacy_control_enabled, + R.bool.global_privacy_control_enabled_default, + /* reloadCurrentSession */ true) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + settings.setGlobalPrivacyControl(value); + } + }; + + private final BooleanSetting mEtbPrivateModeEnabled = + new BooleanSetting( + R.string.key_etb_private_mode_enabled, + R.bool.etb_private_mode_enabled_default, + /* reloadCurrentSession */ true) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + settings.getContentBlocking().setEmailTrackerBlockingPrivateBrowsing(value); + } + }; + + private final BooleanSetting mExtensionsProcessEnabled = + new BooleanSetting( + R.string.key_extensions_process_enabled, + R.bool.extensions_process_enabled_default, + /* reloadCurrentSession */ true) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + settings.setExtensionsProcessEnabled(value); + } + }; + + private final BooleanSetting mTrackingProtection = + new BooleanSetting(R.string.key_tracking_protection, R.bool.tracking_protection_default) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + mTabSessionManager.setUseTrackingProtection(value); + settings.getContentBlocking().setStrictSocialTrackingProtection(value); + } + }; + + private final StringSetting mEnhancedTrackingProtection = + new StringSetting( + R.string.key_enhanced_tracking_protection, + R.string.enhanced_tracking_protection_default) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final String value) { + int etpLevel; + switch (value) { + case "disabled": + etpLevel = ContentBlocking.EtpLevel.NONE; + break; + case "standard": + etpLevel = ContentBlocking.EtpLevel.DEFAULT; + break; + case "strict": + etpLevel = ContentBlocking.EtpLevel.STRICT; + break; + default: + throw new RuntimeException("Invalid ETP level: " + value); + } + + settings.getContentBlocking().setEnhancedTrackingProtectionLevel(etpLevel); + } + }; + + private final StringSetting mCookieBannerHandling = + new StringSetting( + R.string.key_cookie_banner_handling, R.string.cookie_banner_handling_default) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final String value) { + int cbMode; + switch (value) { + case "disabled": + cbMode = ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_DISABLED; + break; + case "reject_all": + cbMode = ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT; + break; + case "reject_accept_all": + cbMode = ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT; + break; + default: + throw new RuntimeException("Invalid Cookie Banner Handling mode: " + value); + } + settings.getContentBlocking().setCookieBannerMode(cbMode); + } + }; + + private final StringSetting mCookieBannerHandlingPrivateMode = + new StringSetting( + R.string.key_cookie_banner_handling_pb, R.string.cookie_banner_handling_pb_default) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final String value) { + int cbPrivateMode; + switch (value) { + case "disabled": + cbPrivateMode = ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_DISABLED; + break; + case "reject_all": + cbPrivateMode = ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT; + break; + case "reject_accept_all": + cbPrivateMode = ContentBlocking.CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT; + break; + default: + throw new RuntimeException("Invalid Cookie Banner Handling private mode: " + value); + } + settings.getContentBlocking().setCookieBannerModePrivateBrowsing(cbPrivateMode); + } + }; + + private final BooleanSetting mDynamicFirstPartyIsolation = + new BooleanSetting(R.string.key_dfpi, R.bool.dfpi_default) { + @Override + public void setValue(final GeckoRuntimeSettings settings, final Boolean value) { + int cookieBehavior = + value + ? ContentBlocking.CookieBehavior.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS + : ContentBlocking.CookieBehavior.ACCEPT_NON_TRACKERS; + settings.getContentBlocking().setCookieBehavior(cookieBehavior); + } + }; + + private final BooleanSetting mAllowAutoplay = + new BooleanSetting( + R.string.key_autoplay, R.bool.autoplay_default, /* reloadCurrentSession */ true); + + private final BooleanSetting mAllowExtensionsInPrivateBrowsing = + new BooleanSetting( + R.string.key_allow_extensions_in_private_browsing, + R.bool.allow_extensions_in_private_browsing_default) { + @Override + public void setValue(final WebExtensionController controller, final Boolean value) { + controller.setAllowedInPrivateBrowsing(sExtensionManager.extension, value); + } + }; + + private void onPreferencesChange(SharedPreferences preferences) { + for (Setting<?> setting : SETTINGS) { + setting.onPrefChange(preferences); + } + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // We might have been started because the user clicked on a notification + WebNotification notification = getIntent().getParcelableExtra("onClick"); + if (notification != null) { + getIntent().removeExtra("onClick"); + notification.click(); + } + + Log.i(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - application start"); + createNotificationChannel(); + setContentView(R.layout.geckoview_activity); + mGeckoView = findViewById(R.id.gecko_view); + mGeckoView.setActivityContextDelegate(new ExampleActivityDelegate()); + mTabSessionManager = new TabSessionManager(); + + setSupportActionBar(findViewById(R.id.toolbar)); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + preferences.registerOnSharedPreferenceChangeListener(this); + // Read initial preference state + onPreferencesChange(preferences); + + mToolbarView = new ToolbarLayout(this, mTabSessionManager); + mToolbarView.setId(R.id.toolbar_layout); + mToolbarView.setTabListener(this); + + getSupportActionBar() + .setCustomView( + mToolbarView, + new ActionBar.LayoutParams( + ActionBar.LayoutParams.MATCH_PARENT, ActionBar.LayoutParams.WRAP_CONTENT)); + getSupportActionBar().setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); + + mFullAccessibilityTree = getIntent().getBooleanExtra(FULL_ACCESSIBILITY_TREE_EXTRA, false); + mProgressView = findViewById(R.id.page_progress); + + if (sGeckoRuntime == null) { + final GeckoRuntimeSettings.Builder runtimeSettingsBuilder = + new GeckoRuntimeSettings.Builder(); + + if (BuildConfig.DEBUG) { + // In debug builds, we want to load JavaScript resources fresh with + // each build. + runtimeSettingsBuilder.arguments(new String[] {"-purgecaches"}); + } + + final Bundle extras = getIntent().getExtras(); + if (extras != null) { + runtimeSettingsBuilder.extras(extras); + } + runtimeSettingsBuilder + .remoteDebuggingEnabled(mRemoteDebugging.value()) + .consoleOutput(true) + .contentBlocking( + new ContentBlocking.Settings.Builder() + .antiTracking( + ContentBlocking.AntiTracking.DEFAULT | ContentBlocking.AntiTracking.STP) + .safeBrowsing(ContentBlocking.SafeBrowsing.DEFAULT) + .cookieBehavior(ContentBlocking.CookieBehavior.ACCEPT_NON_TRACKERS) + .cookieBehaviorPrivateMode(ContentBlocking.CookieBehavior.ACCEPT_NON_TRACKERS) + .enhancedTrackingProtectionLevel(ContentBlocking.EtpLevel.DEFAULT) + .emailTrackerBlockingPrivateMode(mEtbPrivateModeEnabled.value()) + .build()) + .crashHandler(ExampleCrashHandler.class) + .preferredColorScheme(mPreferredColorScheme.value()) + .telemetryDelegate(new ExampleTelemetryDelegate()) + .javaScriptEnabled(mJavascriptEnabled.value()) + .extensionsProcessEnabled(mExtensionsProcessEnabled.value()) + .globalPrivacyControlEnabled(mGlobalPrivacyControlEnabled.value()) + .aboutConfigEnabled(true); + + sGeckoRuntime = GeckoRuntime.create(this, runtimeSettingsBuilder.build()); + + sExtensionManager = new WebExtensionManager(sGeckoRuntime, mTabSessionManager); + mTabSessionManager.setTabObserver(sExtensionManager); + + sGeckoRuntime.getWebExtensionController().setDebuggerDelegate(sExtensionManager); + sGeckoRuntime.setAutocompleteStorageDelegate(new ExampleAutocompleteStorageDelegate()); + sGeckoRuntime.getOrientationController().setDelegate(new ExampleOrientationDelegate()); + sGeckoRuntime.setServiceWorkerDelegate( + new GeckoRuntime.ServiceWorkerDelegate() { + @NonNull + @Override + public GeckoResult<GeckoSession> onOpenWindow(@NonNull String url) { + return mNavigationDelegate.onNewSession(null, url); + } + }); + + // `getSystemService` call requires API level 23 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + sGeckoRuntime.setWebNotificationDelegate( + new WebNotificationDelegate() { + NotificationManager notificationManager = getSystemService(NotificationManager.class); + + @Override + public void onShowNotification(@NonNull WebNotification notification) { + Intent clickIntent = new Intent(GeckoViewActivity.this, GeckoViewActivity.class); + clickIntent.putExtra("onClick", notification); + PendingIntent dismissIntent = + PendingIntent.getActivity( + GeckoViewActivity.this, mLastID, clickIntent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(GeckoViewActivity.this, CHANNEL_ID) + .setContentTitle(notification.title) + .setContentText(notification.text) + .setSmallIcon(R.drawable.ic_status_logo) + .setContentIntent(dismissIntent) + .setAutoCancel(true); + + mNotificationIDMap.put(notification.tag, mLastID); + + if (notification.imageUrl != null && notification.imageUrl.length() > 0) { + final GeckoWebExecutor executor = new GeckoWebExecutor(sGeckoRuntime); + + GeckoResult<WebResponse> response = + executor.fetch( + new WebRequest.Builder(notification.imageUrl) + .addHeader("Accept", "image") + .build()); + response.accept( + value -> { + Bitmap bitmap = BitmapFactory.decodeStream(value.body); + builder.setLargeIcon(bitmap); + notificationManager.notify(mLastID++, builder.build()); + }); + } else { + notificationManager.notify(mLastID++, builder.build()); + } + } + + @Override + public void onCloseNotification(@NonNull WebNotification notification) { + if (mNotificationIDMap.containsKey(notification.tag)) { + int id = mNotificationIDMap.get(notification.tag); + notificationManager.cancel(id); + mNotificationIDMap.remove(notification.tag); + } + } + }); + } + + sGeckoRuntime.setDelegate( + () -> { + mKillProcessOnDestroy = true; + finish(); + }); + + sGeckoRuntime.setActivityDelegate( + pendingIntent -> { + final GeckoResult<Intent> result = new GeckoResult<>(); + try { + final int code = mNextActivityResultCode++; + mPendingActivityResult.put(code, result); + GeckoViewActivity.this.startIntentSenderForResult( + pendingIntent.getIntentSender(), code, null, 0, 0, 0); + } catch (IntentSender.SendIntentException e) { + result.completeExceptionally(e); + } + return result; + }); + } + + sExtensionManager.setExtensionDelegate(this); + + if (savedInstanceState == null) { + TabSession session = getIntent().getParcelableExtra("session"); + if (session != null) { + connectSession(session); + + if (!session.isOpen()) { + session.open(sGeckoRuntime); + } + + mFullAccessibilityTree = session.getSettings().getFullAccessibilityTree(); + + mTabSessionManager.addSession(session); + session.open(sGeckoRuntime); + setGeckoViewSession(session); + } else { + session = createSession(); + session.open(sGeckoRuntime); + mTabSessionManager.setCurrentSession(session); + mGeckoView.setSession(session); + sGeckoRuntime.getWebExtensionController().setTabActive(session, true); + } + loadFromIntent(getIntent()); + } + + mGeckoView.setDynamicToolbarMaxHeight(findViewById(R.id.toolbar).getLayoutParams().height); + + mToolbarView.getLocationView().setCommitListener(mCommitListener); + mToolbarView.updateTabCount(); + } + + private void openSettingsActivity() { + Intent intent = new Intent(this, SettingsActivity.class); + startActivity(intent); + } + + @Override + public TabSession getSession(GeckoSession session) { + return mTabSessionManager.getSession(session); + } + + @Override + public TabSession getCurrentSession() { + return mTabSessionManager.getCurrentSession(); + } + + @Override + public void onActionButton(ActionButton button) { + mToolbarView.setBrowserActionButton(button); + } + + @Override + public GeckoSession toggleBrowserActionPopup(boolean force) { + if (mPopupSession == null) { + openPopupSession(); + } + + ViewGroup.LayoutParams params = mPopupView.getLayoutParams(); + boolean shouldShow = force || params.width == 0; + setViewVisibility(mPopupView, shouldShow); + + return shouldShow ? mPopupSession : null; + } + + private static void setViewVisibility(final View view, final boolean visible) { + if (view == null) { + return; + } + + ViewGroup.LayoutParams params = view.getLayoutParams(); + + if (visible) { + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + } else { + params.height = 0; + params.width = 0; + } + + view.setLayoutParams(params); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String s) { + onPreferencesChange(sharedPreferences); + } + + private class PopupSessionContentDelegate implements GeckoSession.ContentDelegate { + @Override + public void onCloseRequest(final GeckoSession session) { + setViewVisibility(mPopupView, false); + if (mPopupSession != null) { + mPopupSession.close(); + } + mPopupSession = null; + mPopupView = null; + } + } + + private void openPopupSession() { + LayoutInflater inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE); + mPopupView = inflater.inflate(R.layout.browser_action_popup, null); + GeckoView geckoView = mPopupView.findViewById(R.id.gecko_view_popup); + geckoView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW); + mPopupSession = new TabSession(); + mPopupSession.setContentDelegate(new PopupSessionContentDelegate()); + mPopupSession.open(sGeckoRuntime); + geckoView.setSession(mPopupSession); + + mPopupView.setOnFocusChangeListener(this::hideBrowserAction); + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(0, 0); + params.addRule(RelativeLayout.ABOVE, R.id.toolbar); + mPopupView.setLayoutParams(params); + mPopupView.setFocusable(true); + ((ViewGroup) findViewById(R.id.main)).addView(mPopupView); + } + + private void hideBrowserAction(View view, boolean hasFocus) { + if (!hasFocus) { + ViewGroup.LayoutParams params = mPopupView.getLayoutParams(); + params.height = 0; + params.width = 0; + mPopupView.setLayoutParams(params); + } + } + + private void createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.app_name); + String description = getString(R.string.activity_label); + int importance = NotificationManager.IMPORTANCE_DEFAULT; + NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); + channel.setDescription(description); + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + private TabSession createSession(final @Nullable String cookieStoreId) { + GeckoSessionSettings.Builder settingsBuilder = new GeckoSessionSettings.Builder(); + settingsBuilder + .usePrivateMode(mUsePrivateBrowsing) + .fullAccessibilityTree(mFullAccessibilityTree) + .userAgentOverride(mUserAgent.value()) + .viewportMode( + mDesktopMode + ? GeckoSessionSettings.VIEWPORT_MODE_DESKTOP + : GeckoSessionSettings.VIEWPORT_MODE_MOBILE) + .userAgentMode( + mDesktopMode + ? GeckoSessionSettings.USER_AGENT_MODE_DESKTOP + : GeckoSessionSettings.USER_AGENT_MODE_MOBILE) + .useTrackingProtection(mTrackingProtection.value()) + .displayMode(mDisplayMode.value()); + + if (cookieStoreId != null) { + settingsBuilder.contextId(cookieStoreId); + } + + TabSession session = mTabSessionManager.newSession(settingsBuilder.build()); + connectSession(session); + + return session; + } + + private TabSession createSession() { + return createSession(null); + } + + private final GeckoSession.NavigationDelegate mNavigationDelegate = + new ExampleNavigationDelegate(); + + private void connectSession(GeckoSession session) { + session.setContentDelegate(new ExampleContentDelegate()); + session.setHistoryDelegate(new ExampleHistoryDelegate()); + final ExampleContentBlockingDelegate cb = new ExampleContentBlockingDelegate(); + session.setContentBlockingDelegate(cb); + session.setProgressDelegate(new ExampleProgressDelegate(cb)); + session.setNavigationDelegate(mNavigationDelegate); + + final BasicGeckoViewPrompt prompt = new BasicGeckoViewPrompt(this); + prompt.filePickerRequestCode = REQUEST_FILE_PICKER; + session.setPromptDelegate(prompt); + + final ExamplePermissionDelegate permission = new ExamplePermissionDelegate(); + permission.androidPermissionRequestCode = REQUEST_PERMISSIONS; + session.setPermissionDelegate(permission); + + session.setMediaDelegate(new ExampleMediaDelegate(this)); + + session.setMediaSessionDelegate(new ExampleMediaSessionDelegate(this)); + + session.setTranslationsSessionDelegate(new ExampleTranslationsSessionDelegate()); + + session.setSelectionActionDelegate(new BasicSelectionActionDelegate(this)); + if (sExtensionManager.extension != null) { + final WebExtension.SessionController sessionController = session.getWebExtensionController(); + sessionController.setActionDelegate(sExtensionManager.extension, sExtensionManager); + sessionController.setTabDelegate(sExtensionManager.extension, sExtensionManager); + } + + updateDesktopMode(session); + } + + private void recreateSession() { + recreateSession(mTabSessionManager.getCurrentSession()); + } + + private void recreateSession(TabSession session) { + if (session != null) { + mTabSessionManager.closeSession(session); + } + + session = createSession(); + session.open(sGeckoRuntime); + mTabSessionManager.setCurrentSession(session); + mGeckoView.setSession(session); + sGeckoRuntime.getWebExtensionController().setTabActive(session, true); + if (mCurrentUri != null) { + session.loadUri(mCurrentUri); + } + } + + @Override + public void onRestoreInstanceState(Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + if (savedInstanceState != null && mGeckoView.getSession() != null) { + mTabSessionManager.setCurrentSession((TabSession) mGeckoView.getSession()); + sGeckoRuntime.getWebExtensionController().setTabActive(mGeckoView.getSession(), true); + } else { + recreateSession(); + } + } + + private void updateDesktopMode(GeckoSession session) { + session + .getSettings() + .setViewportMode( + mDesktopMode + ? GeckoSessionSettings.VIEWPORT_MODE_DESKTOP + : GeckoSessionSettings.VIEWPORT_MODE_MOBILE); + session + .getSettings() + .setUserAgentMode( + mDesktopMode + ? GeckoSessionSettings.USER_AGENT_MODE_DESKTOP + : GeckoSessionSettings.USER_AGENT_MODE_MOBILE); + } + + @Override + public void onBackPressed() { + GeckoSession session = mTabSessionManager.getCurrentSession(); + if (mFullScreen && session != null) { + session.exitFullScreen(); + return; + } + + if (mCanGoBack && session != null) { + session.goBack(); + return; + } + + super.onBackPressed(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.actions, menu); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + menu.findItem(R.id.action_pb).setChecked(mUsePrivateBrowsing); + menu.findItem(R.id.collapse).setChecked(mCollapsed); + menu.findItem(R.id.desktop_mode).setChecked(mDesktopMode); + menu.findItem(R.id.action_tpe) + .setChecked( + mTrackingProtectionPermission != null + && mTrackingProtectionPermission.value == ContentPermission.VALUE_ALLOW); + menu.findItem(R.id.action_forward).setEnabled(mCanGoForward); + + final boolean hasSession = mTabSessionManager.getCurrentSession() != null; + menu.findItem(R.id.action_reload).setEnabled(hasSession); + menu.findItem(R.id.action_forward).setEnabled(hasSession); + menu.findItem(R.id.action_close_tab).setEnabled(hasSession); + menu.findItem(R.id.action_tpe).setEnabled(hasSession && mTrackingProtectionPermission != null); + menu.findItem(R.id.action_pb).setEnabled(hasSession); + menu.findItem(R.id.desktop_mode).setEnabled(hasSession); + menu.findItem(R.id.translate).setVisible(mExpectedTranslate); + menu.findItem(R.id.translate_restore).setVisible(mTranslateRestore); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + GeckoSession session = mTabSessionManager.getCurrentSession(); + switch (item.getItemId()) { + case R.id.action_reload: + session.reload(); + break; + case R.id.action_forward: + session.goForward(); + break; + case R.id.action_tpe: + sGeckoRuntime + .getStorageController() + .setPermission( + mTrackingProtectionPermission, + mTrackingProtectionPermission.value == ContentPermission.VALUE_ALLOW + ? ContentPermission.VALUE_DENY + : ContentPermission.VALUE_ALLOW); + session.reload(); + break; + case R.id.desktop_mode: + mDesktopMode = !mDesktopMode; + updateDesktopMode(session); + session.reload(); + break; + case R.id.action_pb: + mUsePrivateBrowsing = !mUsePrivateBrowsing; + recreateSession(); + break; + case R.id.collapse: + mCollapsed = !mCollapsed; + setViewVisibility(mGeckoView, !mCollapsed); + break; + case R.id.install_addon: + installAddon(); + break; + case R.id.update_addon: + updateAddon(); + break; + case R.id.settings: + openSettingsActivity(); + break; + case R.id.action_new_tab: + createNewTab(); + break; + case R.id.action_close_tab: + closeTab((TabSession) session); + break; + case R.id.save_pdf: + savePdf(session); + break; + case R.id.print_page: + printPage(session); + break; + case R.id.shopping_actions: + shoppingActions(session, mCurrentUri); + break; + case R.id.translate: + translate(session); + break; + case R.id.translate_restore: + translateRestore(session); + break; + case R.id.translate_manage: + translateManage(); + break; + default: + return super.onOptionsItemSelected(item); + } + + return true; + } + + private void installAddon() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.install_addon); + + final EditText input = new EditText(this); + input.setInputType(InputType.TYPE_CLASS_TEXT); + input.setHint(R.string.install_addon_hint); + builder.setView(input); + + builder.setPositiveButton( + R.string.install, + (dialog, which) -> { + final String uri = input.getText().toString(); + + // We only suopport one extension at a time, so remove the currently installed + // extension if there is one + setViewVisibility(mPopupView, false); + mPopupView = null; + mPopupSession = null; + sExtensionManager + .unregisterExtension() + .then( + unused -> { + final WebExtensionController controller = + sGeckoRuntime.getWebExtensionController(); + controller.setPromptDelegate(sExtensionManager); + return controller.install(uri, null); + }) + .then( + extension -> + sGeckoRuntime + .getWebExtensionController() + .setAllowedInPrivateBrowsing( + extension, mAllowExtensionsInPrivateBrowsing.value())) + .accept(extension -> sExtensionManager.registerExtension(extension)); + }); + builder.setNegativeButton( + R.string.cancel, + (dialog, which) -> { + // Nothing to do + }); + + builder.show(); + } + + private void updateAddon() { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.update_addon); + + sExtensionManager + .updateExtension() + .accept( + extension -> { + if (extension != null) { + builder.setMessage("Success"); + } else { + builder.setMessage("No addon to update"); + } + builder.show(); + }, + exception -> { + builder.setMessage("Failed: " + exception); + builder.show(); + }); + } + + private void createNewTab() { + Double startTime = sGeckoRuntime.getProfilerController().getProfilerTime(); + TabSession newSession = createSession(); + newSession.open(sGeckoRuntime); + setGeckoViewSession(newSession); + mToolbarView.updateTabCount(); + sGeckoRuntime.getProfilerController().addMarker("Create new tab", startTime); + } + + @SuppressLint("WrongThread") + @UiThread + private void savePdf(GeckoSession session) { + session + .saveAsPdf() + .accept( + pdfStream -> { + try { + WebResponse response = + new WebResponse.Builder(null) + .body(pdfStream) + .addHeader("Content-Type", "application/pdf") + .addHeader("Content-Disposition", "attachment; filename=PDFDownload.pdf") + .build(); + session.getContentDelegate().onExternalResponse(session, response); + + } catch (Exception e) { + Log.d(LOGTAG, e.getMessage()); + } + }); + } + + private void printPage(GeckoSession session) { + session.didPrintPageContent(); + } + + private void translate(GeckoSession session) { + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.translate); + Spinner fromSelect = new Spinner(this); + Spinner toSelect = new Spinner(this); + + // Set spinners with data + TranslationsController.RuntimeTranslation.listSupportedLanguages() + .then( + supportedLanguages -> { + // Just a check if sorting is working on the Language object by reversing, Languages + // should generally come from the API in the display order. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Collections.reverse(supportedLanguages.fromLanguages); + } + ArrayAdapter<TranslationsController.Language> fromData = + new ArrayAdapter<TranslationsController.Language>( + this.getBaseContext(), + android.R.layout.simple_spinner_item, + supportedLanguages.fromLanguages); + fromSelect.setAdapter(fromData); + // Set detected language + final int index = + fromData.getPosition( + new TranslationsController.Language(mDetectedLanguage, null)); + fromSelect.setSelection(index); + + ArrayAdapter<TranslationsController.Language> toData = + new ArrayAdapter<TranslationsController.Language>( + this.getBaseContext(), + android.R.layout.simple_spinner_item, + supportedLanguages.toLanguages); + toSelect.setAdapter(toData); + // Set preferred language + TranslationsController.RuntimeTranslation.preferredLanguages() + .then( + preferredList -> { + Log.d(LOGTAG, "Preferred Translation Languages: " + preferredList); + // Reorder dropdown listing based on preferences + for (int i = preferredList.size() - 1; i >= 0; i--) { + final int langIndex = + toData.getPosition( + new TranslationsController.Language(preferredList.get(i), null)); + TranslationsController.Language displayLanguage = + toData.getItem(langIndex); + toData.remove(displayLanguage); + toData.insert(displayLanguage, 0); + if (i == 0) { + toSelect.setSelection(0); + } + } + return null; + }); + return null; + }); + builder.setView( + translateLayout( + fromSelect, + R.string.translate_language_from_hint, + toSelect, + R.string.translate_language_to_hint, + -1)); + builder.setPositiveButton( + R.string.translate_action, + (dialog, which) -> { + final TranslationsController.Language fromLang = + (TranslationsController.Language) fromSelect.getSelectedItem(); + final TranslationsController.Language toLang = + (TranslationsController.Language) toSelect.getSelectedItem(); + session.getSessionTranslation().translate(fromLang.code, toLang.code, null); + mTranslateRestore = true; + }); + builder.setNegativeButton( + R.string.cancel, + (dialog, which) -> { + // Nothing to do + }); + + builder.show(); + } + + private void translateRestore(GeckoSession session) { + session + .getSessionTranslation() + .restoreOriginalPage() + .then( + new GeckoResult.OnValueListener<Void, Object>() { + @Nullable + @Override + public GeckoResult<Object> onValue(@Nullable Void value) throws Throwable { + mTranslateRestore = false; + return null; + } + }); + } + + private void translateManage() { + Spinner languageSelect = new Spinner(this); + Spinner operationSelect = new Spinner(this); + // Should match ModelOperation choices + List<String> operationChoices = + new ArrayList<>( + Arrays.asList( + new String[] { + TranslationsController.RuntimeTranslation.DELETE.toString(), + TranslationsController.RuntimeTranslation.DOWNLOAD.toString() + })); + ArrayAdapter<String> operationData = + new ArrayAdapter<String>( + this.getBaseContext(), android.R.layout.simple_spinner_item, operationChoices); + operationSelect.setAdapter(operationData); + + // Get current model states + GeckoResult<List<TranslationsController.RuntimeTranslation.LanguageModel>> currentStates = + TranslationsController.RuntimeTranslation.listModelDownloadStates(); + currentStates.then( + models -> { + List<TranslationsController.Language> languages = + new ArrayList<TranslationsController.Language>(); + // Pseudo container of "all" just to simplify spinner for GVE + languages.add(new TranslationsController.Language("all", "All Models")); + for (var model : models) { + Log.i(LOGTAG, "Translate Model State: " + model); + languages.add(model.language); + } + ArrayAdapter<TranslationsController.Language> languageData = + new ArrayAdapter<TranslationsController.Language>( + this.getBaseContext(), android.R.layout.simple_spinner_item, languages); + languageSelect.setAdapter(languageData); + return null; + }); + + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.translate_manage); + builder.setView( + translateLayout( + languageSelect, + R.string.translate_manage_languages, + operationSelect, + R.string.translate_manage_operations, + R.string.translate_display_hint)); + builder.setPositiveButton( + R.string.translate_manage_action, + (dialog, which) -> { + final TranslationsController.Language selectedLanguage = + (TranslationsController.Language) languageSelect.getSelectedItem(); + + final String operation = (String) operationSelect.getSelectedItem(); + + String operationLevel = TranslationsController.RuntimeTranslation.LANGUAGE; + // Pseudo option for ease of GVE + if (selectedLanguage.code.equals("all")) { + operationLevel = TranslationsController.RuntimeTranslation.ALL; + } + TranslationsController.RuntimeTranslation.ModelManagementOptions options = + new TranslationsController.RuntimeTranslation.ModelManagementOptions.Builder() + .languageToManage(selectedLanguage.code) + .operation(operation) + .operationLevel(operationLevel) + .build(); + + // Complete Operation + GeckoResult<Void> requestOperation = + TranslationsController.RuntimeTranslation.manageLanguageModel(options); + requestOperation.then( + opt -> { + // Log Changes + GeckoResult<List<TranslationsController.RuntimeTranslation.LanguageModel>> + reportChanges = + TranslationsController.RuntimeTranslation.listModelDownloadStates(); + reportChanges.then( + models -> { + for (var model : models) { + Log.i(LOGTAG, "Translate Model State: " + model); + } + return null; + }); + return null; + }); + }); + builder.setNegativeButton( + R.string.cancel, + (dialog, which) -> { + // Nothing to do + }); + + builder.show(); + } + + private RelativeLayout translateLayout( + Spinner spinnerA, int labelA, Spinner spinnerB, int labelB, int labelInfo) { + // From fields + TextView fromLangLabel = new TextView(this); + fromLangLabel.setText(labelA); + LinearLayout from = new LinearLayout(this); + from.setId(View.generateViewId()); + from.addView(fromLangLabel); + from.addView(spinnerA); + RelativeLayout.LayoutParams fromParams = + new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + fromParams.setMarginStart(30); + + // To fields + TextView toLangLabel = new TextView(this); + toLangLabel.setText(labelB); + LinearLayout to = new LinearLayout(this); + to.setId(View.generateViewId()); + to.addView(toLangLabel); + to.addView(spinnerB); + RelativeLayout.LayoutParams toParams = + new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + toParams.setMarginStart(30); + toParams.addRule(RelativeLayout.BELOW, from.getId()); + + // Layout + RelativeLayout layout = new RelativeLayout(this); + layout.addView(from, fromParams); + layout.addView(to, toParams); + + // Hint + TextView info = new TextView(this); + if (labelInfo != -1) { + RelativeLayout.LayoutParams infoParams = + new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + infoParams.setMarginStart(30); + infoParams.addRule(RelativeLayout.BELOW, to.getId()); + info.setText(labelInfo); + layout.addView(info, infoParams); + } + + return layout; + } + + @Override + public void closeTab(TabSession session) { + mTabSessionManager.closeSession(session); + TabSession tabSession = mTabSessionManager.getCurrentSession(); + setGeckoViewSession(tabSession); + if (tabSession != null) { + tabSession.reload(); + } + mToolbarView.updateTabCount(); + } + + @Override + public void updateTab(TabSession session, WebExtension.UpdateTabDetails details) { + if (details.active == Boolean.TRUE) { + switchToSession(session, false); + } + } + + public void onBrowserActionClick() { + sExtensionManager.onClicked(mTabSessionManager.getCurrentSession()); + } + + public void switchToSession(TabSession session, boolean activateTab) { + TabSession currentSession = mTabSessionManager.getCurrentSession(); + if (session != currentSession) { + setGeckoViewSession(session, activateTab); + mCurrentUri = session.getUri(); + if (!session.isOpen()) { + // Session's process was previously killed; reopen + session.open(sGeckoRuntime); + session.loadUri(mCurrentUri); + } + mToolbarView.getLocationView().setText(mCurrentUri); + } + } + + public void switchToTab(int index) { + TabSession nextSession = mTabSessionManager.getSession(index); + switchToSession(nextSession, true); + } + + private void setGeckoViewSession(TabSession session) { + setGeckoViewSession(session, true); + } + + private void setGeckoViewSession(TabSession session, boolean activateTab) { + final WebExtensionController controller = sGeckoRuntime.getWebExtensionController(); + final GeckoSession previousSession = mGeckoView.getSession(); + if (previousSession != null) { + controller.setTabActive(previousSession, false); + } + + final boolean hasSession = session != null; + final LocationView view = mToolbarView.getLocationView(); + // No point having the URL bar enabled if there's no session to navigate to + view.setEnabled(hasSession); + + if (hasSession) { + mGeckoView.setSession(session); + if (activateTab) { + controller.setTabActive(session, true); + } + mTabSessionManager.setCurrentSession(session); + } else { + mGeckoView.coverUntilFirstPaint(Color.WHITE); + view.setText(""); + } + } + + @Override + public void onDestroy() { + if (mKillProcessOnDestroy) { + android.os.Process.killProcess(android.os.Process.myPid()); + } + + super.onDestroy(); + } + + @Override + protected void onNewIntent(final Intent intent) { + super.onNewIntent(intent); + + if (ACTION_SHUTDOWN.equals(intent.getAction())) { + mKillProcessOnDestroy = true; + if (sGeckoRuntime != null) { + sGeckoRuntime.shutdown(); + } + finish(); + return; + } + + if (intent.hasExtra("onClick")) { + WebNotification notification = intent.getExtras().getParcelable("onClick"); + if (notification != null) { + intent.removeExtra("onClick"); + notification.click(); + } + } + + setIntent(intent); + + if (intent.getData() != null) { + loadFromIntent(intent); + } + } + + private void loadFromIntent(final Intent intent) { + final Uri uri = intent.getData(); + if (uri != null) { + mTabSessionManager + .getCurrentSession() + .load( + new GeckoSession.Loader() + .uri(uri.toString()) + .flags(GeckoSession.LOAD_FLAGS_EXTERNAL)); + } + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (requestCode == REQUEST_FILE_PICKER) { + final BasicGeckoViewPrompt prompt = + (BasicGeckoViewPrompt) mTabSessionManager.getCurrentSession().getPromptDelegate(); + prompt.onFileCallbackResult(resultCode, data); + } else if (mPendingActivityResult.containsKey(requestCode)) { + final GeckoResult<Intent> result = mPendingActivityResult.remove(requestCode); + + if (resultCode == Activity.RESULT_OK) { + result.complete(data); + } else { + result.completeExceptionally(new RuntimeException("Unknown error")); + } + } else { + super.onActivityResult(requestCode, resultCode, data); + } + } + + @Override + public void onRequestPermissionsResult( + final int requestCode, final String[] permissions, final int[] grantResults) { + if (requestCode == REQUEST_PERMISSIONS) { + final ExamplePermissionDelegate permission = + (ExamplePermissionDelegate) + mTabSessionManager.getCurrentSession().getPermissionDelegate(); + permission.onRequestPermissionsResult(permissions, grantResults); + } else if (requestCode == REQUEST_WRITE_EXTERNAL_STORAGE + && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + continueDownloads(); + } else { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + + private void continueDownloads() { + final LinkedList<WebResponse> downloads = mPendingDownloads; + mPendingDownloads = new LinkedList<>(); + + for (final WebResponse response : downloads) { + downloadFile(response); + } + } + + private void downloadFile(final WebResponse response) { + if (response.body == null) { + return; + } + + if (ContextCompat.checkSelfPermission( + GeckoViewActivity.this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + mPendingDownloads.add(response); + ActivityCompat.requestPermissions( + GeckoViewActivity.this, + new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE}, + REQUEST_WRITE_EXTERNAL_STORAGE); + return; + } + + final String filename = getFileName(response); + + try { + String downloadsPath = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + .getAbsolutePath() + + "/" + + filename; + + Log.i(LOGTAG, "Downloading to: " + downloadsPath); + int bufferSize = 1024; // to read in 1Mb increments + byte[] buffer = new byte[bufferSize]; + try (OutputStream out = new BufferedOutputStream(new FileOutputStream(downloadsPath))) { + int len; + while ((len = response.body.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + } catch (Throwable e) { + Log.i(LOGTAG, String.valueOf(e.getStackTrace())); + } + } catch (Throwable e) { + Log.i(LOGTAG, String.valueOf(e.getStackTrace())); + } + } + + private String getFileName(final WebResponse response) { + String filename; + String contentDispositionHeader; + if (response.headers.containsKey("content-disposition")) { + contentDispositionHeader = response.headers.get("content-disposition"); + } else { + contentDispositionHeader = + response.headers.getOrDefault("Content-Disposition", "default filename=GVDownload"); + } + Pattern pattern = Pattern.compile("(filename=\"?)(.+)(\"?)"); + Matcher matcher = pattern.matcher(contentDispositionHeader); + if (matcher.find()) { + filename = matcher.group(2).replaceAll("\\s", "%20"); + } else { + filename = "GVEdownload"; + } + + return filename; + } + + private static boolean isForeground() { + final ActivityManager.RunningAppProcessInfo appProcessInfo = + new ActivityManager.RunningAppProcessInfo(); + ActivityManager.getMyMemoryState(appProcessInfo); + return appProcessInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + || appProcessInfo.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE; + } + + private String mErrorTemplate; + + private String createErrorPage(final String error) { + if (mErrorTemplate == null) { + InputStream stream = null; + BufferedReader reader = null; + StringBuilder builder = new StringBuilder(); + try { + stream = getResources().getAssets().open("error.html"); + reader = new BufferedReader(new InputStreamReader(stream)); + + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + builder.append("\n"); + } + + mErrorTemplate = builder.toString(); + } catch (IOException e) { + Log.d(LOGTAG, "Failed to open error page template", e); + return null; + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + Log.e(LOGTAG, "Failed to close error page template stream", e); + } + } + + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + Log.e(LOGTAG, "Failed to close error page template reader", e); + } + } + } + } + + return mErrorTemplate.replace("$ERROR", error); + } + + private class ExampleHistoryDelegate implements GeckoSession.HistoryDelegate { + private final HashSet<String> mVisitedURLs; + + private ExampleHistoryDelegate() { + mVisitedURLs = new HashSet<String>(); + } + + @Override + public GeckoResult<Boolean> onVisited( + GeckoSession session, String url, String lastVisitedURL, int flags) { + Log.i(LOGTAG, "Visited URL: " + url); + + mVisitedURLs.add(url); + return GeckoResult.fromValue(true); + } + + @Override + public GeckoResult<boolean[]> getVisited(GeckoSession session, String[] urls) { + boolean[] visited = new boolean[urls.length]; + for (int i = 0; i < urls.length; i++) { + visited[i] = mVisitedURLs.contains(urls[i]); + } + return GeckoResult.fromValue(visited); + } + + @Override + public void onHistoryStateChange( + final GeckoSession session, final GeckoSession.HistoryDelegate.HistoryList state) { + Log.i(LOGTAG, "History state updated"); + } + } + + private class ExampleAutocompleteStorageDelegate implements Autocomplete.StorageDelegate { + private Map<String, Autocomplete.LoginEntry> mStorage = new HashMap<>(); + + @Nullable + @Override + public GeckoResult<Autocomplete.LoginEntry[]> onLoginFetch() { + return GeckoResult.fromValue(mStorage.values().toArray(new Autocomplete.LoginEntry[0])); + } + + @Override + public void onLoginSave(@NonNull Autocomplete.LoginEntry login) { + mStorage.put(login.guid, login); + } + } + + private class ExampleOrientationDelegate implements OrientationController.OrientationDelegate { + @Override + public GeckoResult<AllowOrDeny> onOrientationLock(@NonNull int aOrientation) { + setRequestedOrientation(aOrientation); + return GeckoResult.allow(); + } + + @Override + public void onOrientationUnlock() { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + } + + private class ExampleContentDelegate implements GeckoSession.ContentDelegate { + @Override + public void onTitleChange(GeckoSession session, String title) { + Log.i(LOGTAG, "Content title changed to " + title); + TabSession tabSession = mTabSessionManager.getSession(session); + if (tabSession != null) { + tabSession.setTitle(title); + } + } + + @Override + public void onFullScreen(final GeckoSession session, final boolean fullScreen) { + getWindow() + .setFlags( + fullScreen ? WindowManager.LayoutParams.FLAG_FULLSCREEN : 0, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + mFullScreen = fullScreen; + if (fullScreen) { + getSupportActionBar().hide(); + } else { + getSupportActionBar().show(); + } + } + + @Override + public void onFocusRequest(final GeckoSession session) { + Log.i(LOGTAG, "Content requesting focus"); + } + + @Override + public void onCloseRequest(final GeckoSession session) { + final TabSession currentSession = mTabSessionManager.getCurrentSession(); + if (session == currentSession) { + closeTab(currentSession); + } + } + + @Override + public void onContextMenu( + final GeckoSession session, int screenX, int screenY, final ContextElement element) { + Log.d( + LOGTAG, + "onContextMenu screenX=" + + screenX + + " screenY=" + + screenY + + " type=" + + element.type + + " linkUri=" + + element.linkUri + + " title=" + + element.title + + " alt=" + + element.altText + + " srcUri=" + + element.srcUri); + } + + @Override + public void onExternalResponse(@NonNull GeckoSession session, @NonNull WebResponse response) { + downloadFile(response); + } + + @Override + public void onCrash(GeckoSession session) { + Log.e(LOGTAG, "Crashed, reopening session"); + session.open(sGeckoRuntime); + } + + @Override + public void onKill(GeckoSession session) { + TabSession tabSession = mTabSessionManager.getSession(session); + if (tabSession == null) { + return; + } + + if (tabSession != mTabSessionManager.getCurrentSession()) { + Log.e(LOGTAG, "Background session killed"); + return; + } + + if (isForeground()) { + throw new IllegalStateException("Foreground content process unexpectedly killed by OS!"); + } + + Log.e(LOGTAG, "Current session killed, reopening"); + + tabSession.open(sGeckoRuntime); + tabSession.loadUri(tabSession.getUri()); + } + + @Override + public void onFirstComposite(final GeckoSession session) { + Log.d(LOGTAG, "onFirstComposite"); + } + + @Override + public void onWebAppManifest(final GeckoSession session, JSONObject manifest) { + Log.d(LOGTAG, "onWebAppManifest: " + manifest); + } + + private boolean activeAlert = false; + + @Override + public GeckoResult<SlowScriptResponse> onSlowScript( + final GeckoSession geckoSession, final String scriptFileName) { + BasicGeckoViewPrompt prompt = + (BasicGeckoViewPrompt) mTabSessionManager.getCurrentSession().getPromptDelegate(); + if (prompt != null) { + GeckoResult<SlowScriptResponse> result = new GeckoResult<SlowScriptResponse>(); + if (!activeAlert) { + activeAlert = true; + prompt.onSlowScriptPrompt(geckoSession, getString(R.string.slow_script), result); + } + return result.then( + value -> { + activeAlert = false; + return GeckoResult.fromValue(value); + }); + } + return null; + } + + @Override + public void onMetaViewportFitChange(final GeckoSession session, final String viewportFit) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return; + } + WindowManager.LayoutParams layoutParams = getWindow().getAttributes(); + if (viewportFit.equals("cover")) { + layoutParams.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } else if (viewportFit.equals("contain")) { + layoutParams.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER; + } else { + layoutParams.layoutInDisplayCutoutMode = + WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; + } + getWindow().setAttributes(layoutParams); + } + + @Override + public void onProductUrl(@NonNull final GeckoSession session) { + Log.d("Gecko", "onProductUrl"); + } + + @Override + public void onShowDynamicToolbar(final GeckoSession session) { + final View toolbar = findViewById(R.id.toolbar); + if (toolbar != null) { + toolbar.setTranslationY(0f); + mGeckoView.setVerticalClipping(0); + } + } + + @Override + public void onCookieBannerDetected(final GeckoSession session) { + Log.d("BELL", "A cookie banner was detected on this website"); + } + + @Override + public void onCookieBannerHandled(final GeckoSession session) { + Log.d("BELL", "A cookie banner was handled on this website"); + } + } + + private class ExampleProgressDelegate implements GeckoSession.ProgressDelegate { + private ExampleContentBlockingDelegate mCb; + + private ExampleProgressDelegate(final ExampleContentBlockingDelegate cb) { + mCb = cb; + } + + @Override + public void onPageStart(GeckoSession session, String url) { + Log.i(LOGTAG, "Starting to load page at " + url); + Log.i(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - page load start"); + mCb.clearCounters(); + mExpectedTranslate = false; + mTranslateRestore = false; + } + + @Override + public void onPageStop(GeckoSession session, boolean success) { + Log.i(LOGTAG, "Stopping page load " + (success ? "successfully" : "unsuccessfully")); + Log.i(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - page load stop"); + mCb.logCounters(); + } + + @Override + public void onProgressChange(GeckoSession session, int progress) { + Log.i(LOGTAG, "onProgressChange " + progress); + + mProgressView.setProgress(progress); + + if (progress > 0 && progress < 100) { + mProgressView.setVisibility(View.VISIBLE); + } else { + mProgressView.setVisibility(View.GONE); + } + } + + @Override + public void onSecurityChange(GeckoSession session, SecurityInformation securityInfo) { + Log.i(LOGTAG, "Security status changed to " + securityInfo.securityMode); + } + + @Override + public void onSessionStateChange(GeckoSession session, GeckoSession.SessionState state) { + Log.i(LOGTAG, "New Session state: " + state.toString()); + } + } + + private class ExamplePermissionDelegate implements PermissionDelegate { + + public int androidPermissionRequestCode = 1; + private Callback mCallback; + + class ExampleNotificationCallback implements PermissionDelegate.Callback { + private final PermissionDelegate.Callback mCallback; + + ExampleNotificationCallback(final PermissionDelegate.Callback callback) { + mCallback = callback; + } + + @Override + public void reject() { + mShowNotificationsRejected = true; + mCallback.reject(); + } + + @Override + public void grant() { + mShowNotificationsRejected = false; + mCallback.grant(); + } + } + + class ExamplePersistentStorageCallback implements PermissionDelegate.Callback { + private final PermissionDelegate.Callback mCallback; + private final String mUri; + + ExamplePersistentStorageCallback(final PermissionDelegate.Callback callback, String uri) { + mCallback = callback; + mUri = uri; + } + + @Override + public void reject() { + mCallback.reject(); + } + + @Override + public void grant() { + mAcceptedPersistentStorage.add(mUri); + mCallback.grant(); + } + } + + public void onRequestPermissionsResult(final String[] permissions, final int[] grantResults) { + if (mCallback == null) { + return; + } + + final Callback cb = mCallback; + mCallback = null; + for (final int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + // At least one permission was not granted. + cb.reject(); + return; + } + } + cb.grant(); + } + + @Override + public void onAndroidPermissionsRequest( + final GeckoSession session, final String[] permissions, final Callback callback) { + if (Build.VERSION.SDK_INT >= 23) { + // requestPermissions was introduced in API 23. + mCallback = callback; + requestPermissions(permissions, androidPermissionRequestCode); + } else { + callback.grant(); + } + } + + @Override + public GeckoResult<Integer> onContentPermissionRequest( + final GeckoSession session, final ContentPermission perm) { + final int resId; + switch (perm.permission) { + case PERMISSION_GEOLOCATION: + resId = R.string.request_geolocation; + break; + case PERMISSION_DESKTOP_NOTIFICATION: + resId = R.string.request_notification; + break; + case PERMISSION_PERSISTENT_STORAGE: + resId = R.string.request_storage; + break; + case PERMISSION_XR: + resId = R.string.request_xr; + break; + case PERMISSION_AUTOPLAY_AUDIBLE: + case PERMISSION_AUTOPLAY_INAUDIBLE: + if (!mAllowAutoplay.value()) { + return GeckoResult.fromValue(ContentPermission.VALUE_DENY); + } else { + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW); + } + case PERMISSION_MEDIA_KEY_SYSTEM_ACCESS: + resId = R.string.request_media_key_system_access; + break; + case PERMISSION_STORAGE_ACCESS: + resId = R.string.request_storage_access; + break; + default: + return GeckoResult.fromValue(ContentPermission.VALUE_DENY); + } + + final String title = getString(resId, Uri.parse(perm.uri).getAuthority()); + final BasicGeckoViewPrompt prompt = + (BasicGeckoViewPrompt) mTabSessionManager.getCurrentSession().getPromptDelegate(); + return prompt.onPermissionPrompt(session, title, perm); + } + + private String[] normalizeMediaName(final MediaSource[] sources) { + if (sources == null) { + return null; + } + + String[] res = new String[sources.length]; + for (int i = 0; i < sources.length; i++) { + final int mediaSource = sources[i].source; + final String name = sources[i].name; + if (MediaSource.SOURCE_CAMERA == mediaSource) { + if (name.toLowerCase(Locale.ROOT).contains("front")) { + res[i] = getString(R.string.media_front_camera); + } else { + res[i] = getString(R.string.media_back_camera); + } + } else if (!name.isEmpty()) { + res[i] = name; + } else if (MediaSource.SOURCE_MICROPHONE == mediaSource) { + res[i] = getString(R.string.media_microphone); + } else { + res[i] = getString(R.string.media_other); + } + } + + return res; + } + + @Override + public void onMediaPermissionRequest( + final GeckoSession session, + final String uri, + final MediaSource[] video, + final MediaSource[] audio, + final MediaCallback callback) { + // If we don't have device permissions at this point, just automatically reject the request + // as we will have already have requested device permissions before getting to this point + // and if we've reached here and we don't have permissions then that means that the user + // denied them. + if ((audio != null + && ContextCompat.checkSelfPermission( + GeckoViewActivity.this, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) + || (video != null + && ContextCompat.checkSelfPermission( + GeckoViewActivity.this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED)) { + callback.reject(); + return; + } + + final String host = Uri.parse(uri).getAuthority(); + final String title; + if (audio == null) { + title = getString(R.string.request_video, host); + } else if (video == null) { + title = getString(R.string.request_audio, host); + } else { + title = getString(R.string.request_media, host); + } + + String[] videoNames = normalizeMediaName(video); + String[] audioNames = normalizeMediaName(audio); + + final BasicGeckoViewPrompt prompt = + (BasicGeckoViewPrompt) mTabSessionManager.getCurrentSession().getPromptDelegate(); + prompt.onMediaPrompt(session, title, video, audio, videoNames, audioNames, callback); + } + } + + private ContentPermission getTrackingProtectionPermission(final List<ContentPermission> perms) { + for (ContentPermission perm : perms) { + if (perm.permission == PermissionDelegate.PERMISSION_TRACKING) { + return perm; + } + } + return null; + } + + public void shoppingActions(@NonNull final GeckoSession session, @NonNull final String url) { + Spinner actionSelect = new Spinner(this); + List<String> actions = + new ArrayList<>( + Arrays.asList( + new String[] { + "Get Analysis", + "Get Recommendations", + "Create Analysis", + "Get Analysis Status", + "Poll Until Analysis Completed", + "Report Back in Stock", + })); + ArrayAdapter<String> actionData = + new ArrayAdapter<String>( + this.getBaseContext(), android.R.layout.simple_spinner_item, actions); + actionSelect.setAdapter(actionData); + + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.shopping_actions); + builder.setView( + shoppingLayout( + actionSelect, R.string.shopping_manage_actions, R.string.shopping_display_log)); + builder.setPositiveButton( + R.string.shopping_query, + (dialog, which) -> { + final String action = (String) actionSelect.getSelectedItem(); + switch (action) { + case "Get Analysis": + requestAnalysis(session, url); + break; + case "Get Recommendations": + requestRecommendations(session, url); + break; + case "Create Analysis": + requestCreateAnalysis(session, url); + break; + case "Get Analysis Status": + requestAnalysisCreationStatus(session, url); + break; + case "Poll Until Analysis Completed": + pollForAnalysisCompleted(session, url); + break; + case "Report Back in Stock": + reportBackInStock(session, url); + break; + default: + throw new RuntimeException("Unknown action: " + action); + } + }); + builder.setNegativeButton( + R.string.cancel, + (dialog, which) -> { + // Nothing to do + }); + + builder.show(); + } + + private RelativeLayout shoppingLayout(Spinner spinnerA, int labelA, int labelInfo) { + TextView fromLangLabel = new TextView(this); + fromLangLabel.setText(labelA); + LinearLayout action = new LinearLayout(this); + action.setId(View.generateViewId()); + action.addView(fromLangLabel); + action.addView(spinnerA); + RelativeLayout.LayoutParams actionParams = + new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + actionParams.setMarginStart(30); + + // Layout + RelativeLayout layout = new RelativeLayout(this); + layout.addView(action, actionParams); + + // Hint + TextView info = new TextView(this); + if (labelInfo != -1) { + RelativeLayout.LayoutParams infoParams = + new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + infoParams.setMarginStart(30); + infoParams.addRule(RelativeLayout.BELOW, action.getId()); + info.setText(labelInfo); + layout.addView(info, infoParams); + } + + return layout; + } + + public void requestAnalysis(@NonNull final GeckoSession session, @NonNull final String url) { + GeckoResult<GeckoSession.ReviewAnalysis> result = session.requestAnalysis(url); + result.map( + analysis -> { + Log.d(LOGTAG, "Shopping Action: Get analysis: " + analysis); + return analysis; + }); + } + + public void requestCreateAnalysis( + @NonNull final GeckoSession session, @NonNull final String url) { + GeckoResult<String> result = session.requestCreateAnalysis(url); + result.map( + status -> { + Log.d(LOGTAG, "Shopping Action: Create analysis, status: " + status); + return status; + }); + } + + public void requestAnalysisCreationStatus( + @NonNull final GeckoSession session, @NonNull final String url) { + GeckoResult<GeckoSession.AnalysisStatusResponse> result = session.requestAnalysisStatus(url); + result.map( + status -> { + Log.d(LOGTAG, "Shopping Action: Get analysis status: " + status.status); + Log.d(LOGTAG, "Shopping Action: Get analysis status Progress: " + status.progress); + return status; + }); + } + + public void pollForAnalysisCompleted( + @NonNull final GeckoSession session, @NonNull final String url) { + Log.d(LOGTAG, "Shopping Action: Poll until analysis completed"); + GeckoResult<String> result = session.pollForAnalysisCompleted(url); + result.map( + status -> { + Log.d(LOGTAG, "Shopping Action: Get analysis status: " + status); + return status; + }); + } + + public void reportBackInStock(@NonNull final GeckoSession session, @NonNull final String url) { + Log.d(LOGTAG, "Shopping Action: Report back in stock"); + GeckoResult<String> result = session.reportBackInStock(url); + result.map( + message -> { + Log.d(LOGTAG, "Shopping Action: Back in stock status: " + message); + return message; + }); + } + + public void requestRecommendations( + @NonNull final GeckoSession session, @NonNull final String url) { + GeckoResult<List<GeckoSession.Recommendation>> result = session.requestRecommendations(url); + result.map( + recs -> { + List<String> aids = new ArrayList<>(); + for (int i = 0; i < recs.size(); ++i) { + aids.add(recs.get(i).aid); + } + if (aids.size() >= 1) { + Log.d( + LOGTAG, "Shopping Action: Sending attribution events to first AID: " + aids.get(0)); + session + .sendClickAttributionEvent(aids.get(0)) + .then( + new GeckoResult.OnValueListener<Boolean, Void>() { + @Override + public GeckoResult<Void> onValue(final Boolean isSuccessful) { + Log.d( + LOGTAG, + "Shopping Action: Success of click attribution event: " + isSuccessful); + return null; + } + }); + session + .sendImpressionAttributionEvent(aids.get(0)) + .then( + new GeckoResult.OnValueListener<Boolean, Void>() { + @Override + public GeckoResult<Void> onValue(final Boolean isSuccessful) { + Log.d( + LOGTAG, + "Shopping Action: Success of impression attribution event: " + + isSuccessful); + return null; + } + }); + session + .sendPlacementAttributionEvent(aids.get(0)) + .then( + new GeckoResult.OnValueListener<Boolean, Void>() { + @Override + public GeckoResult<Void> onValue(final Boolean isSuccessful) { + Log.d( + LOGTAG, + "Shopping Action: Success of placement attribution event: " + + isSuccessful); + return null; + } + }); + } else { + Log.d(LOGTAG, "Shopping Action: No recommendations. No attribution events were sent."); + } + return recs; + }); + } + + private class ExampleNavigationDelegate implements GeckoSession.NavigationDelegate { + @Override + public void onLocationChange( + GeckoSession session, final String url, final List<ContentPermission> perms) { + mToolbarView.getLocationView().setText(url); + TabSession tabSession = mTabSessionManager.getSession(session); + if (tabSession != null) { + tabSession.onLocationChange(url); + } + mTrackingProtectionPermission = getTrackingProtectionPermission(perms); + mCurrentUri = url; + } + + @Override + public void onCanGoBack(GeckoSession session, boolean canGoBack) { + mCanGoBack = canGoBack; + } + + @Override + public void onCanGoForward(GeckoSession session, boolean canGoForward) { + mCanGoForward = canGoForward; + } + + @Override + public GeckoResult<AllowOrDeny> onLoadRequest( + final GeckoSession session, final LoadRequest request) { + Log.d( + LOGTAG, + "onLoadRequest=" + + request.uri + + " triggerUri=" + + request.triggerUri + + " where=" + + request.target + + " isRedirect=" + + request.isRedirect + + " isDirectNavigation=" + + request.isDirectNavigation); + + return GeckoResult.allow(); + } + + @Override + public GeckoResult<AllowOrDeny> onSubframeLoadRequest( + final GeckoSession session, final LoadRequest request) { + Log.d( + LOGTAG, + "onSubframeLoadRequest=" + + request.uri + + " triggerUri=" + + request.triggerUri + + " isRedirect=" + + request.isRedirect + + "isDirectNavigation=" + + request.isDirectNavigation); + + return GeckoResult.allow(); + } + + @Override + public GeckoResult<GeckoSession> onNewSession(final GeckoSession session, final String uri) { + final TabSession newSession = createSession(); + mToolbarView.updateTabCount(); + setGeckoViewSession(newSession); + // A reference to newSession is stored by mTabSessionManager, + // which prevents the session from being garbage-collected. + return GeckoResult.fromValue(newSession); + } + + private String categoryToString(final int category) { + switch (category) { + case WebRequestError.ERROR_CATEGORY_UNKNOWN: + return "ERROR_CATEGORY_UNKNOWN"; + case WebRequestError.ERROR_CATEGORY_SECURITY: + return "ERROR_CATEGORY_SECURITY"; + case WebRequestError.ERROR_CATEGORY_NETWORK: + return "ERROR_CATEGORY_NETWORK"; + case WebRequestError.ERROR_CATEGORY_CONTENT: + return "ERROR_CATEGORY_CONTENT"; + case WebRequestError.ERROR_CATEGORY_URI: + return "ERROR_CATEGORY_URI"; + case WebRequestError.ERROR_CATEGORY_PROXY: + return "ERROR_CATEGORY_PROXY"; + case WebRequestError.ERROR_CATEGORY_SAFEBROWSING: + return "ERROR_CATEGORY_SAFEBROWSING"; + default: + return "UNKNOWN"; + } + } + + private String errorToString(final int error) { + switch (error) { + case WebRequestError.ERROR_UNKNOWN: + return "ERROR_UNKNOWN"; + case WebRequestError.ERROR_SECURITY_SSL: + return "ERROR_SECURITY_SSL"; + case WebRequestError.ERROR_SECURITY_BAD_CERT: + return "ERROR_SECURITY_BAD_CERT"; + case WebRequestError.ERROR_NET_RESET: + return "ERROR_NET_RESET"; + case WebRequestError.ERROR_NET_INTERRUPT: + return "ERROR_NET_INTERRUPT"; + case WebRequestError.ERROR_NET_TIMEOUT: + return "ERROR_NET_TIMEOUT"; + case WebRequestError.ERROR_CONNECTION_REFUSED: + return "ERROR_CONNECTION_REFUSED"; + case WebRequestError.ERROR_UNKNOWN_PROTOCOL: + return "ERROR_UNKNOWN_PROTOCOL"; + case WebRequestError.ERROR_UNKNOWN_HOST: + return "ERROR_UNKNOWN_HOST"; + case WebRequestError.ERROR_UNKNOWN_SOCKET_TYPE: + return "ERROR_UNKNOWN_SOCKET_TYPE"; + case WebRequestError.ERROR_UNKNOWN_PROXY_HOST: + return "ERROR_UNKNOWN_PROXY_HOST"; + case WebRequestError.ERROR_MALFORMED_URI: + return "ERROR_MALFORMED_URI"; + case WebRequestError.ERROR_REDIRECT_LOOP: + return "ERROR_REDIRECT_LOOP"; + case WebRequestError.ERROR_SAFEBROWSING_PHISHING_URI: + return "ERROR_SAFEBROWSING_PHISHING_URI"; + case WebRequestError.ERROR_SAFEBROWSING_MALWARE_URI: + return "ERROR_SAFEBROWSING_MALWARE_URI"; + case WebRequestError.ERROR_SAFEBROWSING_UNWANTED_URI: + return "ERROR_SAFEBROWSING_UNWANTED_URI"; + case WebRequestError.ERROR_SAFEBROWSING_HARMFUL_URI: + return "ERROR_SAFEBROWSING_HARMFUL_URI"; + case WebRequestError.ERROR_CONTENT_CRASHED: + return "ERROR_CONTENT_CRASHED"; + case WebRequestError.ERROR_OFFLINE: + return "ERROR_OFFLINE"; + case WebRequestError.ERROR_PORT_BLOCKED: + return "ERROR_PORT_BLOCKED"; + case WebRequestError.ERROR_PROXY_CONNECTION_REFUSED: + return "ERROR_PROXY_CONNECTION_REFUSED"; + case WebRequestError.ERROR_FILE_NOT_FOUND: + return "ERROR_FILE_NOT_FOUND"; + case WebRequestError.ERROR_FILE_ACCESS_DENIED: + return "ERROR_FILE_ACCESS_DENIED"; + case WebRequestError.ERROR_INVALID_CONTENT_ENCODING: + return "ERROR_INVALID_CONTENT_ENCODING"; + case WebRequestError.ERROR_UNSAFE_CONTENT_TYPE: + return "ERROR_UNSAFE_CONTENT_TYPE"; + case WebRequestError.ERROR_CORRUPTED_CONTENT: + return "ERROR_CORRUPTED_CONTENT"; + case WebRequestError.ERROR_HTTPS_ONLY: + return "ERROR_HTTPS_ONLY"; + case WebRequestError.ERROR_BAD_HSTS_CERT: + return "ERROR_BAD_HSTS_CERT"; + default: + return "UNKNOWN"; + } + } + + private String createErrorPage(final int category, final int error) { + if (mErrorTemplate == null) { + InputStream stream = null; + BufferedReader reader = null; + StringBuilder builder = new StringBuilder(); + try { + stream = getResources().getAssets().open("error.html"); + reader = new BufferedReader(new InputStreamReader(stream)); + + String line; + while ((line = reader.readLine()) != null) { + builder.append(line); + builder.append("\n"); + } + + mErrorTemplate = builder.toString(); + } catch (IOException e) { + Log.d(LOGTAG, "Failed to open error page template", e); + return null; + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + Log.e(LOGTAG, "Failed to close error page template stream", e); + } + } + + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + Log.e(LOGTAG, "Failed to close error page template reader", e); + } + } + } + } + + return GeckoViewActivity.this.createErrorPage( + categoryToString(category) + " : " + errorToString(error)); + } + + @Override + public GeckoResult<String> onLoadError( + final GeckoSession session, final String uri, final WebRequestError error) { + Log.d( + LOGTAG, + "onLoadError=" + uri + " error category=" + error.category + " error=" + error.code); + + return GeckoResult.fromValue("data:text/html," + createErrorPage(error.category, error.code)); + } + } + + private class ExampleContentBlockingDelegate implements ContentBlocking.Delegate { + private int mBlockedAds = 0; + private int mBlockedAnalytics = 0; + private int mBlockedSocial = 0; + private int mBlockedContent = 0; + private int mBlockedTest = 0; + private int mBlockedStp = 0; + + private void clearCounters() { + mBlockedAds = 0; + mBlockedAnalytics = 0; + mBlockedSocial = 0; + mBlockedContent = 0; + mBlockedTest = 0; + mBlockedStp = 0; + } + + private void logCounters() { + Log.d( + LOGTAG, + "Trackers blocked: " + + mBlockedAds + + " ads, " + + mBlockedAnalytics + + " analytics, " + + mBlockedSocial + + " social, " + + mBlockedContent + + " content, " + + mBlockedTest + + " test, " + + mBlockedStp + + "stp"); + } + + @Override + public void onContentBlocked( + final GeckoSession session, final ContentBlocking.BlockEvent event) { + Log.d( + LOGTAG, + "onContentBlocked" + + " AT: " + + event.getAntiTrackingCategory() + + " SB: " + + event.getSafeBrowsingCategory() + + " CB: " + + event.getCookieBehaviorCategory() + + " URI: " + + event.uri); + if ((event.getAntiTrackingCategory() & ContentBlocking.AntiTracking.TEST) != 0) { + mBlockedTest++; + } + if ((event.getAntiTrackingCategory() & ContentBlocking.AntiTracking.AD) != 0) { + mBlockedAds++; + } + if ((event.getAntiTrackingCategory() & ContentBlocking.AntiTracking.ANALYTIC) != 0) { + mBlockedAnalytics++; + } + if ((event.getAntiTrackingCategory() & ContentBlocking.AntiTracking.SOCIAL) != 0) { + mBlockedSocial++; + } + if ((event.getAntiTrackingCategory() & ContentBlocking.AntiTracking.CONTENT) != 0) { + mBlockedContent++; + } + if ((event.getAntiTrackingCategory() & ContentBlocking.AntiTracking.STP) != 0) { + mBlockedStp++; + } + } + + @Override + public void onContentLoaded( + final GeckoSession session, final ContentBlocking.BlockEvent event) { + Log.d( + LOGTAG, + "onContentLoaded" + + " AT: " + + event.getAntiTrackingCategory() + + " SB: " + + event.getSafeBrowsingCategory() + + " CB: " + + event.getCookieBehaviorCategory() + + " URI: " + + event.uri); + } + } + + private class ExampleMediaDelegate implements GeckoSession.MediaDelegate { + private Integer mLastNotificationId = 100; + private Integer mNotificationId; + private final Activity mActivity; + + public ExampleMediaDelegate(Activity activity) { + mActivity = activity; + } + + @Override + public void onRecordingStatusChanged(@NonNull GeckoSession session, RecordingDevice[] devices) { + String message; + int icon; + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(mActivity); + RecordingDevice camera = null; + RecordingDevice microphone = null; + + for (RecordingDevice device : devices) { + if (device.type == RecordingDevice.Type.CAMERA) { + camera = device; + } else if (device.type == RecordingDevice.Type.MICROPHONE) { + microphone = device; + } + } + if (camera != null && microphone != null) { + Log.d(LOGTAG, "ExampleDeviceDelegate:onRecordingDeviceEvent display alert_mic_camera"); + message = getResources().getString(R.string.device_sharing_camera_and_mic); + icon = R.drawable.alert_mic_camera; + } else if (camera != null) { + Log.d(LOGTAG, "ExampleDeviceDelegate:onRecordingDeviceEvent display alert_camera"); + message = getResources().getString(R.string.device_sharing_camera); + icon = R.drawable.alert_camera; + } else if (microphone != null) { + Log.d(LOGTAG, "ExampleDeviceDelegate:onRecordingDeviceEvent display alert_mic"); + message = getResources().getString(R.string.device_sharing_microphone); + icon = R.drawable.alert_mic; + } else { + Log.d(LOGTAG, "ExampleDeviceDelegate:onRecordingDeviceEvent dismiss any notifications"); + if (mNotificationId != null) { + notificationManager.cancel(mNotificationId); + mNotificationId = null; + } + return; + } + if (mNotificationId == null) { + mNotificationId = ++mLastNotificationId; + } + + Intent intent = new Intent(mActivity, GeckoViewActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + PendingIntent pendingIntent = + PendingIntent.getActivity( + mActivity.getApplicationContext(), 0, intent, PendingIntent.FLAG_IMMUTABLE); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(mActivity.getApplicationContext(), CHANNEL_ID) + .setSmallIcon(icon) + .setContentTitle(getResources().getString(R.string.app_name)) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setCategory(NotificationCompat.CATEGORY_SERVICE); + + notificationManager.notify(mNotificationId, builder.build()); + } + } + + private class ExampleTranslationsSessionDelegate + implements TranslationsController.SessionTranslation.Delegate { + @Override + public void onOfferTranslate(@NonNull GeckoSession session) { + Log.i(LOGTAG, "onOfferTranslate"); + } + + @Override + public void onExpectedTranslate(@NonNull GeckoSession session) { + Log.i(LOGTAG, "onExpectedTranslate"); + mExpectedTranslate = true; + } + + @Override + public void onTranslationStateChange( + @NonNull GeckoSession session, + @Nullable TranslationsController.SessionTranslation.TranslationState translationState) { + Log.i(LOGTAG, "onTranslationStateChange"); + if (translationState.detectedLanguages != null) { + mDetectedLanguage = translationState.detectedLanguages.docLangTag; + } + } + } + + private class ExampleMediaSessionDelegate implements MediaSession.Delegate { + private final Activity mActivity; + + public ExampleMediaSessionDelegate(Activity activity) { + mActivity = activity; + } + + @Override + public void onFullscreen( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + final boolean enabled, + @Nullable final MediaSession.ElementMetadata meta) { + Log.d(LOGTAG, "onFullscreen: Metadata=" + (meta != null ? meta.toString() : "null")); + + if (!enabled) { + mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER); + return; + } + + if (meta == null) { + return; + } + + if (meta.width > meta.height) { + mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); + } else { + mActivity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } + } + + private final class ExampleTelemetryDelegate implements RuntimeTelemetry.Delegate { + @Override + public void onHistogram(final @NonNull RuntimeTelemetry.Histogram histogram) { + Log.d(LOGTAG, "onHistogram " + histogram); + } + + @Override + public void onBooleanScalar(final @NonNull RuntimeTelemetry.Metric<Boolean> scalar) { + Log.d(LOGTAG, "onBooleanScalar " + scalar); + } + + @Override + public void onLongScalar(final @NonNull RuntimeTelemetry.Metric<Long> scalar) { + Log.d(LOGTAG, "onLongScalar " + scalar); + } + + @Override + public void onStringScalar(final @NonNull RuntimeTelemetry.Metric<String> scalar) { + Log.d(LOGTAG, "onStringScalar " + scalar); + } + } + + private class ExampleActivityDelegate implements GeckoView.ActivityContextDelegate { + public Context getActivityContext() { + return GeckoViewActivity.this; + } + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewBottomBehavior.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewBottomBehavior.java new file mode 100644 index 0000000000..c51ab969c6 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/GeckoViewBottomBehavior.java @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview_example; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import androidx.appcompat.widget.Toolbar; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import org.mozilla.geckoview.GeckoView; + +public class GeckoViewBottomBehavior extends CoordinatorLayout.Behavior<GeckoView> { + public GeckoViewBottomBehavior(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + public boolean layoutDependsOn(CoordinatorLayout parent, GeckoView child, View dependency) { + return dependency instanceof Toolbar; + } + + @Override + public boolean onDependentViewChanged( + CoordinatorLayout parent, GeckoView child, View dependency) { + child.setVerticalClipping(Math.round(-dependency.getTranslationY())); + return true; + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/LocationView.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/LocationView.java new file mode 100644 index 0000000000..c09d80509d --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/LocationView.java @@ -0,0 +1,64 @@ +/* -*- 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_example; + +import android.content.Context; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.TextView; +import androidx.appcompat.widget.AppCompatEditText; + +public class LocationView extends AppCompatEditText { + + private CommitListener mCommitListener; + private FocusAndCommitListener mFocusCommitListener = new FocusAndCommitListener(); + + public interface CommitListener { + void onCommit(String text); + } + + public LocationView(Context context) { + super(context); + + this.setInputType(EditorInfo.TYPE_CLASS_TEXT | EditorInfo.TYPE_TEXT_VARIATION_URI); + this.setSingleLine(true); + this.setSelectAllOnFocus(true); + this.setHint(R.string.location_hint); + + setOnFocusChangeListener(mFocusCommitListener); + setOnEditorActionListener(mFocusCommitListener); + } + + public void setCommitListener(CommitListener listener) { + mCommitListener = listener; + } + + private class FocusAndCommitListener implements OnFocusChangeListener, OnEditorActionListener { + private String mInitialText; + private boolean mCommitted; + + @Override + public void onFocusChange(View view, boolean focused) { + if (focused) { + mInitialText = ((TextView) view).getText().toString(); + mCommitted = false; + } else if (!mCommitted) { + setText(mInitialText); + } + } + + @Override + public boolean onEditorAction(TextView textView, int i, KeyEvent keyEvent) { + if (mCommitListener != null) { + mCommitListener.onCommit(textView.getText().toString()); + } + + mCommitted = true; + return true; + } + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/NestedGeckoView.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/NestedGeckoView.java new file mode 100644 index 0000000000..c165acd3c8 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/NestedGeckoView.java @@ -0,0 +1,169 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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_example; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import androidx.core.view.NestedScrollingChild; +import androidx.core.view.NestedScrollingChildHelper; +import androidx.core.view.ViewCompat; +import org.mozilla.geckoview.GeckoView; +import org.mozilla.geckoview.PanZoomController; + +/** + * GeckoView that supports nested scrolls (for using in a CoordinatorLayout). + * + * <p>This code is a simplified version of the NestedScrollView implementation which can be found in + * the support library: [android.support.v4.widget.NestedScrollView] + * + * <p>Based on: https://github.com/takahirom/webview-in-coordinatorlayout + */ +public class NestedGeckoView extends GeckoView implements NestedScrollingChild { + + private int mLastY; + private final int[] mScrollOffset = new int[2]; + private final int[] mScrollConsumed = new int[2]; + private int mNestedOffsetY; + private NestedScrollingChildHelper mChildHelper; + + /** + * Integer indicating how user's MotionEvent was handled. + * + * <p>There must be a 1-1 relation between this values and [EngineView.InputResult]'s. + */ + private int mInputResult = PanZoomController.INPUT_RESULT_UNHANDLED; + + public NestedGeckoView(final Context context) { + this(context, null); + } + + public NestedGeckoView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mChildHelper = new NestedScrollingChildHelper(this); + setNestedScrollingEnabled(true); + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + MotionEvent event = MotionEvent.obtain(ev); + final int action = event.getActionMasked(); + int eventY = (int) event.getY(); + + switch (action) { + case MotionEvent.ACTION_MOVE: + final boolean allowScroll = + !shouldPinOnScreen() && mInputResult == PanZoomController.INPUT_RESULT_HANDLED; + int deltaY = mLastY - eventY; + + if (allowScroll && dispatchNestedPreScroll(0, deltaY, mScrollConsumed, mScrollOffset)) { + deltaY -= mScrollConsumed[1]; + event.offsetLocation(0f, -mScrollOffset[1]); + mNestedOffsetY += mScrollOffset[1]; + } + + mLastY = eventY - mScrollOffset[1]; + + if (allowScroll && dispatchNestedScroll(0, mScrollOffset[1], 0, deltaY, mScrollOffset)) { + mLastY -= mScrollOffset[1]; + event.offsetLocation(0f, mScrollOffset[1]); + mNestedOffsetY += mScrollOffset[1]; + } + break; + + case MotionEvent.ACTION_DOWN: + // A new gesture started. Reset handled status and ask GV if it can handle this. + mInputResult = PanZoomController.INPUT_RESULT_UNHANDLED; + updateInputResult(event); + + mNestedOffsetY = 0; + mLastY = eventY; + + // The event should be handled either by onTouchEvent, + // either by onTouchEventForResult, never by both. + // Early return if we sent it to updateInputResult(..) which calls onTouchEventForResult. + event.recycle(); + return true; + + // We don't care about other touch events + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + stopNestedScroll(); + break; + } + + // Execute event handler from parent class in all cases + final boolean eventHandled = callSuperOnTouchEvent(event); + + // Recycle previously obtained event + event.recycle(); + + return eventHandled; + } + + private boolean callSuperOnTouchEvent(MotionEvent event) { + return super.onTouchEvent(event); + } + + private void updateInputResult(MotionEvent event) { + super.onTouchEventForDetailResult(event) + .accept( + inputResult -> { + mInputResult = inputResult.handledResult(); + startNestedScroll(ViewCompat.SCROLL_AXIS_VERTICAL); + }); + } + + public int getInputResult() { + return mInputResult; + } + + @Override + public void setNestedScrollingEnabled(boolean enabled) { + mChildHelper.setNestedScrollingEnabled(enabled); + } + + @Override + public boolean isNestedScrollingEnabled() { + return mChildHelper.isNestedScrollingEnabled(); + } + + @Override + public boolean startNestedScroll(int axes) { + return mChildHelper.startNestedScroll(axes); + } + + @Override + public void stopNestedScroll() { + mChildHelper.stopNestedScroll(); + } + + @Override + public boolean hasNestedScrollingParent() { + return mChildHelper.hasNestedScrollingParent(); + } + + @Override + public boolean dispatchNestedScroll( + int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed, int[] offsetInWindow) { + return mChildHelper.dispatchNestedScroll( + dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, offsetInWindow); + } + + @Override + public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { + return mChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); + } + + @Override + public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { + return mChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); + } + + @Override + public boolean dispatchNestedPreFling(float velocityX, float velocityY) { + return mChildHelper.dispatchNestedPreFling(velocityX, velocityY); + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/SessionActivity.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/SessionActivity.java new file mode 100644 index 0000000000..e1ad16238c --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/SessionActivity.java @@ -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.geckoview_example; + +public class SessionActivity extends GeckoViewActivity {} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/SettingsActivity.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/SettingsActivity.java new file mode 100644 index 0000000000..e9229a0b1f --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/SettingsActivity.java @@ -0,0 +1,44 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview_example; + +import android.os.Bundle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.preference.PreferenceFragmentCompat; + +public class SettingsActivity extends AppCompatActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_settings); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.container, new SettingsFragment()) + .commit(); + + Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + // add back arrow to toolbar + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setDisplayShowHomeEnabled(true); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + public static class SettingsFragment extends PreferenceFragmentCompat { + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + setPreferencesFromResource(R.xml.settings, rootKey); + } + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/TabSession.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/TabSession.java new file mode 100644 index 0000000000..a7afa51352 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/TabSession.java @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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_example; + +import androidx.annotation.NonNull; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoSessionSettings; +import org.mozilla.geckoview.WebExtension; + +public class TabSession extends GeckoSession { + private String mTitle; + private String mUri; + public WebExtension.Action action; + + public TabSession() { + super(); + } + + public TabSession(GeckoSessionSettings settings) { + super(settings); + } + + public String getTitle() { + return mTitle == null || mTitle.length() == 0 ? "about:blank" : mTitle; + } + + public void setTitle(String title) { + this.mTitle = title; + } + + public String getUri() { + return mUri; + } + + @Override + public void loadUri(@NonNull String uri) { + super.loadUri(uri); + mUri = uri; + } + + public void onLocationChange(@NonNull String uri) { + mUri = uri; + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/TabSessionManager.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/TabSessionManager.java new file mode 100644 index 0000000000..83bd4fa14e --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/TabSessionManager.java @@ -0,0 +1,121 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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_example; + +import androidx.annotation.Nullable; +import java.util.ArrayList; +import org.mozilla.geckoview.GeckoSession; +import org.mozilla.geckoview.GeckoSessionSettings; +import org.mozilla.geckoview.WebExtension; + +public class TabSessionManager { + private static ArrayList<TabSession> mTabSessions = new ArrayList<>(); + private int mCurrentSessionIndex = 0; + private TabObserver mTabObserver; + private boolean mTrackingProtection; + + public interface TabObserver { + void onCurrentSession(TabSession session); + } + + public TabSessionManager() {} + + public void unregisterWebExtension() { + for (final TabSession session : mTabSessions) { + session.action = null; + } + } + + public void setWebExtensionDelegates( + WebExtension extension, + WebExtension.ActionDelegate actionDelegate, + WebExtension.SessionTabDelegate tabDelegate) { + for (final TabSession session : mTabSessions) { + final WebExtension.SessionController sessionController = session.getWebExtensionController(); + sessionController.setActionDelegate(extension, actionDelegate); + sessionController.setTabDelegate(extension, tabDelegate); + } + } + + public void setUseTrackingProtection(boolean trackingProtection) { + if (trackingProtection == mTrackingProtection) { + return; + } + mTrackingProtection = trackingProtection; + + for (final TabSession session : mTabSessions) { + session.getSettings().setUseTrackingProtection(trackingProtection); + } + } + + public void setTabObserver(TabObserver observer) { + mTabObserver = observer; + } + + public void addSession(TabSession session) { + mTabSessions.add(session); + } + + public TabSession getSession(int index) { + if (index >= mTabSessions.size() || index < 0) { + return null; + } + return mTabSessions.get(index); + } + + public TabSession getCurrentSession() { + return getSession(mCurrentSessionIndex); + } + + public TabSession getSession(GeckoSession session) { + int index = mTabSessions.indexOf(session); + if (index == -1) { + return null; + } + return getSession(index); + } + + public void setCurrentSession(TabSession session) { + int index = mTabSessions.indexOf(session); + if (index == -1) { + mTabSessions.add(session); + index = mTabSessions.size() - 1; + } + mCurrentSessionIndex = index; + + if (mTabObserver != null) { + mTabObserver.onCurrentSession(session); + } + } + + private boolean isCurrentSession(TabSession session) { + return session == getCurrentSession(); + } + + public void closeSession(@Nullable TabSession session) { + if (session == null) { + return; + } + if (isCurrentSession(session) && mCurrentSessionIndex == mTabSessions.size() - 1) { + --mCurrentSessionIndex; + } + session.close(); + mTabSessions.remove(session); + } + + public TabSession newSession(GeckoSessionSettings settings) { + TabSession tabSession = new TabSession(settings); + mTabSessions.add(tabSession); + return tabSession; + } + + public int sessionCount() { + return mTabSessions.size(); + } + + public ArrayList<TabSession> getSessions() { + return mTabSessions; + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ToolbarBottomBehavior.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ToolbarBottomBehavior.java new file mode 100644 index 0000000000..af4d3a1e98 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ToolbarBottomBehavior.java @@ -0,0 +1,64 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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_example; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.ViewCompat; +import org.mozilla.geckoview.PanZoomController; + +public class ToolbarBottomBehavior extends CoordinatorLayout.Behavior<View> { + public ToolbarBottomBehavior(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + public boolean onStartNestedScroll( + CoordinatorLayout coordinatorLayout, + View child, + View directTargetChild, + View target, + int axes, + int type) { + NestedGeckoView geckoView = (NestedGeckoView) target; + if (axes == ViewCompat.SCROLL_AXIS_VERTICAL + && geckoView.getInputResult() == PanZoomController.INPUT_RESULT_HANDLED) { + return true; + } + + if (geckoView.getInputResult() == PanZoomController.INPUT_RESULT_UNHANDLED) { + // Restore the toolbar to the original (visible) state, this is what A-C does. + child.setTranslationY(0f); + } + + return false; + } + + @Override + public void onStopNestedScroll( + CoordinatorLayout coordinatorLayout, View child, View target, int type) { + // Snap up or down the user stops scrolling. + if (child.getTranslationY() >= (child.getHeight() / 2f)) { + child.setTranslationY(child.getHeight()); + } else { + child.setTranslationY(0f); + } + } + + @Override + public void onNestedPreScroll( + CoordinatorLayout coordinatorLayout, + View child, + View target, + int dx, + int dy, + int[] consumed, + int type) { + super.onNestedPreScroll(coordinatorLayout, child, target, dx, dy, consumed, type); + child.setTranslationY(Math.max(0f, Math.min(child.getHeight(), child.getTranslationY() + dy))); + } +} diff --git a/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ToolbarLayout.java b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ToolbarLayout.java new file mode 100644 index 0000000000..ce074f2ee3 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/java/org/mozilla/geckoview_example/ToolbarLayout.java @@ -0,0 +1,131 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a 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_example; + +import android.content.Context; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.GradientDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TextView; +import androidx.core.content.ContextCompat; + +public class ToolbarLayout extends LinearLayout { + public interface TabListener { + void switchToTab(int tabId); + + void onBrowserActionClick(); + } + + private LocationView mLocationView; + private Button mTabsCountButton; + private View mBrowserAction; + private TabListener mTabListener; + private TabSessionManager mSessionManager; + + public ToolbarLayout(Context context, TabSessionManager sessionManager) { + super(context); + mSessionManager = sessionManager; + initView(); + } + + private void initView() { + setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, 1.0f)); + setOrientation(LinearLayout.HORIZONTAL); + mLocationView = new LocationView(getContext()); + mLocationView.setLayoutParams( + new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT, 1.0f)); + mLocationView.setId(R.id.url_bar); + addView(mLocationView); + + mTabsCountButton = getTabsCountButton(); + addView(mTabsCountButton); + + mBrowserAction = getBrowserAction(); + addView(mBrowserAction); + } + + private Button getTabsCountButton() { + Button button = new Button(getContext()); + button.setLayoutParams(new LayoutParams(150, LayoutParams.MATCH_PARENT)); + button.setId(R.id.tabs_button); + button.setOnClickListener(this::onTabButtonClicked); + button.setBackground(ContextCompat.getDrawable(getContext(), R.drawable.tab_number_background)); + button.setTypeface(button.getTypeface(), Typeface.BOLD); + return button; + } + + private View getBrowserAction() { + View browserAction = + ((LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.browser_action, this, false); + browserAction.setVisibility(GONE); + return browserAction; + } + + public void setBrowserActionButton(ActionButton button) { + if (button == null) { + mBrowserAction.setVisibility(GONE); + return; + } + + BitmapDrawable drawable = new BitmapDrawable(getContext().getResources(), button.icon); + ImageView view = mBrowserAction.findViewById(R.id.browser_action_icon); + view.setOnClickListener(this::onBrowserActionButtonClicked); + view.setBackground(drawable); + + TextView badge = mBrowserAction.findViewById(R.id.browser_action_badge); + if (button.text != null && !button.text.equals("")) { + if (button.backgroundColor != null) { + GradientDrawable backgroundDrawable = ((GradientDrawable) badge.getBackground().mutate()); + backgroundDrawable.setColor(button.backgroundColor); + backgroundDrawable.invalidateSelf(); + } + if (button.textColor != null) { + badge.setTextColor(button.textColor); + } + badge.setText(button.text); + badge.setVisibility(VISIBLE); + } else { + badge.setVisibility(GONE); + } + + mBrowserAction.setVisibility(VISIBLE); + } + + public void onBrowserActionButtonClicked(View view) { + mTabListener.onBrowserActionClick(); + } + + public LocationView getLocationView() { + return mLocationView; + } + + public void setTabListener(TabListener listener) { + this.mTabListener = listener; + } + + public void updateTabCount() { + mTabsCountButton.setText(String.valueOf(mSessionManager.sessionCount())); + } + + public void onTabButtonClicked(View view) { + PopupMenu tabButtonMenu = new PopupMenu(getContext(), mTabsCountButton); + for (int idx = 0; idx < mSessionManager.sessionCount(); ++idx) { + tabButtonMenu.getMenu().add(0, idx, idx, mSessionManager.getSession(idx).getTitle()); + } + tabButtonMenu.setOnMenuItemClickListener( + item -> { + mTabListener.switchToTab(item.getItemId()); + return true; + }); + tabButtonMenu.show(); + } +} diff --git a/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_camera.png b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_camera.png Binary files differnew file mode 100644 index 0000000000..6c7b806fa9 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_camera.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_mic.png b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_mic.png Binary files differnew file mode 100644 index 0000000000..f56254888b --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_mic.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_mic_camera.png b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_mic_camera.png Binary files differnew file mode 100644 index 0000000000..091ec077dd --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/alert_mic_camera.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-hdpi/ic_crash.png b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/ic_crash.png Binary files differnew file mode 100644 index 0000000000..bb0a241e33 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/ic_crash.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-hdpi/ic_status_logo.png b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/ic_status_logo.png Binary files differnew file mode 100644 index 0000000000..374ef69857 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-hdpi/ic_status_logo.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-mdpi/ic_crash.png b/mobile/android/geckoview_example/src/main/res/drawable-mdpi/ic_crash.png Binary files differnew file mode 100644 index 0000000000..d385b3e5cc --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-mdpi/ic_crash.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_camera.png b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_camera.png Binary files differnew file mode 100644 index 0000000000..efe4811071 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_camera.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_mic.png b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_mic.png Binary files differnew file mode 100644 index 0000000000..198a7ba318 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_mic.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_mic_camera.png b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_mic_camera.png Binary files differnew file mode 100644 index 0000000000..26ba6520b9 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/alert_mic_camera.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/ic_crash.png b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/ic_crash.png Binary files differnew file mode 100644 index 0000000000..3d9bd68082 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xhdpi/ic_crash.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_camera.png b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_camera.png Binary files differnew file mode 100644 index 0000000000..5c21f5bd4f --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_camera.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_mic.png b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_mic.png Binary files differnew file mode 100644 index 0000000000..561816f087 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_mic.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_mic_camera.png b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_mic_camera.png Binary files differnew file mode 100644 index 0000000000..3b34d229ed --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/alert_mic_camera.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/ic_crash.png b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/ic_crash.png Binary files differnew file mode 100644 index 0000000000..39d5c7f57c --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/ic_crash.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/logo.png b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/logo.png Binary files differnew file mode 100644 index 0000000000..162891e993 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable-xxhdpi/logo.png diff --git a/mobile/android/geckoview_example/src/main/res/drawable/rounded_bg.xml b/mobile/android/geckoview_example/src/main/res/drawable/rounded_bg.xml new file mode 100644 index 0000000000..9cfaf557e2 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable/rounded_bg.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + <solid + android:id="@+id/browser_action_badge_background" + android:color="#176d7a" + /> + <corners android:radius="5dp" /> +</shape>
\ No newline at end of file diff --git a/mobile/android/geckoview_example/src/main/res/drawable/tab_number_background.xml b/mobile/android/geckoview_example/src/main/res/drawable/tab_number_background.xml new file mode 100644 index 0000000000..d5292fda8f --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/drawable/tab_number_background.xml @@ -0,0 +1,20 @@ +<?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/. --> +<shape xmlns:android="http://schemas.android.com/apk/res/android" + android:shape="rectangle"> + + <solid android:color="@android:color/transparent" /> + + <corners + android:bottomRightRadius="2dp" + android:bottomLeftRadius="2dp" + android:topLeftRadius="2dp" + android:topRightRadius="2dp"/> + + <stroke + android:width="1dp" + android:color="@android:color/darker_gray" /> + +</shape>
\ No newline at end of file diff --git a/mobile/android/geckoview_example/src/main/res/layout/activity_settings.xml b/mobile/android/geckoview_example/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000000..d6c4025ea6 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/layout/activity_settings.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <androidx.appcompat.widget.Toolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/colorBackgroundDark" + app:theme="@style/ToolBarStyle" + android:elevation="4dp"/> + + <FrameLayout + android:id="@+id/container" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_weight="1" + android:background="@color/colorPrimaryDark"/> + +</LinearLayout> diff --git a/mobile/android/geckoview_example/src/main/res/layout/browser_action.xml b/mobile/android/geckoview_example/src/main/res/layout/browser_action.xml new file mode 100644 index 0000000000..fa06e95e88 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/layout/browser_action.xml @@ -0,0 +1,32 @@ +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="?android:actionBarSize" + android:layout_height="?android:actionBarSize" + android:gravity="center" + android:orientation="vertical"> + + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + > + <ImageView + android:id="@+id/browser_action_icon" + android:layout_width="36dp" + android:layout_height="36dp" + android:layout_centerInParent="true" + /> + </RelativeLayout> + + <TextView + android:id="@+id/browser_action_badge" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:background="@drawable/rounded_bg" + android:textColor="@color/colorPrimaryDark" + android:layout_alignParentRight="true" + android:paddingLeft="3dp" + android:paddingRight="3dp" + android:layout_marginTop="3dp" + android:layout_marginRight="3dp" + android:text="12" + /> +</RelativeLayout> diff --git a/mobile/android/geckoview_example/src/main/res/layout/browser_action_popup.xml b/mobile/android/geckoview_example/src/main/res/layout/browser_action_popup.xml new file mode 100644 index 0000000000..a1bb627d17 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/layout/browser_action_popup.xml @@ -0,0 +1,13 @@ +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:orientation="vertical"> + + <org.mozilla.geckoview.GeckoView + android:id="@+id/gecko_view_popup" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scrollbars="none" + /> +</RelativeLayout> diff --git a/mobile/android/geckoview_example/src/main/res/layout/geckoview_activity.xml b/mobile/android/geckoview_example/src/main/res/layout/geckoview_activity.xml new file mode 100644 index 0000000000..8ee4a615a9 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/layout/geckoview_activity.xml @@ -0,0 +1,32 @@ +<androidx.coordinatorlayout.widget.CoordinatorLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/main" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <org.mozilla.geckoview_example.NestedGeckoView + android:id="@+id/gecko_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:scrollbars="none" + app:layout_behavior="org.mozilla.geckoview_example.GeckoViewBottomBehavior" + /> + + <androidx.appcompat.widget.Toolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="?android:actionBarSize" + android:layout_gravity="bottom" + android:background="#eeeeee" + app:layout_behavior="org.mozilla.geckoview_example.ToolbarBottomBehavior" + app:layout_scrollFlags="scroll|enterAlways|snap|exitUntilCollapsed" /> + + <ProgressBar + android:id="@+id/page_progress" + style="@style/Base.Widget.AppCompat.ProgressBar.Horizontal" + android:layout_width="match_parent" + android:layout_height="3dp" + android:layout_alignTop="@id/gecko_view" /> + +</androidx.coordinatorlayout.widget.CoordinatorLayout> diff --git a/mobile/android/geckoview_example/src/main/res/menu/actions.xml b/mobile/android/geckoview_example/src/main/res/menu/actions.xml new file mode 100644 index 0000000000..0da3bd2bc9 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/menu/actions.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> + <item android:title="@string/tracking_protection_ex" android:checkable="true" + android:id="@+id/action_tpe" app:showAsAction="never" /> + <item android:title="@string/desktop_mode" android:id="@+id/desktop_mode" android:checkable="true" + app:showAsAction="never" /> + <item android:title="@string/collapse" android:id="@+id/collapse" android:checkable="true" + app:showAsAction="never" /> + <item android:title="@string/private_browsing" android:checkable="true" android:id="@+id/action_pb"/> + <item android:title="@string/new_tab" android:id="@+id/action_new_tab"/> + <item android:title="@string/install_addon" android:id="@+id/install_addon"/> + <item android:title="@string/update_addon" android:id="@+id/update_addon"/> + <item android:title="@string/close_tab" android:id="@+id/action_close_tab"/> + <item android:title="@string/forward" android:id="@+id/action_forward"/> + <item android:title="@string/reload" android:id="@+id/action_reload"/> + <item android:title="@string/save_pdf" android:id="@+id/save_pdf"/> + <item android:title="@string/print_page" android:id="@+id/print_page"/> + <item android:title="Shopping Actions" android:id="@+id/shopping_actions"/> + <item android:title="@string/translate" android:id="@+id/translate"/> + <item android:title="@string/translate_restore" android:id="@+id/translate_restore"/> + <item android:title="@string/translate_manage" android:id="@+id/translate_manage"/> + <item android:title="@string/settings" android:id="@+id/settings" app:showAsAction="never" /> +</menu> diff --git a/mobile/android/geckoview_example/src/main/res/values/colors.xml b/mobile/android/geckoview_example/src/main/res/values/colors.xml new file mode 100644 index 0000000000..157834fe0c --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <color name="colorPrimary">#3F51B5</color> + <color name="colorBackgroundDark">#00a996</color> + <color name="colorPrimaryDark">#FFFFFF</color> + <color name="colorAccent">#FF4081</color> +</resources> diff --git a/mobile/android/geckoview_example/src/main/res/values/ids.xml b/mobile/android/geckoview_example/src/main/res/values/ids.xml new file mode 100644 index 0000000000..068f6e3d95 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/values/ids.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <item name="toolbar_layout" type="id"/> + <item name="url_bar" type="id"/> + <item name="browser_action" type="id"/> + <item name="tabs_button" type="id"/> +</resources> diff --git a/mobile/android/geckoview_example/src/main/res/values/strings.xml b/mobile/android/geckoview_example/src/main/res/values/strings.xml new file mode 100644 index 0000000000..13ce38a9e3 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/values/strings.xml @@ -0,0 +1,177 @@ +<resources> + <string name="app_name">GeckoView Example</string> + <string name="activity_label">GeckoView Example</string> + <string name="location_hint">Enter URL or search keywords...</string> + <string name="username">Username</string> + <string name="password">Password</string> + <string name="clear_field">Clear</string> + <string name="request_storage">Allow access to device storage for "%1$s"?</string> + <string name="request_storage_access">Allow third parties to access first party storage for "%1$s"?</string> + <string name="request_geolocation">Share location with "%1$s"?</string> + <string name="request_notification">Allow notifications for "%1$s"?</string> + <string name="request_video">Share video with "%1$s"</string> + <string name="request_audio">Share audio with "%1$s"</string> + <string name="request_media">Share video and audio with "%1$s"</string> + <string name="request_xr">Share WebXR displays with "%1$s"?</string> + <string name="request_autoplay">Allow video to autoplay on "%1$s"?</string> + <string name="request_media_key_system_access">Allow system media key access for "%1$s"?</string> + <string name="media_back_camera">Back camera</string> + <string name="media_front_camera">Front camera</string> + <string name="media_microphone">Microphone</string> + <string name="media_other">Unknown source</string> + + <string name="crash_native">Native</string> + <string name="crash_java">Java</string> + <string name="crash_content_native">Content (Native)</string> + <string name="tracking_protection">Tracking Protection</string> + <string name="tracking_protection_ex">TP exception</string> + <string name="private_browsing">Private Browsing</string> + <string name="global_privacy_control">Global Privacy Control</string> + <string name="remote_debugging">Remote Debugging</string> + <string name="forward">Forward</string> + <string name="reload">Reload</string> + <string name="settings">Settings...</string> + <string name="crashed_title">GeckoView Example Crashed</string> + <string name="crashed_text">Tap to report to Mozilla.</string> + <string name="crashed_ignore">Ignore</string> + <string name="crashed_report">Report</string> + <string name="device_sharing_microphone">Microphone is on</string> + <string name="device_sharing_camera">Camera is on</string> + <string name="device_sharing_camera_and_mic">Camera and microphone are on</string> + <string name="new_tab">New tab</string> + <string name="install_addon">Install Add-on...</string> + <string name="install_addon_hint">Add-on URL: https://addons.mozilla.org/...</string> + <string name="update_addon">Update Add-on</string> + <string name="close_tab">Close tab</string> + <string name="desktop_mode">Desktop site</string> + <string name="collapse">Collapse GV</string> + <string name="slow_script">A script on this page is causing your web browser to run slowly</string> + <string name="wait">Wait</string> + <string name="stop">Stop</string> + <string name="install">Install</string> + <string name="cancel">Cancel</string> + <string name="addon_uri">WebExtension xpi URL</string> + <string name="save_pdf">Save as PDF</string> + <string name="print_page">Print Page</string> + <string name="translate">Translate</string> + <string name="translate_restore">Restore to Original</string> + <string name="translate_language_from_hint">From Language</string> + <string name="translate_language_to_hint">To Language</string> + <string name="translate_action">Translate</string> + <string name="translate_manage">Manage Translate</string> + <string name="translate_manage_languages">Languages</string> + <string name="translate_manage_operations">Operations</string> + <string name="translate_display_hint">See Logcat for State</string> + <string name="translate_manage_action">Update</string> + <string name="shopping_actions">Shopping Actions</string> + <string name="shopping_manage_actions">Actions</string> + <string name="shopping_display_log">See Logcat for "Shopping Action" Results</string> + <string name="shopping_query">Query</string> + + + # Preferences + <string name="key_tracking_protection">tracking_protection</string> + <item type="bool" name="tracking_protection_default">true</item> + + <string name="key_javascript_enabled">javascript_enabled</string> + <item type="bool" name="javascript_enabled_default">true</item> + + <string name="key_extensions_process_enabled">extensions_process_enabled</string> + <item type="bool" name="extensions_process_enabled_default">false</item> + + <string name="key_remote_debugging">remote_debugging</string> + <item type="bool" name="remote_debugging_default">true</item> + + <string name="key_dfpi">dfpi</string> + <item type="bool" name="dfpi_default">false</item> + + <string name="key_autoplay">autoplay</string> + <item type="bool" name="autoplay_default">false</item> + + <string name="key_allow_extensions_in_private_browsing">allow_extensions_in_private_browsing</string> + <item type="bool" name="allow_extensions_in_private_browsing_default">false</item> + + <string name="key_global_privacy_control_enabled">global_privacy_control_enabled</string> + <item type="bool" name="global_privacy_control_enabled_default">false</item> + + <string name="key_etb_private_mode_enabled">etb_private_mode_enabled</string> + <item type="bool" name="etb_private_mode_enabled_default">false</item> + + <string name="key_user_agent_override">user_agent_override</string> + <string name="user_agent_override_default"></string> + <string-array name="user_agent_override_display_names"> + <item>Default</item> + <item>Chrome 80 on Android</item> + <item>Safari 12 on iPhone</item> + </string-array> + <string-array name="user_agent_override_values"> + <item></item> + <item>Mozilla/5.0 (Linux; Android 10; Z832 Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.162 Mobile Safari/537.36</item> + <item>Mozilla/5.0 (iPhone; CPU OS 10_15_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.1 Mobile/14E304 Safari/605.1.15</item> + </string-array> + + <string name="key_cookie_banner_handling">cookie_banner_handling</string> + <string name="key_cookie_banner_handling_pb">cookie_banner_handling_pb</string> + <string name="cookie_banner_handling_default">disabled</string> + <string name="cookie_banner_handling_pb_default">reject_accept_all</string> + <string name="key_enhanced_tracking_protection">enhanced_tracking_protection</string> + <string name="enhanced_tracking_protection_default">standard</string> + <string-array name="enhanced_tracking_protection_display_names"> + <item>Disabled</item> + <item>Enabled (standard)</item> + <item>Enabled (strict)</item> + </string-array> + <string-array name="enhanced_tracking_protection_values"> + <item>disabled</item> + <item>standard</item> + <item>strict</item> + </string-array> + <string-array name="cookie_banner_handling_names"> + <item>Disabled</item> + <item>Enabled (reject all)</item> + <item>Enabled (reject or accept all)</item> + </string-array> + <string-array name="cookie_banner_handling_values"> + <item>disabled</item> + <item>reject_all</item> + <item>reject_accept_all</item> + </string-array> + + <string name="key_preferred_color_scheme">preferred_color_scheme</string> + <item type="integer" name="preferred_color_scheme_default">-1</item> + <string-array name="pref_preferred_color_scheme_display_names"> + <item>Follow System Preference</item> + <item>Light</item> + <item>Dark</item> + </string-array> + <string-array name="pref_preferred_color_scheme_values"> + <item>-1</item> + <item>0</item> + <item>1</item> + </string-array> + + <string name="key_display_mode">display_mode</string> + <item type="integer" name="display_mode_default">0</item> + <string-array name="pref_display_mode_names"> + <item>Browser</item> + <item>MinimalUi</item> + <item>Standalone</item> + <item>Fullscreen</item> + </string-array> + <string-array name="pref_display_mode_values"> + <item>0</item> + <item>1</item> + <item>2</item> + <item>3</item> + </string-array> + + <string name="before_unload_message">This page is asking you to confirm that you want to leave - data you have entered may not be saved</string> + <string name="before_unload_title">Are you sure?</string> + <string name="before_unload_leave_page">Leave Page</string> + <string name="before_unload_stay">Stay on Page</string> + + <string name="repost_confirm_message">To display this page, GeckoViewExample must send information that will repeat any action (such as a search or order confirmation) that was performed earlier.</string> + <string name="repost_confirm_title">Are you sure?</string> + <string name="repost_confirm_resend">Resend</string> + <string name="repost_confirm_cancel">Cancel</string> +</resources> diff --git a/mobile/android/geckoview_example/src/main/res/values/styles.xml b/mobile/android/geckoview_example/src/main/res/values/styles.xml new file mode 100644 index 0000000000..e8420145ba --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/values/styles.xml @@ -0,0 +1,7 @@ +<resources> + <style name="ToolBarStyle" parent="Theme.AppCompat"> + <item name="android:textColorPrimary">@android:color/white</item> + <item name="android:textColorSecondary">@android:color/white</item> + <item name="actionMenuTextColor">@android:color/white</item> + </style> +</resources> diff --git a/mobile/android/geckoview_example/src/main/res/xml/settings.xml b/mobile/android/geckoview_example/src/main/res/xml/settings.xml new file mode 100644 index 0000000000..3d4aa65e06 --- /dev/null +++ b/mobile/android/geckoview_example/src/main/res/xml/settings.xml @@ -0,0 +1,82 @@ +<PreferenceScreen + xmlns:app="http://schemas.android.com/apk/res-auto"> + + <SwitchPreferenceCompat + app:key="@string/key_tracking_protection" + app:title="Enable Tracking Protection" + app:defaultValue="@bool/tracking_protection_default"/> + <ListPreference + app:key="@string/key_enhanced_tracking_protection" + app:title="Enhanced Tracking Protection" + app:summary="%s" + app:entries="@array/enhanced_tracking_protection_display_names" + app:entryValues="@array/enhanced_tracking_protection_values" + app:defaultValue="@string/enhanced_tracking_protection_default"/> + <ListPreference + app:key="@string/key_cookie_banner_handling" + app:title="Cookie Banner Handling" + app:summary="%s" + app:entries="@array/cookie_banner_handling_names" + app:entryValues="@array/cookie_banner_handling_values" + app:defaultValue="@string/cookie_banner_handling_default"/> + <ListPreference + app:key="@string/key_cookie_banner_handling_pb" + app:title="Cookie Banner Handling Private mode" + app:summary="%s" + app:entries="@array/cookie_banner_handling_names" + app:entryValues="@array/cookie_banner_handling_values" + app:defaultValue="@string/cookie_banner_handling_pb_default"/> + <SwitchPreferenceCompat + app:key="@string/key_dfpi" + app:title="Enable Dynamic FPI" + app:defaultValue="@bool/dfpi_default"/> + <SwitchPreferenceCompat + app:key="@string/key_autoplay" + app:title="Allow Autoplay" + app:defaultValue="@bool/autoplay_default"/> + <SwitchPreferenceCompat + app:key="@string/key_remote_debugging" + app:title="Remote Debugging" + app:defaultValue="@bool/remote_debugging_default"/> + <ListPreference + app:key="@string/key_preferred_color_scheme" + app:title="Preferred Color Scheme" + app:summary="%s" + app:entries="@array/pref_preferred_color_scheme_display_names" + app:entryValues="@array/pref_preferred_color_scheme_values" + app:defaultValue="@integer/preferred_color_scheme_default"/> + <ListPreference + app:key="@string/key_user_agent_override" + app:title="User Agent String Override" + app:summary="%s" + app:entries="@array/user_agent_override_display_names" + app:entryValues="@array/user_agent_override_values" + app:defaultValue="@string/user_agent_override_default"/> + <SwitchPreferenceCompat + app:key="@string/key_allow_extensions_in_private_browsing" + app:title="Run extensions in private tabs" + app:defaultValue="@bool/allow_extensions_in_private_browsing_default"/> + <ListPreference + app:key="@string/key_display_mode" + app:title="Display Mode" + app:summary="%s" + app:entries="@array/pref_display_mode_names" + app:entryValues="@array/pref_display_mode_values" + app:defaultValue="@integer/display_mode_default"/> + <SwitchPreferenceCompat + app:key="@string/key_javascript_enabled" + app:title="Javascript Enabled" + app:defaultValue="@bool/javascript_enabled_default"/> + <SwitchPreferenceCompat + app:key="@string/key_extensions_process_enabled" + app:title="Extensions Process Enabled" + app:defaultValue="@bool/extensions_process_enabled_default"/> + <SwitchPreferenceCompat + app:key="@string/key_global_privacy_control_enabled" + app:title="Do not sell or share my data" + app:defaultValue="@bool/global_privacy_control_enabled_default"/> + <SwitchPreferenceCompat + app:key="@string/key_etb_private_mode_enabled" + app:title="Enable ETB in Private Mode" + app:defaultValue="@bool/global_privacy_control_enabled_default"/> +</PreferenceScreen> diff --git a/mobile/android/gen_from_jinja.py b/mobile/android/gen_from_jinja.py new file mode 100644 index 0000000000..6c625226ee --- /dev/null +++ b/mobile/android/gen_from_jinja.py @@ -0,0 +1,40 @@ +# -*- 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/. + +import os + +from jinja2 import Environment, FileSystemLoader, StrictUndefined + + +def main(output_fd, input_filename, *args): + # FileSystemLoader requires the path to the directory containing templates, + # not the file name of the template itself. We hang onto the leaf name + # which will shortly be passed to Environment.get_template. + (path, leaf) = os.path.split(input_filename) + + # Jinja's default value for undefined is too permissive and would allow + # omissions to slip into the generated output. We set undefined to + # StrictUndefined to force Jinja to raise an exception any time a required + # value is missing. + env = Environment( + loader=FileSystemLoader(path, encoding="utf-8"), + autoescape=True, + undefined=StrictUndefined, + ) + tpl = env.get_template(leaf) + + context = dict() + + # args should all be key=value pairs that will be added to the context. + # Note that all values are *strings*, so the Jinja template may need to + # convert them to other types during processing. + # (As in Python, the empty string is falsy, so simple boolean checks are possible) + for arg in args: + (k, v) = arg.split("=", 1) + context[k] = v + + # Now run the template and send its output directly to output_fd + tpl.stream(context).dump(output_fd, encoding="utf-8") diff --git a/mobile/android/gradle.configure b/mobile/android/gradle.configure new file mode 100644 index 0000000000..d13290c1d9 --- /dev/null +++ b/mobile/android/gradle.configure @@ -0,0 +1,678 @@ +# -*- 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/. + +# If --with-gradle is specified, build mobile/android with Gradle. If no +# Gradle binary is specified use the in tree Gradle wrapper. The wrapper +# downloads and installs Gradle, which is good for local developers but not +# good in automation. +option( + "--without-gradle", + nargs="?", + help="Disable building mobile/android with Gradle " + "(argument: location of binary or wrapper (gradle/gradlew))", +) + + +@depends("--with-gradle") +def with_gradle(value): + if not value: + die( + "Building --without-gradle is no longer supported: " + "see https://bugzilla.mozilla.org/show_bug.cgi?id=1414415." + ) + + if value: + return True + + +@depends(host, "--with-gradle", build_environment) +@imports(_from="os.path", _import="isfile") +def gradle(host, value, build_env): + if len(value): + gradle = value[0] + else: + gradle = os.path.join(build_env.topsrcdir, "gradlew") + if host.os == "WINNT": + gradle = gradle + ".bat" + + # TODO: verify that $GRADLE is executable. + if not isfile(gradle): + die("GRADLE must be executable: %s", gradle) + + return gradle + + +set_config("GRADLE", gradle) + + +@dependable +@imports(_from="itertools", _import="chain") +def gradle_android_build_config(): + def capitalize(s): + # str.capitalize lower cases trailing letters. + if s: + return s[0].upper() + s[1:] + else: + return s + + def variant(productFlavors, buildType): + return namespace( + productFlavors=productFlavors, + buildType=buildType, + # Like 'WithoutGeckoBinariesDebug' + name="".join(capitalize(t) for t in chain(productFlavors, (buildType,))), + ) + + return namespace( + geckoview=namespace( + variant=variant(("withGeckoBinaries",), "debug"), + ), + geckoview_example=namespace( + variant=variant(("withGeckoBinaries",), "debug"), + ), + ) + + +@depends(gradle_android_build_config) +def gradle_android_intermediates_folder(build_config): + """Path to intermediates classes folder.""" + + def uncapitalize(s): + if s: + return s[0].lower() + s[1:] + else: + return s + + def capitalize(s): + # str.capitalize lower cases trailing letters. + if s: + return s[0].upper() + s[1:] + else: + return s + + productFlavor = uncapitalize( + "".join(capitalize(f) for f in build_config.geckoview.variant.productFlavors) + ) + buildType = uncapitalize(build_config.geckoview.variant.buildType) + + return ( + "gradle/build/mobile/android/geckoview/intermediates/javac/{}{}/classes".format( + productFlavor, + capitalize(buildType), + ) + ) + + +set_config( + "GRADLE_ANDROID_GECKOVIEW_APILINT_FOLDER", gradle_android_intermediates_folder +) + + +@depends(gradle_android_build_config) +def gradle_android_geckoview_test_runner_bundle(build_config): + """Path to intermediates classes folder.""" + + def uncapitalize(s): + if s: + return s[0].lower() + s[1:] + else: + return s + + def capitalize(s): + # str.capitalize lower cases trailing letters. + if s: + return s[0].upper() + s[1:] + else: + return s + + productFlavor = uncapitalize( + "".join(capitalize(f) for f in build_config.geckoview.variant.productFlavors) + ) + buildType = uncapitalize(build_config.geckoview.variant.buildType) + variant = uncapitalize(build_config.geckoview.variant.name) + + return "gradle/build/mobile/android/test_runner/outputs/bundle/{}/test_runner-{}-{}.aab".format( + variant, + productFlavor, + buildType, + ) + + +set_config( + "GRADLE_ANDROID_GECKOVIEW_TEST_RUNNER_BUNDLE", + gradle_android_geckoview_test_runner_bundle, +) + + +@depends(gradle_android_build_config) +def gradle_android_geckoview_example_bundle(build_config): + """Path to intermediates classes folder.""" + + def uncapitalize(s): + if s: + return s[0].lower() + s[1:] + else: + return s + + def capitalize(s): + # str.capitalize lower cases trailing letters. + if s: + return s[0].upper() + s[1:] + else: + return s + + productFlavor = uncapitalize( + "".join(capitalize(f) for f in build_config.geckoview.variant.productFlavors) + ) + buildType = uncapitalize(build_config.geckoview.variant.buildType) + variant = uncapitalize(build_config.geckoview.variant.name) + + return "gradle/build/mobile/android/geckoview_example/outputs/bundle/{}/geckoview_example-{}-{}.aab".format( + variant, + productFlavor, + buildType, + ) + + +set_config( + "GRADLE_ANDROID_GECKOVIEW_EXAMPLE_BUNDLE", gradle_android_geckoview_example_bundle +) + + +@depends(gradle_android_build_config) +def gradle_android_variant_name(build_config): + """Like "withoutGeckoBinariesDebug".""" + + def uncapitalize(s): + if s: + return s[0].lower() + s[1:] + else: + return s + + return namespace( + geckoview=uncapitalize(build_config.geckoview.variant.name), + ) + + +set_config( + "GRADLE_ANDROID_GECKOVIEW_VARIANT_NAME", gradle_android_variant_name.geckoview +) + + +@depends(gradle_android_build_config) +def gradle_android_app_tasks(build_config): + """Gradle tasks run by |mach android assemble-app|.""" + return [ + "geckoview:generateJNIWrappersForGenerated{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config("GRADLE_ANDROID_APP_TASKS", gradle_android_app_tasks) + + +@dependable +def gradle_android_generate_sdk_bindings_tasks(): + """Gradle tasks run by |mach android generate-sdk-bindings|.""" + return [ + "geckoview:generateSDKBindings", + ] + + +set_config( + "GRADLE_ANDROID_GENERATE_SDK_BINDINGS_TASKS", + gradle_android_generate_sdk_bindings_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_generate_generated_jni_wrappers_tasks(build_config): + """Gradle tasks run by |mach android generate-generated-jni-wrappers|.""" + return [ + "geckoview:generateJNIWrappersForGenerated{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config( + "GRADLE_ANDROID_GENERATE_GENERATED_JNI_WRAPPERS_TASKS", + gradle_android_generate_generated_jni_wrappers_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_test_tasks(build_config): + """Gradle tasks run by |mach android test|.""" + return [ + "geckoview:test{geckoview.variant.name}UnitTest".format( + geckoview=build_config.geckoview + ), + ] + + +set_config("GRADLE_ANDROID_TEST_TASKS", gradle_android_test_tasks) + + +@depends(gradle_android_build_config) +def gradle_android_lint_tasks(build_config): + """Gradle tasks run by |mach android lint|.""" + return [ + "geckoview:lint{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config("GRADLE_ANDROID_LINT_TASKS", gradle_android_lint_tasks) + + +@depends(gradle_android_build_config) +def gradle_android_api_lint_tasks(build_config): + """Gradle tasks run by |mach android api-lint|.""" + return [ + "geckoview:apiLint{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config("GRADLE_ANDROID_API_LINT_TASKS", gradle_android_api_lint_tasks) + + +set_config( + "GRADLE_ANDROID_FORMAT_LINT_FIX_TASKS", ["spotlessJavaApply", "spotlessKotlinApply"] +) + + +@dependable +def gradle_android_format_lint_check_tasks(): + return ["spotlessJavaCheck", "spotlessKotlinCheck"] + + +set_config( + "GRADLE_ANDROID_FORMAT_LINT_CHECK_TASKS", gradle_android_format_lint_check_tasks +) + +set_config( + "GRADLE_ANDROID_FORMAT_LINT_FOLDERS", + [ + "mobile/android/annotations", + "mobile/android/geckoview", + "mobile/android/geckoview_example", + "mobile/android/test_runner", + "mobile/android/examples/messaging_example", + "mobile/android/examples/port_messaging_example", + ], +) + + +@depends(gradle_android_build_config) +def gradle_android_checkstyle_tasks(build_config): + """Gradle tasks run by |mach android checkstyle|.""" + return [ + "geckoview:checkstyle{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config("GRADLE_ANDROID_CHECKSTYLE_TASKS", gradle_android_checkstyle_tasks) + + +@depends(gradle_android_build_config) +def gradle_android_checkstyle_output_files(build_config): + def uncapitalize(s): + if s: + return s[0].lower() + s[1:] + else: + return s + + variant = uncapitalize(build_config.geckoview.variant.name) + + """Output folder for checkstyle""" + return [ + "gradle/build/mobile/android/geckoview/reports/checkstyle/{}.xml".format( + variant + ), + ] + + +set_config( + "GRADLE_ANDROID_CHECKSTYLE_OUTPUT_FILES", gradle_android_checkstyle_output_files +) + + +option( + "--disable-android-bundle", + help="{Enable|Disable} AAB build.", +) + +imply_option("--disable-android-bundle", False, when="--enable-address-sanitizer") + + +@depends(gradle_android_build_config, "--disable-android-bundle") +def gradle_android_archive_geckoview_tasks(build_config, aab_enabled): + """Gradle tasks run by |mach android archive-geckoview|.""" + tasks = [ + "geckoview:assemble{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + "geckoview:assemble{geckoview.variant.name}AndroidTest".format( + geckoview=build_config.geckoview + ), + "test_runner:assemble{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "geckoview_example:assemble{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "messaging_example:assemble{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "port_messaging_example:assemble{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "geckoview:publish{geckoview.variant.name}PublicationToMavenRepository".format( + geckoview=build_config.geckoview + ), + "exoplayer2:publishDebugPublicationToMavenRepository", + ] + + if aab_enabled: + tasks += [ + "test_runner:bundle{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "geckoview_example:bundle{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + ] + return tasks + + +set_config( + "GRADLE_ANDROID_ARCHIVE_GECKOVIEW_TASKS", gradle_android_archive_geckoview_tasks +) + + +@depends(gradle_android_build_config) +def gradle_android_geckoview_docs_tasks(build_config): + """Gradle tasks run by |mach android geckoview-docs|.""" + return [ + "geckoview:javadoc{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config("GRADLE_ANDROID_GECKOVIEW_DOCS_TASKS", gradle_android_geckoview_docs_tasks) + + +@depends(gradle_android_build_config) +def gradle_android_geckoview_docs_archive_tasks(build_config): + """Gradle tasks run by |mach android geckoview-docs --archive| or |... --upload.""" + return [ + "geckoview:javadocCopyJar{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config( + "GRADLE_ANDROID_GECKOVIEW_DOCS_ARCHIVE_TASKS", + gradle_android_geckoview_docs_archive_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_geckoview_docs_output_files(build_config): + """Output files for GeckoView javadoc.""" + + def uncapitalize(s): + if s: + return s[0].lower() + s[1:] + else: + return s + + variant = uncapitalize(build_config.geckoview.variant.name) + + return [ + "gradle/build/mobile/android/geckoview/reports/javadoc-results-{}.json".format( + variant + ), + ] + + +set_config( + "GRADLE_ANDROID_GECKOVIEW_DOCS_OUTPUT_FILES", + gradle_android_geckoview_docs_output_files, +) + + +@depends(gradle_android_build_config) +def gradle_android_archive_coverage_artifacts_tasks(build_config): + """Gradle tasks run by |mach android archive-coverage-artifacts|.""" + return [ + "geckoview:archiveClassfiles{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + "geckoview:copyCoverageDependencies", + ] + + +set_config( + "GRADLE_ANDROID_ARCHIVE_COVERAGE_ARTIFACTS_TASKS", + gradle_android_archive_coverage_artifacts_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_build_geckoview_example_tasks(build_config): + """Gradle tasks run by |mach android build-geckoview_example|.""" + return [ + "geckoview_example:assemble{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "geckoview_example:bundle{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + "geckoview:assemble{geckoview.variant.name}AndroidTest".format( + geckoview=build_config.geckoview + ), + "test_runner:assemble{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + "test_runner:bundle{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config( + "GRADLE_ANDROID_BUILD_GECKOVIEW_EXAMPLE_TASKS", + gradle_android_build_geckoview_example_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_compile_all_tasks(build_config): + """Gradle tasks run by |mach android compile-all|.""" + + def capitalize(s): + # str.capitalize lower cases trailing letters. + if s: + return s[0].upper() + s[1:] + else: + return s + + buildType = capitalize(build_config.geckoview.variant.buildType) + + tasks = [ + f"compileJava", + f"compileTestJava", + f"compile{buildType}Sources", + f"compile{buildType}UnitTestSources", + f"geckoview:compile{build_config.geckoview.variant.name}Sources", + f"geckoview:compile{build_config.geckoview.variant.name}UnitTestSources", + f"test_runner:compile{build_config.geckoview.variant.name}Sources", + f"test_runner:compile{build_config.geckoview.variant.name}UnitTestSources", + f"messaging_example:compile{build_config.geckoview.variant.name}Sources", + f"messaging_example:compile{build_config.geckoview.variant.name}UnitTestSources", + f"port_messaging_example:compile{build_config.geckoview.variant.name}Sources", + f"port_messaging_example:compile{build_config.geckoview.variant.name}UnitTestSources", + f"geckoview_example:compile{build_config.geckoview_example.variant.name}Sources", + f"geckoview_example:compile{build_config.geckoview_example.variant.name}UnitTestSources", + ] + + if buildType == "Debug": + tasks += [ + f"compile{buildType}AndroidTestSources", + f"geckoview:compile{build_config.geckoview.variant.name}AndroidTestSources", + f"test_runner:compile{build_config.geckoview.variant.name}AndroidTestSources", + f"messaging_example:compile{build_config.geckoview.variant.name}AndroidTestSources", + f"port_messaging_example:compile{build_config.geckoview.variant.name}AndroidTestSources", + f"geckoview_example:compile{build_config.geckoview_example.variant.name}AndroidTestSources", + ] + + return tasks + + +set_config( + "GRADLE_ANDROID_COMPILE_ALL_TASKS", + gradle_android_compile_all_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_install_geckoview_test_runner_tasks(build_config): + """Gradle tasks run by |mach android install-geckoview-test_runner|.""" + return [ + "test_runner:install{geckoview.variant.name}".format( + geckoview=build_config.geckoview + ), + ] + + +set_config( + "GRADLE_ANDROID_INSTALL_GECKOVIEW_TEST_RUNNER_TASKS", + gradle_android_install_geckoview_test_runner_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_install_geckoview_test_tasks(build_config): + """Gradle tasks run by |mach android install-geckoview-test|.""" + return [ + "geckoview:install{geckoview.variant.name}AndroidTest".format( + geckoview=build_config.geckoview + ), + ] + + +set_config( + "GRADLE_ANDROID_INSTALL_GECKOVIEW_TEST_TASKS", + gradle_android_install_geckoview_test_tasks, +) + + +@depends(gradle_android_build_config) +def gradle_android_install_geckoview_example_tasks(build_config): + """Gradle tasks run by |mach android install-geckoview_example|.""" + return [ + "geckoview_example:install{geckoview_example.variant.name}".format( + geckoview_example=build_config.geckoview_example + ), + ] + + +set_config( + "GRADLE_ANDROID_INSTALL_GECKOVIEW_EXAMPLE_TASKS", + gradle_android_install_geckoview_example_tasks, +) + + +@depends( + gradle_android_api_lint_tasks, + gradle_android_format_lint_check_tasks, + gradle_android_checkstyle_tasks, +) +@imports(_from="itertools", _import="chain") +def gradle_android_dependencies_tasks(*tasks): + """Gradle tasks run by |mach android dependencies|.""" + + # The union, plus a bit more, of all of the Gradle tasks + # invoked by the android-* automation jobs. + def withoutGeckoBinaries(task): + return task.replace("withGeckoBinaries", "withoutGeckoBinaries") + + return list(withoutGeckoBinaries(t) for t in chain(*tasks)) + + +set_config("GRADLE_ANDROID_DEPENDENCIES_TASKS", gradle_android_dependencies_tasks) + + +# Automation uses this to change log levels, not use the daemon, and use +# offline mode. +option(env="GRADLE_FLAGS", default="", help="Flags to pass to Gradle.") + + +@depends("GRADLE_FLAGS") +def gradle_flags(value): + return value[0] if value else "" + + +set_config("GRADLE_FLAGS", gradle_flags) + +# Automation will set this to (file:///path/to/local, ...) via the mozconfig. +# Local developer default is (maven.google.com). +option( + env="GRADLE_MAVEN_REPOSITORIES", + nargs="+", + default=( + "https://maven.mozilla.org/maven2/", + "https://maven.google.com/", + "https://repo.maven.apache.org/maven2/", + "https://plugins.gradle.org/m2/", + ), + help="Comma-separated URLs of Maven repositories containing Gradle dependencies.", +) + +option( + "--allow-insecure-gradle-repositories", + help="Gradle is allowed to connect to insecure Maven repositories.", +) + +set_config( + "ALLOW_INSECURE_GRADLE_REPOSITORIES", + True, + when="--allow-insecure-gradle-repositories", +) + +option( + "--download-all-gradle-dependencies", + help="Download all dependencies, even those that are conditionally used.", +) + +set_config( + "DOWNLOAD_ALL_GRADLE_DEPENDENCIES", + True, + when="--download-all-gradle-dependencies", +) + + +@depends("GRADLE_MAVEN_REPOSITORIES") +@imports(_from="os.path", _import="isdir") +def gradle_maven_repositories(values): + if not values: + die("GRADLE_MAVEN_REPOSITORIES must not be empty") + if not all(values): + die("GRADLE_MAVEN_REPOSITORIES entries must not be empty") + return values + + +set_config("GRADLE_MAVEN_REPOSITORIES", gradle_maven_repositories) diff --git a/mobile/android/gradle.py b/mobile/android/gradle.py new file mode 100644 index 0000000000..9f310ddb7b --- /dev/null +++ b/mobile/android/gradle.py @@ -0,0 +1,60 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import os +import subprocess +import sys +from contextlib import contextmanager + +import mozpack.path as mozpath +from mozbuild.util import ensureParentDir, lock_file + + +@contextmanager +def gradle_lock(topobjdir, max_wait_seconds=600): + # Building the same Gradle root project with multiple concurrent processes + # is not well supported, so we use a simple lock file to serialize build + # steps. + lock_path = "{}/gradle/mach_android.lockfile".format(topobjdir) + ensureParentDir(lock_path) + lock_instance = lock_file(lock_path, max_wait=max_wait_seconds) + + try: + yield + finally: + del lock_instance + + +def android(verb, *args): + import buildconfig + + with gradle_lock(buildconfig.topobjdir): + cmd = [ + sys.executable, + mozpath.join(buildconfig.topsrcdir, "mach"), + "android", + verb, + ] + cmd.extend(args) + env = dict(os.environ) + # Confusingly, `MACH` is set only within `mach build`. + if env.get("MACH"): + env["GRADLE_INVOKED_WITHIN_MACH_BUILD"] = "1" + if env.get("LD_LIBRARY_PATH"): + del env["LD_LIBRARY_PATH"] + subprocess.check_call(cmd, env=env) + + return 0 + + +def assemble_app(dummy_output_file, *inputs): + return android("assemble-app") + + +def generate_sdk_bindings(dummy_output_file, *args): + return android("generate-sdk-bindings", *args) + + +def generate_generated_jni_wrappers(dummy_output_file, *args): + return android("generate-generated-jni-wrappers", *args) diff --git a/mobile/android/gradle/debug_level.gradle b/mobile/android/gradle/debug_level.gradle new file mode 100644 index 0000000000..a9537da327 --- /dev/null +++ b/mobile/android/gradle/debug_level.gradle @@ -0,0 +1,17 @@ +/* -*- Mode: Groovy; 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/. */ + +// Bug 1353055 - Strip 'vars' debugging information to agree with moz.build. +ext.configureVariantDebugLevel = { variant -> + // Like 'debug' or 'release'. + def buildType = variant.buildType.name + + // The default is 'lines,source,vars', which includes debugging information + // that is quite large: roughly 500kb for Fennec. Therefore we remove + // 'vars' unless we're producing a debug build, where it is useful. + if (!'debug'.equals(buildType) || mozconfig.substs.MOZILLA_OFFICIAL) { + variant.javaCompileProvider.get().options.debugOptions.debugLevel = 'lines,source' + } +} diff --git a/mobile/android/gradle/dotgradle-offline/gradle.properties b/mobile/android/gradle/dotgradle-offline/gradle.properties new file mode 100644 index 0000000000..3f77ec9a2f --- /dev/null +++ b/mobile/android/gradle/dotgradle-offline/gradle.properties @@ -0,0 +1,3 @@ +// Per https://docs.gradle.org/current/userguide/build_environment.html, this +// overrides the gradle.properties in topsrcdir. +org.gradle.daemon=false diff --git a/mobile/android/gradle/dotgradle-offline/init.gradle b/mobile/android/gradle/dotgradle-offline/init.gradle new file mode 100644 index 0000000000..8f06472aed --- /dev/null +++ b/mobile/android/gradle/dotgradle-offline/init.gradle @@ -0,0 +1,4 @@ +// From https://discuss.gradle.org/t/enable-offline-mode-using-gradle-properties/12134/2. +startParameter.offline = true +// Sadly, this doesn't work: see http://stackoverflow.com/a/19686585. +// startParameter.logLevel = org.gradle.api.logging.LogLevel.INFO diff --git a/mobile/android/gradle/dotgradle-online/gradle.properties b/mobile/android/gradle/dotgradle-online/gradle.properties new file mode 100644 index 0000000000..3f77ec9a2f --- /dev/null +++ b/mobile/android/gradle/dotgradle-online/gradle.properties @@ -0,0 +1,3 @@ +// Per https://docs.gradle.org/current/userguide/build_environment.html, this +// overrides the gradle.properties in topsrcdir. +org.gradle.daemon=false diff --git a/mobile/android/gradle/dotgradle-online/init.gradle b/mobile/android/gradle/dotgradle-online/init.gradle new file mode 100644 index 0000000000..dbba0e3da5 --- /dev/null +++ b/mobile/android/gradle/dotgradle-online/init.gradle @@ -0,0 +1,4 @@ +// From https://discuss.gradle.org/t/enable-offline-mode-using-gradle-properties/12134/2. +startParameter.offline = false +// Sadly, this doesn't work: see http://stackoverflow.com/a/19686585. +// startParameter.logLevel = org.gradle.api.logging.LogLevel.INFO diff --git a/mobile/android/gradle/mach_env.gradle b/mobile/android/gradle/mach_env.gradle new file mode 100644 index 0000000000..560d7dac22 --- /dev/null +++ b/mobile/android/gradle/mach_env.gradle @@ -0,0 +1,29 @@ +ext.machEnv = { topsrcdir -> + // Allow to specify mozconfig in `local.properties` via + // `mozilla-central.mozconfig=/path/to/mozconfig`. This can't be an environment + // variable because it's not feasible to specify environment variables under + // Android Studio on some platforms including macOS. + def localProperties = new Properties() + def localPropertiesFile = new File(topsrcdir, 'local.properties') + if (localPropertiesFile.canRead()) { + localPropertiesFile.withInputStream { + localProperties.load(it) + logger.lifecycle("settings.gradle> Read local.properties: ${localPropertiesFile}") + } + } + + def localMozconfig = localProperties.getProperty("mozilla-central.mozconfig") + + def env = System.env.collect { k, v -> "${k}=${v}" } + if (localMozconfig) { + def envMozconfig = System.env.get('FOUND_MOZCONFIG') + if (!envMozconfig || localMozconfig == envMozconfig) { + logger.lifecycle("settings.gradle> Setting mozconfig from local.properties: ${localMozconfig}") + env << "MOZCONFIG=${localMozconfig}" + } else { + logger.lifecycle("settings.gradle> Preferring mozconfig set in mach environment to mozconfig set in local.properties: ${envMozconfig}") + } + } + + return env +} diff --git a/mobile/android/gradle/product_flavors.gradle b/mobile/android/gradle/product_flavors.gradle new file mode 100644 index 0000000000..6278d9ff3f --- /dev/null +++ b/mobile/android/gradle/product_flavors.gradle @@ -0,0 +1,17 @@ +/* -*- Mode: Groovy; 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/. */ + +ext.configureProductFlavors = { + flavorDimensions "geckoBinaries" + productFlavors { + withGeckoBinaries { + dimension "geckoBinaries" + } + + withoutGeckoBinaries { + dimension "geckoBinaries" + } + } +} diff --git a/mobile/android/gradle/with_gecko_binaries.gradle b/mobile/android/gradle/with_gecko_binaries.gradle new file mode 100644 index 0000000000..009a1f586e --- /dev/null +++ b/mobile/android/gradle/with_gecko_binaries.gradle @@ -0,0 +1,81 @@ +/* -*- Mode: Groovy; 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/. */ + +// The JNI wrapper generation tasks depend on the JAR creation task of the :annotations project. +evaluationDependsOn(':annotations') + +// Whether to include compiled artifacts: `lib/**/*.so` and `assets/omni.ja`. +// Multi-locale packaging wants to include compiled artifacts but *not* rebuild +// them: see also `rootProject.{machStagePackage,geckoBinariesOnlyIf}`. +def hasCompileArtifacts() { + return project.mozconfig.substs.COMPILE_ENVIRONMENT // Full builds. + || project.mozconfig.substs.MOZ_ARTIFACT_BUILDS // Artifact builds. + || System.getenv("MOZ_CHROME_MULTILOCALE") // Multi-locale packaging. +} + +ext.configureVariantWithGeckoBinaries = { variant -> + if (hasCompileArtifacts()) { + // Local (read, not 'official') builds want to reflect developer changes to + // the omnijar sources, and (when compiling) to reflect developer changes to + // the native binaries. To do this, the Gradle build calls out to the + // moz.build system, which can be re-entrant. Official builds are driven by + // the moz.build system and should never be re-entrant in this way. + def assetGenTask = tasks.findByName("generate${variant.name.capitalize()}Assets") + def jniLibFoldersTask = tasks.findByName("merge${variant.name.capitalize()}JniLibFolders") + if (!mozconfig.substs.MOZILLA_OFFICIAL && (variant.productFlavors*.name).contains('withGeckoBinaries')) { + assetGenTask.dependsOn rootProject.machStagePackage + jniLibFoldersTask.dependsOn rootProject.machStagePackage + } + } +} + +ext.configureLibraryVariantWithJNIWrappers = { variant, module -> + // BundleLibRuntime prepares the library for further processing to be + // incorporated in an app. We use this version to create the JNI wrappers. + def jarTask = tasks["bundleLibRuntimeToJar${variant.name.capitalize()}"] + def bundleJar = jarTask.outputs.files.find({ it.name == 'classes.jar' }) + + def annotationProcessorsJarTask = project(':annotations').jar + + def wrapperTask + if (System.env.IS_LANGUAGE_REPACK == '1') { + // Single-locale l10n repacks set `IS_LANGUAGE_REPACK=1` and don't + // really have a build environment. + wrapperTask = task("generateJNIWrappersFor${module}${variant.name.capitalize()}") + } else { + wrapperTask = task("generateJNIWrappersFor${module}${variant.name.capitalize()}", type: JavaExec) { + classpath annotationProcessorsJarTask.archivePath + + // Configure the classpath at evaluation-time, not at + // configuration-time: see above comment. + doFirst { + classpath variant.javaCompileProvider.get().classpath + } + + mainClass = 'org.mozilla.gecko.annotationProcessors.AnnotationProcessor' + args module + args bundleJar + + workingDir "${topobjdir}/widget/android" + + inputs.file(bundleJar) + inputs.file(annotationProcessorsJarTask.archivePath) + inputs.property("module", module) + + outputs.file("${topobjdir}/widget/android/GeneratedJNINatives.h") + outputs.file("${topobjdir}/widget/android/GeneratedJNIWrappers.cpp") + outputs.file("${topobjdir}/widget/android/GeneratedJNIWrappers.h") + + dependsOn jarTask + dependsOn annotationProcessorsJarTask + } + } + + if (module == 'Generated') { + tasks["bundle${variant.name.capitalize()}Aar"].dependsOn wrapperTask + } else { + tasks["assemble${variant.name.capitalize()}"].dependsOn wrapperTask + } +} diff --git a/mobile/android/installer/Makefile.in b/mobile/android/installer/Makefile.in new file mode 100644 index 0000000000..5920b7a4b2 --- /dev/null +++ b/mobile/android/installer/Makefile.in @@ -0,0 +1,68 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +STANDALONE_MAKEFILE := 1 + +# overwrite mobile-l10n.js with a matchOS=true one for multi-locale builds +ifeq ($(AB_CD),multi) +L10N_PREF_JS_EXPORTS = $(srcdir)/mobile-l10n.js +L10N_PREF_JS_EXPORTS_PATH = $(FINAL_TARGET)/$(PREF_DIR) +L10N_PREF_JS_EXPORTS_FLAGS = $(PREF_PPFLAGS) --silence-missing-directive-warnings +PP_TARGETS += L10N_PREF_JS_EXPORTS +endif + +include $(topsrcdir)/config/rules.mk + +MOZ_PKG_REMOVALS = $(srcdir)/removed-files.in + +MOZ_PKG_MANIFEST = $(srcdir)/package-manifest.in +MOZ_PKG_DUPEFLAGS = -f $(srcdir)/allowed-dupes.mn + +DEFINES += -DPKG_LOCALE_MANIFEST=$(topobjdir)/mobile/android/installer/locale-manifest.in +MOZ_CHROME_LOCALE_ENTRIES=@BINPATH@/chrome/ + +DEFINES += \ + -DMOZ_APP_NAME=$(MOZ_APP_NAME) \ + -DPREF_DIR=$(PREF_DIR) \ + -DJAREXT= \ + -DMOZ_CHILD_PROCESS_NAME=$(MOZ_CHILD_PROCESS_NAME) \ + -DANDROID_CPU_ARCH=$(ANDROID_CPU_ARCH) \ + $(NULL) + +ifdef MOZ_DEBUG +DEFINES += -DMOZ_DEBUG=1 +endif + +ifdef MOZ_ANDROID_EXCLUDE_FONTS +DEFINES += -DMOZ_ANDROID_EXCLUDE_FONTS=1 +endif + +ifdef MOZ_ARTIFACT_BUILDS +DEFINES += -DMOZ_ARTIFACT_BUILDS=1 +endif + +MOZ_PKG_DIR = geckoview + +ifdef MOZ_ANDROID_FAT_AAR_ARCHITECTURES +DEFINES += -DMOZ_ANDROID_FAT_AAR_ARCHITECTURES=1 +endif + +include $(topsrcdir)/toolkit/mozapps/installer/packager.mk + +ifeq (Darwin,$(OS_TARGET)) +BINPATH = $(_BINPATH) +DEFINES += -DAPPNAME=$(_APPNAME) +else +# Every other platform just winds up in dist/bin +BINPATH = bin +endif +DEFINES += -DBINPATH=$(BINPATH) + +ifdef ENABLE_WEBDRIVER +DEFINES += -DENABLE_WEBDRIVER=1 +endif + +ifdef MOZ_CLANG_RT_ASAN_LIB_PATH +DEFINES += -DMOZ_CLANG_RT_ASAN_LIB=$(notdir $(MOZ_CLANG_RT_ASAN_LIB_PATH)) +endif diff --git a/mobile/android/installer/allowed-dupes.mn b/mobile/android/installer/allowed-dupes.mn new file mode 100644 index 0000000000..1b9a92e058 --- /dev/null +++ b/mobile/android/installer/allowed-dupes.mn @@ -0,0 +1,28 @@ +# Known duplicate files +# This file is ideally removed, but some existing files will be grandfathered in +# See bug 1303184 +# +# PLEASE DO NOT ADD MORE EXCEPTIONS TO THIS LIST +# + +# Row and column icons are duplicated +res/table-remove-column-active.gif +res/table-remove-row-active.gif +res/table-remove-column-hover.gif +res/table-remove-row-hover.gif +res/table-remove-column.gif +res/table-remove-row.gif + +res/multilocale.txt +update.locale + +#ifdef MOZ_ANDROID_FAT_AAR_ARCHITECTURES +defaults/pref/arm64-v8a/geckoview-prefs.js +defaults/pref/armeabi-v7a/geckoview-prefs.js +defaults/pref/x86/geckoview-prefs.js +defaults/pref/x86_64/geckoview-prefs.js +arm64-v8a/greprefs.js +armeabi-v7a/greprefs.js +x86/greprefs.js +x86_64/greprefs.js +#endif # MOZ_ANDROID_FAT_AAR_ARCHITECTURES diff --git a/mobile/android/installer/mobile-l10n.js b/mobile/android/installer/mobile-l10n.js new file mode 100644 index 0000000000..20ddbeb819 --- /dev/null +++ b/mobile/android/installer/mobile-l10n.js @@ -0,0 +1,9 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 pref is in its own file for complex reasons. See bug 1428099 for +// details. Do not add other prefs to this file. + +// Inherit locale from the OS, used for multi-locale builds +pref("intl.locale.requested", ""); diff --git a/mobile/android/installer/moz.build b/mobile/android/installer/moz.build new file mode 100644 index 0000000000..2d4f67a08b --- /dev/null +++ b/mobile/android/installer/moz.build @@ -0,0 +1,8 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") diff --git a/mobile/android/installer/package-manifest.in b/mobile/android/installer/package-manifest.in new file mode 100644 index 0000000000..08361b8870 --- /dev/null +++ b/mobile/android/installer/package-manifest.in @@ -0,0 +1,219 @@ +; This Source Code Form is subject to the terms of the Mozilla Public +; License, v. 2.0. If a copy of the MPL was not distributed with this +; file, You can obtain one at http://mozilla.org/MPL/2.0/. + +; Package file for the Fennec build. +; +; File format: +; +; [] designates a toplevel component. Example: [xpcom] +; - in front of a file specifies it to be removed from the destination +; * wildcard support to recursively copy the entire directory +; ; file comment +; + +#filter substitution + +[@AB_CD@] +@BINPATH@/@PREF_DIR@/mobile-l10n.js +@BINPATH@/update.locale +#ifdef MOZ_UPDATER +@BINPATH@/updater.ini +#endif +@BINPATH@/dictionaries/* +@BINPATH@/hyphenation/* +@BINPATH@/localization/* + +[lib destdir="lib/@ANDROID_CPU_ARCH@"] + +#ifdef MOZ_CLANG_RT_ASAN_LIB +@BINPATH@/@MOZ_CLANG_RT_ASAN_LIB@ +#endif + +#ifndef MOZ_STATIC_JS +@BINPATH@/@DLL_PREFIX@mozjs@DLL_SUFFIX@ +#endif +#ifdef MOZ_DMD +@BINPATH@/@DLL_PREFIX@dmd@DLL_SUFFIX@ +#endif +#ifndef MOZ_FOLD_LIBS +@BINPATH@/@DLL_PREFIX@plc4@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@plds4@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@nspr4@DLL_SUFFIX@ +#endif +@BINPATH@/@DLL_PREFIX@lgpllibs@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@gkcodecs@DLL_SUFFIX@ +#ifdef MOZ_FFVPX +@BINPATH@/@DLL_PREFIX@mozavutil@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@mozavcodec@DLL_SUFFIX@ +#endif +#ifdef MOZ_OMX_PLUGIN +@BINPATH@/@DLL_PREFIX@omxplugin@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@omxpluginkk@DLL_SUFFIX@ +#endif +@BINPATH@/@DLL_PREFIX@xul@DLL_SUFFIX@ + +@BINPATH@/@DLL_PREFIX@nssckbi@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@nss3@DLL_SUFFIX@ +#ifndef MOZ_FOLD_LIBS +@BINPATH@/@DLL_PREFIX@nssutil3@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@smime3@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@ssl3@DLL_SUFFIX@ +#endif +@BINPATH@/@DLL_PREFIX@softokn3@DLL_SUFFIX@ +@BINPATH@/@DLL_PREFIX@freebl3@DLL_SUFFIX@ +#ifndef CROSS_COMPILE +@BINPATH@/@DLL_PREFIX@freebl3.chk +@BINPATH@/@DLL_PREFIX@softokn3.chk +#endif + +@BINPATH@/@DLL_PREFIX@ipcclientcerts@DLL_SUFFIX@ + +#ifndef MOZ_FOLD_LIBS +@BINPATH@/@DLL_PREFIX@mozsqlite3@DLL_SUFFIX@ +#endif + +@BINPATH@/@DLL_PREFIX@mozglue@DLL_SUFFIX@ +# This should be MOZ_CHILD_PROCESS_NAME, but that has a "lib/" prefix. +@BINPATH@/@MOZ_CHILD_PROCESS_NAME@ + +#ifdef MOZ_ANDROID_GOOGLE_VR +@BINPATH@/@DLL_PREFIX@gvr@DLL_SUFFIX@ +#endif + +[xpcom] +@BINPATH@/package-name.txt + +[browser] +; [Base Browser Files] +@BINPATH@/application.ini +@BINPATH@/platform.ini +@BINPATH@/defaults/settings/last_modified.json +; The addons blocklist data is not packaged and will be downloaded after install. +; See https://bugzilla.mozilla.org/show_bug.cgi?id=1639050#c5 +; @BINPATH@/defaults/settings/blocklists/addons-bloomfilters.json +; @BINPATH@/defaults/settings/blocklists/addons-bloomfilters/addons-mlbf.bin +; @BINPATH@/defaults/settings/blocklists/addons-bloomfilters/addons-mlbf.bin.meta.json +@BINPATH@/defaults/settings/blocklists/gfx.json +@BINPATH@/defaults/settings/main/password-recipes.json +@BINPATH@/defaults/settings/security-state/onecrl.json + +; [Components] +@BINPATH@/components/components.manifest + +; JavaScript components +@BINPATH@/components/toolkitsearch.manifest + +@BINPATH@/components/extensions.manifest + +@BINPATH@/components/antitracking.manifest + +@BINPATH@/components/ProcessSingleton.manifest +@BINPATH@/components/servicesComponents.manifest +@BINPATH@/components/servicesSettings.manifest +@BINPATH@/components/l10n-registry.manifest + +; Modules +@BINPATH@/modules/* +@BINPATH@/actors/* + +; [Browser Chrome Files] +@BINPATH@/chrome/pdfjs.manifest +@BINPATH@/chrome/pdfjs/* +@BINPATH@/chrome/toolkit@JAREXT@ +@BINPATH@/chrome/toolkit.manifest + +; [Extensions] +@BINPATH@/components/extensions-toolkit.manifest +@BINPATH@/components/extensions-mobile.manifest + +; Features +@BINPATH@/features/* + +; DevTools +@BINPATH@/chrome/devtools@JAREXT@ +@BINPATH@/chrome/devtools.manifest + +; [Default Preferences] +; All the pref files must be part of base to prevent migration bugs +#ifndef MOZ_ANDROID_FAT_AAR_ARCHITECTURES +@BINPATH@/@ANDROID_CPU_ARCH@/greprefs.js +@BINPATH@/@PREF_DIR@/@ANDROID_CPU_ARCH@/geckoview-prefs.js +#else +@BINPATH@/*/greprefs.js +@BINPATH@/@PREF_DIR@/*/geckoview-prefs.js +#endif # !MOZ_ANDROID_FAT_AAR_ARCHITECTURES +@BINPATH@/@PREF_DIR@/channel-prefs.js +@BINPATH@/defaults/autoconfig/prefcalls.js + +; [Layout Engine Resources] +; Style Sheets, Graphics and other Resources used by the layout engine. +@BINPATH@/res/EditorOverride.css +@BINPATH@/res/contenteditable.css +@BINPATH@/res/designmode.css +@BINPATH@/res/table-add-column-after-active.gif +@BINPATH@/res/table-add-column-after-hover.gif +@BINPATH@/res/table-add-column-after.gif +@BINPATH@/res/table-add-column-before-active.gif +@BINPATH@/res/table-add-column-before-hover.gif +@BINPATH@/res/table-add-column-before.gif +@BINPATH@/res/table-add-row-after-active.gif +@BINPATH@/res/table-add-row-after-hover.gif +@BINPATH@/res/table-add-row-after.gif +@BINPATH@/res/table-add-row-before-active.gif +@BINPATH@/res/table-add-row-before-hover.gif +@BINPATH@/res/table-add-row-before.gif +@BINPATH@/res/table-remove-column-active.gif +@BINPATH@/res/table-remove-column-hover.gif +@BINPATH@/res/table-remove-column.gif +@BINPATH@/res/table-remove-row-active.gif +@BINPATH@/res/table-remove-row-hover.gif +@BINPATH@/res/table-remove-row.gif +@BINPATH@/res/grabber.gif +@BINPATH@/res/dtd/* +@BINPATH@/res/language.properties +@BINPATH@/res/locale/layout/HtmlForm.properties +@BINPATH@/res/locale/layout/MediaDocument.properties +@BINPATH@/res/locale/layout/xmlparser.properties +@BINPATH@/res/locale/dom/dom.properties + +#ifndef MOZ_ANDROID_EXCLUDE_FONTS +@BINPATH@/res/fonts/* +#else +@BINPATH@/res/fonts/*.properties +#endif + +; Content-accessible resources. +@BINPATH@/contentaccessible/* + +; svg +@BINPATH@/res/svg.css + +; For process sandboxing +#if defined(MOZ_SANDBOX) +@BINPATH@/@DLL_PREFIX@mozsandbox@DLL_SUFFIX@ +#endif + +; [Crash Reporter] +; CrashService is not used on Android but the ini files are required for L10N +; strings, see bug 1191351. +#ifdef MOZ_CRASHREPORTER +@BINPATH@/crashreporter.ini +@BINPATH@/crashreporter-override.ini +#endif + +[mobile] +@BINPATH@/chrome/geckoview@JAREXT@ +@BINPATH@/chrome/geckoview.manifest + +@BINPATH@/components/GeckoView.manifest + +; WebDriver (Marionette, Remote Agent) remote protocols +#ifdef ENABLE_WEBDRIVER +@BINPATH@/chrome/remote@JAREXT@ +@BINPATH@/chrome/remote.manifest +#endif + +#ifdef PKG_LOCALE_MANIFEST +#include @PKG_LOCALE_MANIFEST@ +#endif diff --git a/mobile/android/installer/removed-files.in b/mobile/android/installer/removed-files.in new file mode 100644 index 0000000000..125c8eb2e1 --- /dev/null +++ b/mobile/android/installer/removed-files.in @@ -0,0 +1,3 @@ +update.locale +README.txt +components/dom_webspeech.xpt diff --git a/mobile/android/locales/Makefile.in b/mobile/android/locales/Makefile.in new file mode 100644 index 0000000000..a703481bac --- /dev/null +++ b/mobile/android/locales/Makefile.in @@ -0,0 +1,69 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +include $(topsrcdir)/config/config.mk + +SUBMAKEFILES += \ + $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/Makefile \ + $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales/Makefile \ + $(DEPTH)/mobile/locales/Makefile \ + $(NULL) + +L10N_PREF_JS_EXPORTS = $(firstword $(wildcard $(LOCALE_SRCDIR)/mobile-l10n.js) \ + $(srcdir)/en-US/mobile-l10n.js ) +L10N_PREF_JS_EXPORTS_PATH = $(FINAL_TARGET)/$(PREF_DIR) +L10N_PREF_JS_EXPORTS_FLAGS = $(PREF_PPFLAGS) --silence-missing-directive-warnings +PP_TARGETS += L10N_PREF_JS_EXPORTS + +include $(topsrcdir)/config/rules.mk + +# Required for l10n.mk - defines a list of app sub dirs that should +# be included in langpack xpis. +DIST_SUBDIRS = $(DIST_SUBDIR) + +MOZ_LANGPACK_EID=langpack-$(AB_CD)@firefox.mozilla.org + +include $(topsrcdir)/toolkit/locales/l10n.mk + +# For historical reasons, kill stage for repacks due to library moves +# in PACKAGE and UNPACKAGE. +clobber-stage: + $(RM) -rf $(STAGEDIST) + +# merge if we're not en-US, using conditional function as we need +# the current value of AB_CD +l10n-%: AB_CD=$* +l10n-%: + $(if $(filter en-US,$(AB_CD)),, $(MAKE) merge-$*) + $(MAKE) -C $(DEPTH)/mobile/locales l10n-$* + $(MAKE) l10n AB_CD=$* XPI_NAME=locale-$* PREF_DIR=defaults/pref + $(MAKE) multilocale.txt-$* AB_CD=$* XPI_NAME=locale-$* + +# Tailored target to just add the chrome processing for multi-locale builds +# merge if we're not en-US, using conditional function as we need +# the current value of AB_CD +chrome-%: AB_CD=$* +chrome-%: IS_LANGUAGE_REPACK=1 +chrome-%: + $(if $(filter en-US,$(AB_CD)),, $(MAKE) merge-$*) + $(MAKE) -C $(DEPTH)/mobile/locales chrome-$* + $(MAKE) chrome AB_CD=$* + +# When we unpack fennec on MacOS X the platform.ini and application.ini are in slightly +# different locations that on all other platforms +ifeq (Darwin, $(OS_ARCH)) +GECKO_PLATFORM_INI_PATH='$(STAGEDIST)/platform.ini' +FENNEC_APPLICATION_INI_PATH='$(STAGEDIST)/application.ini' +else +GECKO_PLATFORM_INI_PATH='$(STAGEDIST)/platform.ini' +FENNEC_APPLICATION_INI_PATH='$(STAGEDIST)/application.ini' +endif + +ident: + @printf 'gecko_revision ' + @$(PYTHON3) $(topsrcdir)/config/printconfigsetting.py $(GECKO_PLATFORM_INI_PATH) Build SourceStamp + @printf 'fennec_revision ' + @$(PYTHON3) $(topsrcdir)/config/printconfigsetting.py $(FENNEC_APPLICATION_INI_PATH) App SourceStamp + @printf 'buildid ' + @$(PYTHON3) $(topsrcdir)/config/printconfigsetting.py $(FENNEC_APPLICATION_INI_PATH) App BuildID diff --git a/mobile/android/locales/all-locales b/mobile/android/locales/all-locales new file mode 100644 index 0000000000..297d183dca --- /dev/null +++ b/mobile/android/locales/all-locales @@ -0,0 +1,98 @@ +ach +an +ar +ast +az +be +bg +bn +br +bs +ca +cak +cs +cy +da +de +dsb +el +en-CA +en-GB +eo +es-AR +es-CL +es-ES +es-MX +et +eu +fa +ff +fi +fr +fy-NL +ga-IE +gd +gl +gn +gu-IN +he +hi-IN +hr +hsb +hu +hy-AM +ia +id +is +it +ja +ka +kab +kk +km +kn +ko +lij +lo +lt +ltg +lv +meh +mix +ml +mr +ms +my +nb-NO +ne-NP +nl +nn-NO +oc +pa-IN +pl +pt-BR +pt-PT +rm +ro +ru +sk +sl +son +sq +sr +sv-SE +ta +te +th +tl +tr +trs +uk +ur +uz +vi +wo +xh +zam +zh-CN +zh-TW diff --git a/mobile/android/locales/en-US/mobile-l10n.js b/mobile/android/locales/en-US/mobile-l10n.js new file mode 100644 index 0000000000..95a4db06b4 --- /dev/null +++ b/mobile/android/locales/en-US/mobile-l10n.js @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +#filter substitution + +// This comment is to avoid errors during packaging due to the file +// being empty and therefore causing MD5 hash collisions with other +// empty files. See bug 1426943 for details. diff --git a/mobile/android/locales/en-US/mobile/android/aboutConfig.ftl b/mobile/android/locales/en-US/mobile/android/aboutConfig.ftl new file mode 100644 index 0000000000..5cb419181a --- /dev/null +++ b/mobile/android/locales/en-US/mobile/android/aboutConfig.ftl @@ -0,0 +1,28 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +config-toolbar-search = + .placeholder = Search +config-new-pref-name = + .placeholder = Name + +config-new-pref-value-boolean = Boolean +config-new-pref-value-string = String +config-new-pref-value-integer = Integer + +config-new-pref-string = + .placeholder = Enter a string +config-new-pref-number = + .placeholder = Enter a number +config-new-pref-cancel-button = Cancel +config-new-pref-create-button = Create +config-new-pref-change-button = Change + +config-pref-toggle-button = Toggle +config-pref-reset-button = Reset + +config-context-menu-copy-pref-name = + .label = Copy Name +config-context-menu-copy-pref-value = + .label = Copy Value diff --git a/mobile/android/locales/en-US/mobile/android/geckoViewConsole.ftl b/mobile/android/locales/en-US/mobile/android/geckoViewConsole.ftl new file mode 100644 index 0000000000..0892492bde --- /dev/null +++ b/mobile/android/locales/en-US/mobile/android/geckoViewConsole.ftl @@ -0,0 +1,24 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +## Web Console API (in GeckoViewConsole.sys.mjs) + +console-stacktrace-anonymous-function = <anonymous> + +# Variables: +# $filename (String): Source file name +# $functionName (String): JavaScript function name +# $lineNumber (String): The line number of the stacktrace call +console-stacktrace = Stack trace from { $filename }, function { $functionName }, line { $lineNumber }. + +# Variables: +# $name (String): user-defined name for the timer +console-timer-start = { $name }: timer started + +# This string is used to display the result of the console.timeEnd() call. +# +# Variables: +# $name (String): user-defined name for the timer +# $duration (String): number of milliseconds +console-timer-end = { $name }: { $duration }ms diff --git a/mobile/android/locales/filter.py b/mobile/android/locales/filter.py new file mode 100644 index 0000000000..a972c583bc --- /dev/null +++ b/mobile/android/locales/filter.py @@ -0,0 +1,67 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.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 routine controls which localizable files and entries are +reported and l10n-merged. +This needs to stay in sync with the copy in mobile/locales. +""" + + +def test(mod, path, entity=None): + import re + + # ignore anything but mobile, which is our local repo checkout name + if mod not in ("dom", "toolkit", "mobile", "mobile/android"): + return "ignore" + + if mod == "toolkit": + # keep this file list in sync with jar.mn + if path in ( + "chrome/global/commonDialogs.properties", + "chrome/global/intl.properties", + "chrome/global/intl.css", + ): + return "error" + if re.match(r"crashreporter/[^/]*.ftl", path): + # error on crashreporter/*.ftl + return "error" + if re.match(r"toolkit/about/[^/]*About.ftl", path): + # error on toolkit/about/*About.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Mozilla.ftl", path): + # error on toolkit/about/*Mozilla.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Rights.ftl", path): + # error on toolkit/about/*Rights.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Compat.ftl", path): + # error on toolkit/about/*Compat.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Support.ftl", path): + # error on toolkit/about/*Support.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Webrtc.ftl", path): + # error on toolkit/about/*Webrtc.ftl + return "error" + return "ignore" + + if mod == "dom": + # keep this file list in sync with jar.mn + if path in ( + "chrome/accessibility/AccessFu.properties", + "chrome/dom/dom.properties", + ): + return "error" + return "ignore" + + if mod not in ("mobile", "mobile/android"): + # we only have exceptions for mobile* + return "error" + if mod == "mobile/android": + if entity is None: + if re.match(r"mobile-l10n.js", path): + return "ignore" + return "error" + + return "error" diff --git a/mobile/android/locales/jar.mn b/mobile/android/locales/jar.mn new file mode 100644 index 0000000000..98cbaf6668 --- /dev/null +++ b/mobile/android/locales/jar.mn @@ -0,0 +1,57 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Note: This file should only contain locale entries. All +# override and resource entries should go to mobile/android/chrome/jar.mn to avoid +# having to create the same entry for each locale. + +# Fluent files +# Note: All rules must be wildcards, as localized files are optional +# If you're including files from a subdirectory, ensure that you're +# putting them into the corresponding subdirectory in the target. +# The wildcard ** does that for us in toolkit. + +[localization] @AB_CD@.jar: + mobile/android (%mobile/android/**/*.ftl) + +@AB_CD@.jar: +% locale browser @AB_CD@ %locale/@AB_CD@/browser/ + +# overrides for toolkit l10n, also for en-US +# keep this file list in sync with l10n.toml and filter.py +relativesrcdir toolkit/locales: + locale/@AB_CD@/browser/overrides/commonDialogs.properties (%chrome/global/commonDialogs.properties) + locale/@AB_CD@/browser/overrides/intl.properties (%chrome/global/intl.properties) + locale/@AB_CD@/browser/overrides/intl.css (%chrome/global/intl.css) + +# overrides for dom l10n, also for en-US +# keep this file list in sync with filter.py +relativesrcdir dom/locales: + locale/@AB_CD@/browser/overrides/AccessFu.properties (%chrome/accessibility/AccessFu.properties) + locale/@AB_CD@/browser/overrides/dom/dom.properties (%chrome/dom/dom.properties) + +# Only run this if we're not en-US, as en-US is already built +# by toolkit/locales/jar.mn. +#define EN_US en-US +#if AB_CD != EN_US +[localization] @AB_CD@.jar: +relativesrcdir toolkit/locales: +#about:crashes + crashreporter (%crashreporter/**/*.ftl) +#about:about + toolkit/about (%toolkit/about/*About.ftl) +#about:mozilla + toolkit/about (%toolkit/about/*Mozilla.ftl) +#about:support + toolkit/about (%toolkit/about/*Support.ftl) +#about:rights + toolkit/about (%toolkit/about/*Rights.ftl) +#about:compat + toolkit/about (%toolkit/about/*Compat.ftl) +#about:webrtc + toolkit/about (%toolkit/about/*Webrtc.ftl) +#endif +# Do not add files below the endif. Reviewers, expand more context above +# for comments. diff --git a/mobile/android/locales/l10n.ini b/mobile/android/locales/l10n.ini new file mode 100644 index 0000000000..a8669b1986 --- /dev/null +++ b/mobile/android/locales/l10n.ini @@ -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/. + +# Control which directories and modules are part of mobile/android. +# Changes here should be reflected in mobile/locales/l10n.ini so +# that the dashboard picks them up. + +[general] +depth = ../../.. +all = mobile/android/locales/all-locales + +[compare] +dirs = mobile mobile/android + +[includes] +toolkit = toolkit/locales/l10n.ini diff --git a/mobile/android/locales/l10n.toml b/mobile/android/locales/l10n.toml new file mode 100644 index 0000000000..45a31de13f --- /dev/null +++ b/mobile/android/locales/l10n.toml @@ -0,0 +1,184 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +basepath = "../../.." + +locales = [ + "ach", + "an", + "ar", + "ast", + "az", + "be", + "bg", + "bn", + "br", + "bs", + "ca", + "cak", + "cs", + "cy", + "da", + "de", + "dsb", + "el", + "en-CA", + "en-GB", + "eo", + "es-AR", + "es-CL", + "es-ES", + "es-MX", + "et", + "eu", + "fa", + "ff", + "fi", + "fr", + "fy-NL", + "ga-IE", + "gd", + "gl", + "gn", + "gu-IN", + "he", + "hi-IN", + "hr", + "hsb", + "hu", + "hy-AM", + "ia", + "id", + "is", + "it", + "ja", + "ka", + "kab", + "kk", + "km", + "kn", + "ko", + "lij", + "lo", + "lt", + "ltg", + "lv", + "meh", + "mix", + "ml", + "mr", + "ms", + "my", + "nb-NO", + "ne-NP", + "nl", + "nn-NO", + "oc", + "pa-IN", + "pl", + "pt-BR", + "pt-PT", + "rm", + "ro", + "ru", + "sk", + "sl", + "son", + "sq", + "sr", + "sv-SE", + "ta", + "te", + "th", + "tl", + "tr", + "trs", + "uk", + "ur", + "uz", + "vi", + "wo", + "xh", + "zam", + "zh-CN", + "zh-TW", +] + +[build] +exclude-multi-locale = [ + "ach", + "ia", + "km", + "ltg", + "meh", + "mix", + "tl", +] + +[env] + l = "{l10n_base}/{locale}/" + + +[[paths]] + reference = "mobile/android/locales/en-US/**" + l10n = "{l}mobile/android/**" + +# hand-picked paths from toolkit, keep in sync with jar.mn +[[paths]] + reference = "dom/locales/en-US/chrome/accessibility/AccessFu.properties" + l10n = "{l}dom/chrome/accessibility/AccessFu.properties" + +[[paths]] + reference = "dom/locales/en-US/chrome/dom/dom.properties" + l10n = "{l}dom/chrome/dom/dom.properties" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/about/*About.ftl" + l10n = "{l}toolkit/toolkit/about/*About.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/about/*Mozilla.ftl" + l10n = "{l}toolkit/toolkit/about/*Mozilla.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/about/*Rights.ftl" + l10n = "{l}toolkit/toolkit/about/*Rights.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/about/*Compat.ftl" + l10n = "{l}toolkit/toolkit/about/*Compat.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/chrome/global/commonDialogs.properties" + l10n = "{l}toolkit/chrome/global/commonDialogs.properties" + +[[paths]] + reference = "toolkit/locales/en-US/chrome/global/intl.properties" + l10n = "{l}toolkit/chrome/global/intl.properties" + +[[paths]] + reference = "toolkit/locales/en-US/chrome/global/intl.css" + l10n = "{l}toolkit/chrome/global/intl.css" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/services/*.ftl" + l10n = "{l}toolkit/services/*.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/about/*Support.ftl" + l10n = "{l}toolkit/toolkit/about/*Support.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/crashreporter/*.ftl" + l10n = "{l}toolkit/crashreporter/*.ftl" + +[[paths]] + reference = "toolkit/locales/en-US/toolkit/about/*Webrtc.ftl" + l10n = "{l}toolkit/toolkit/about/*Webrtc.ftl" + +[[filters]] + path = [ + "{l}mobile/android/mobile-l10n.js", + ] + action = "ignore" diff --git a/mobile/android/locales/maemo-locales b/mobile/android/locales/maemo-locales new file mode 100644 index 0000000000..83b1ad3195 --- /dev/null +++ b/mobile/android/locales/maemo-locales @@ -0,0 +1,91 @@ +an +ar +ast +az +be +bg +bn +br +bs +ca +cak +cs +cy +da +de +dsb +el +en-CA +en-GB +eo +es-AR +es-CL +es-ES +es-MX +et +eu +fa +ff +fi +fr +fy-NL +ga-IE +gd +gl +gn +gu-IN +he +hi-IN +hr +hsb +hu +hy-AM +id +is +it +ja +ka +kab +kk +kn +ko +lij +lo +lt +lv +ml +mr +ms +my +nb-NO +ne-NP +nl +nn-NO +oc +pa-IN +pl +pt-BR +pt-PT +rm +ro +ru +sk +sl +son +sq +sr +sv-SE +ta +te +th +tr +trs +uk +ur +uz +vi +wo +xh +zam +zh-CN +zh-TW diff --git a/mobile/android/locales/moz.build b/mobile/android/locales/moz.build new file mode 100644 index 0000000000..8a8407ca4c --- /dev/null +++ b/mobile/android/locales/moz.build @@ -0,0 +1,10 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/mach_commands.py b/mobile/android/mach_commands.py new file mode 100644 index 0000000000..68271bd0c0 --- /dev/null +++ b/mobile/android/mach_commands.py @@ -0,0 +1,696 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import argparse +import logging +import os +import sys +import tarfile + +import mozpack.path as mozpath +from mach.decorators import Command, CommandArgument, SubCommand +from mozbuild.base import MachCommandConditions as conditions +from mozbuild.shellutil import split as shell_split + +# Mach's conditions facility doesn't support subcommands. Print a +# deprecation message ourselves instead. +LINT_DEPRECATION_MESSAGE = """ +Android lints are now integrated with mozlint. Instead of +`mach android {api-lint,checkstyle,lint,test}`, run +`mach lint --linter android-{api-lint,checkstyle,lint,test}`. +Or run `mach lint`. +""" + + +# NOTE python/mach/mach/commands/commandinfo.py references this function +# by name. If this function is renamed or removed, that file should +# be updated accordingly as well. +def REMOVED(cls): + """Command no longer exists! Use the Gradle configuration rooted in the top source directory + instead. + + See https://developer.mozilla.org/en-US/docs/Simple_Firefox_for_Android_build#Developing_Firefox_for_Android_in_Android_Studio_or_IDEA_IntelliJ. # NOQA: E501 + """ + return False + + +@Command( + "android", + category="devenv", + description="Run Android-specific commands.", + conditions=[conditions.is_android], +) +def android(command_context): + pass + + +@SubCommand( + "android", + "assemble-app", + """Assemble Firefox for Android. + See http://firefox-source-docs.mozilla.org/build/buildsystem/toolchains.html#firefox-for-android-with-gradle""", # NOQA: E501 +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_assemble_app(command_context, args): + ret = gradle( + command_context, + command_context.substs["GRADLE_ANDROID_APP_TASKS"] + ["-x", "lint"] + args, + verbose=True, + ) + + return ret + + +@SubCommand( + "android", + "generate-sdk-bindings", + """Generate SDK bindings used when building GeckoView.""", +) +@CommandArgument( + "inputs", + nargs="+", + help="config files, like [/path/to/ClassName-classes.txt]+", +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_generate_sdk_bindings(command_context, inputs, args): + import itertools + + def stem(input): + # Turn "/path/to/ClassName-classes.txt" into "ClassName". + return os.path.basename(input).rsplit("-classes.txt", 1)[0] + + bindings_inputs = list(itertools.chain(*((input, stem(input)) for input in inputs))) + bindings_args = "-Pgenerate_sdk_bindings_args={}".format(";".join(bindings_inputs)) + + ret = gradle( + command_context, + command_context.substs["GRADLE_ANDROID_GENERATE_SDK_BINDINGS_TASKS"] + + [bindings_args] + + args, + verbose=True, + ) + + return ret + + +@SubCommand( + "android", + "generate-generated-jni-wrappers", + """Generate GeckoView JNI wrappers used when building GeckoView.""", +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_generate_generated_jni_wrappers(command_context, args): + ret = gradle( + command_context, + command_context.substs["GRADLE_ANDROID_GENERATE_GENERATED_JNI_WRAPPERS_TASKS"] + + args, + verbose=True, + ) + + return ret + + +@SubCommand( + "android", + "api-lint", + """Run Android api-lint. +REMOVED/DEPRECATED: Use 'mach lint --linter android-api-lint'.""", +) +def android_apilint_REMOVED(command_context): + print(LINT_DEPRECATION_MESSAGE) + return 1 + + +@SubCommand( + "android", + "test", + """Run Android test. +REMOVED/DEPRECATED: Use 'mach lint --linter android-test'.""", +) +def android_test_REMOVED(command_context): + print(LINT_DEPRECATION_MESSAGE) + return 1 + + +@SubCommand( + "android", + "lint", + """Run Android lint. +REMOVED/DEPRECATED: Use 'mach lint --linter android-lint'.""", +) +def android_lint_REMOVED(command_context): + print(LINT_DEPRECATION_MESSAGE) + return 1 + + +@SubCommand( + "android", + "checkstyle", + """Run Android checkstyle. +REMOVED/DEPRECATED: Use 'mach lint --linter android-checkstyle'.""", +) +def android_checkstyle_REMOVED(command_context): + print(LINT_DEPRECATION_MESSAGE) + return 1 + + +@SubCommand( + "android", + "gradle-dependencies", + """Collect Android Gradle dependencies. + See http://firefox-source-docs.mozilla.org/build/buildsystem/toolchains.html#firefox-for-android-with-gradle""", # NOQA: E501 +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_gradle_dependencies(command_context, args): + # We don't want to gate producing dependency archives on clean + # lint or checkstyle, particularly because toolchain versions + # can change the outputs for those processes. + gradle( + command_context, + command_context.substs["GRADLE_ANDROID_DEPENDENCIES_TASKS"] + + ["--continue"] + + args, + verbose=True, + ) + + return 0 + + +def get_maven_archive_paths(maven_folder): + for subdir, _, files in os.walk(maven_folder): + if "-SNAPSHOT" in subdir: + continue + for file in files: + yield os.path.join(subdir, file) + + +def create_maven_archive(topobjdir): + gradle_folder = os.path.join(topobjdir, "gradle") + maven_folder = os.path.join(gradle_folder, "maven") + + with tarfile.open( + os.path.join(gradle_folder, "target.maven.tar.xz"), "w|xz" + ) as tar: + for abs_path in get_maven_archive_paths(maven_folder): + tar.add( + abs_path, + arcname=os.path.join( + "geckoview", os.path.relpath(abs_path, maven_folder) + ), + ) + + +@SubCommand( + "android", + "archive-geckoview", + """Create GeckoView archives. + See http://firefox-source-docs.mozilla.org/build/buildsystem/toolchains.html#firefox-for-android-with-gradle""", # NOQA: E501 +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_archive_geckoview(command_context, args): + ret = gradle( + command_context, + command_context.substs["GRADLE_ANDROID_ARCHIVE_GECKOVIEW_TASKS"] + args, + verbose=True, + ) + + if ret != 0: + return ret + if "MOZ_AUTOMATION" in os.environ: + create_maven_archive(command_context.topobjdir) + + return 0 + + +@SubCommand("android", "build-geckoview_example", """Build geckoview_example """) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_build_geckoview_example(command_context, args): + gradle( + command_context, + command_context.substs["GRADLE_ANDROID_BUILD_GECKOVIEW_EXAMPLE_TASKS"] + args, + verbose=True, + ) + + print( + "Execute `mach android install-geckoview_example` " + "to push the geckoview_example and test APKs to a device." + ) + + return 0 + + +@SubCommand("android", "compile-all", """Build all source files""") +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_compile_all(command_context, args): + gradle( + command_context, + command_context.substs["GRADLE_ANDROID_COMPILE_ALL_TASKS"] + args, + verbose=True, + ) + + return 0 + + +def install_app_bundle(command_context, bundle): + from mozdevice import ADBDeviceFactory + + bundletool = mozpath.join(command_context._mach_context.state_dir, "bundletool.jar") + device = ADBDeviceFactory(verbose=True) + bundle_path = mozpath.join(command_context.topobjdir, bundle) + java_home = java_home = os.path.dirname( + os.path.dirname(command_context.substs["JAVA"]) + ) + device.install_app_bundle(bundletool, bundle_path, java_home, timeout=120) + + +@SubCommand("android", "install-geckoview_example", """Install geckoview_example """) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_install_geckoview_example(command_context, args): + gradle( + command_context, + command_context.substs["GRADLE_ANDROID_INSTALL_GECKOVIEW_EXAMPLE_TASKS"] + args, + verbose=True, + ) + + print( + "Execute `mach android build-geckoview_example` " + "to just build the geckoview_example and test APKs." + ) + + return 0 + + +@SubCommand( + "android", "install-geckoview-test_runner", """Install geckoview.test_runner """ +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_install_geckoview_test_runner(command_context, args): + gradle( + command_context, + command_context.substs["GRADLE_ANDROID_INSTALL_GECKOVIEW_TEST_RUNNER_TASKS"] + + args, + verbose=True, + ) + return 0 + + +@SubCommand( + "android", + "install-geckoview-test_runner-aab", + """Install geckoview.test_runner with AAB""", +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_install_geckoview_test_runner_aab(command_context, args): + install_app_bundle( + command_context, + command_context.substs["GRADLE_ANDROID_GECKOVIEW_TEST_RUNNER_BUNDLE"], + ) + return 0 + + +@SubCommand( + "android", + "install-geckoview_example-aab", + """Install geckoview_example with AAB""", +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_install_geckoview_example_aab(command_context, args): + install_app_bundle( + command_context, + command_context.substs["GRADLE_ANDROID_GECKOVIEW_EXAMPLE_BUNDLE"], + ) + return 0 + + +@SubCommand("android", "install-geckoview-test", """Install geckoview.test """) +@CommandArgument("args", nargs=argparse.REMAINDER) +def android_install_geckoview_test(command_context, args): + gradle( + command_context, + command_context.substs["GRADLE_ANDROID_INSTALL_GECKOVIEW_TEST_TASKS"] + args, + verbose=True, + ) + return 0 + + +@SubCommand( + "android", + "geckoview-docs", + """Create GeckoView javadoc and optionally upload to Github""", +) +@CommandArgument("--archive", action="store_true", help="Generate a javadoc archive.") +@CommandArgument( + "--upload", + metavar="USER/REPO", + help="Upload geckoview documentation to Github, using the specified USER/REPO.", +) +@CommandArgument( + "--upload-branch", + metavar="BRANCH[/PATH]", + default="gh-pages", + help="Use the specified branch/path for documentation commits.", +) +@CommandArgument( + "--javadoc-path", + metavar="/PATH", + default="javadoc", + help="Use the specified path for javadoc commits.", +) +@CommandArgument( + "--upload-message", + metavar="MSG", + default="GeckoView docs upload", + help="Use the specified message for commits.", +) +def android_geckoview_docs( + command_context, + archive, + upload, + upload_branch, + javadoc_path, + upload_message, +): + tasks = ( + command_context.substs["GRADLE_ANDROID_GECKOVIEW_DOCS_ARCHIVE_TASKS"] + if archive or upload + else command_context.substs["GRADLE_ANDROID_GECKOVIEW_DOCS_TASKS"] + ) + + ret = gradle(command_context, tasks, verbose=True) + if ret or not upload: + return ret + + # Upload to Github. + fmt = { + "level": os.environ.get("MOZ_SCM_LEVEL", "0"), + "project": os.environ.get("MH_BRANCH", "unknown"), + "revision": os.environ.get("GECKO_HEAD_REV", "tip"), + } + env = {} + + # In order to push to GitHub from TaskCluster, we store a private key + # in the TaskCluster secrets store in the format {"content": "<KEY>"}, + # and the corresponding public key as a writable deploy key for the + # destination repo on GitHub. + secret = os.environ.get("GECKOVIEW_DOCS_UPLOAD_SECRET", "").format(**fmt) + if secret: + # Set up a private key from the secrets store if applicable. + import requests + + req = requests.get("http://taskcluster/secrets/v1/secret/" + secret) + req.raise_for_status() + + keyfile = mozpath.abspath("gv-docs-upload-key") + with open(keyfile, "w") as f: + os.chmod(keyfile, 0o600) + f.write(req.json()["secret"]["content"]) + + # Turn off strict host key checking so ssh does not complain about + # unknown github.com host. We're not pushing anything sensitive, so + # it's okay to not check GitHub's host keys. + env["GIT_SSH_COMMAND"] = 'ssh -i "%s" -o StrictHostKeyChecking=no' % keyfile + + # Clone remote repo. + branch = upload_branch.format(**fmt) + repo_url = "git@github.com:%s.git" % upload + repo_path = mozpath.abspath("gv-docs-repo") + command_context.run_process( + [ + "git", + "clone", + "--branch", + upload_branch, + "--depth", + "1", + repo_url, + repo_path, + ], + append_env=env, + pass_thru=True, + ) + env["GIT_DIR"] = mozpath.join(repo_path, ".git") + env["GIT_WORK_TREE"] = repo_path + env["GIT_AUTHOR_NAME"] = env["GIT_COMMITTER_NAME"] = "GeckoView Docs Bot" + env["GIT_AUTHOR_EMAIL"] = env["GIT_COMMITTER_EMAIL"] = "nobody@mozilla.com" + + # Copy over user documentation. + import mozfile + + # Extract new javadoc to specified directory inside repo. + src_tar = mozpath.join( + command_context.topobjdir, + "gradle", + "build", + "mobile", + "android", + "geckoview", + "libs", + "geckoview-javadoc.jar", + ) + dst_path = mozpath.join(repo_path, javadoc_path.format(**fmt)) + mozfile.remove(dst_path) + mozfile.extract_zip(src_tar, dst_path) + + # Commit and push. + command_context.run_process(["git", "add", "--all"], append_env=env, pass_thru=True) + if ( + command_context.run_process( + ["git", "diff", "--cached", "--quiet"], + append_env=env, + pass_thru=True, + ensure_exit_code=False, + ) + != 0 + ): + # We have something to commit. + command_context.run_process( + ["git", "commit", "--message", upload_message.format(**fmt)], + append_env=env, + pass_thru=True, + ) + command_context.run_process( + ["git", "push", "origin", branch], append_env=env, pass_thru=True + ) + + mozfile.remove(repo_path) + if secret: + mozfile.remove(keyfile) + return 0 + + +@Command( + "gradle", + category="devenv", + description="Run gradle.", + conditions=[conditions.is_android], +) +@CommandArgument( + "-v", + "--verbose", + action="store_true", + help="Verbose output for what commands the build is running.", +) +@CommandArgument("args", nargs=argparse.REMAINDER) +def gradle(command_context, args, verbose=False): + if not verbose: + # Avoid logging the command + command_context.log_manager.terminal_handler.setLevel(logging.CRITICAL) + + # In automation, JAVA_HOME is set via mozconfig, which needs + # to be specially handled in each mach command. This turns + # $JAVA_HOME/bin/java into $JAVA_HOME. + java_home = os.path.dirname(os.path.dirname(command_context.substs["JAVA"])) + + gradle_flags = command_context.substs.get("GRADLE_FLAGS", "") or os.environ.get( + "GRADLE_FLAGS", "" + ) + gradle_flags = shell_split(gradle_flags) + + # We force the Gradle JVM to run with the UTF-8 encoding, since we + # filter strings.xml, which is really UTF-8; the ellipsis character is + # replaced with ??? in some encodings (including ASCII). It's not yet + # possible to filter with encodings in Gradle + # (https://github.com/gradle/gradle/pull/520) and it's challenging to + # do our filtering with Gradle's Ant support. Moreover, all of the + # Android tools expect UTF-8: see + # http://tools.android.com/knownissues/encoding. See + # http://stackoverflow.com/a/21267635 for discussion of this approach. + # + # It's not even enough to set the encoding just for Gradle; it + # needs to be for JVMs spawned by Gradle as well. This + # happens during the maven deployment generating the GeckoView + # documents; this works around "error: unmappable character + # for encoding ASCII" in exoplayer2. See + # https://discuss.gradle.org/t/unmappable-character-for-encoding-ascii-when-building-a-utf-8-project/10692/11 # NOQA: E501 + # and especially https://stackoverflow.com/a/21755671. + + if command_context.substs.get("MOZ_AUTOMATION"): + gradle_flags += ["--console=plain"] + + env = os.environ.copy() + env.update( + { + "GRADLE_OPTS": "-Dfile.encoding=utf-8", + "JAVA_HOME": java_home, + "JAVA_TOOL_OPTIONS": "-Dfile.encoding=utf-8", + # Let Gradle get the right Python path on Windows + "GRADLE_MACH_PYTHON": sys.executable, + } + ) + # Set ANDROID_SDK_ROOT if --with-android-sdk was set. + # See https://bugzilla.mozilla.org/show_bug.cgi?id=1576471 + android_sdk_root = command_context.substs.get("ANDROID_SDK_ROOT", "") + if android_sdk_root: + env["ANDROID_SDK_ROOT"] = android_sdk_root + + return command_context.run_process( + [command_context.substs["GRADLE"]] + gradle_flags + args, + explicit_env=env, + pass_thru=True, # Allow user to run gradle interactively. + ensure_exit_code=False, # Don't throw on non-zero exit code. + cwd=mozpath.join(command_context.topsrcdir), + ) + + +@Command("gradle-install", category="devenv", conditions=[REMOVED]) +def gradle_install_REMOVED(command_context): + pass + + +@Command( + "android-emulator", + category="devenv", + conditions=[], + description="Run the Android emulator with an AVD from test automation. " + "Environment variable MOZ_EMULATOR_COMMAND_ARGS, if present, will " + "over-ride the command line arguments used to launch the emulator.", +) +@CommandArgument( + "--version", + metavar="VERSION", + choices=["arm", "arm64", "x86_64"], + help="Specify which AVD to run in emulator. " + 'One of "arm" (Android supporting armv7 binaries), ' + '"arm64" (for Apple Silicon), or ' + '"x86_64" (Android supporting x86 or x86_64 binaries, ' + "recommended for most applications). " + "By default, the value will match the current build environment.", +) +@CommandArgument("--wait", action="store_true", help="Wait for emulator to be closed.") +@CommandArgument("--gpu", help="Over-ride the emulator -gpu argument.") +@CommandArgument( + "--verbose", action="store_true", help="Log informative status messages." +) +def emulator( + command_context, + version, + wait=False, + gpu=None, + verbose=False, +): + """ + Run the Android emulator with one of the AVDs used in the Mozilla + automated test environment. If necessary, the AVD is fetched from + the taskcluster server and installed. + """ + from mozrunner.devices.android_device import AndroidEmulator + + emulator = AndroidEmulator( + version, + verbose, + substs=command_context.substs, + device_serial="emulator-5554", + ) + if emulator.is_running(): + # It is possible to run multiple emulators simultaneously, but: + # - if more than one emulator is using the same avd, errors may + # occur due to locked resources; + # - additional parameters must be specified when running tests, + # to select a specific device. + # To avoid these complications, allow just one emulator at a time. + command_context.log( + logging.ERROR, + "emulator", + {}, + "An Android emulator is already running.\n" + "Close the existing emulator and re-run this command.", + ) + return 1 + + if not emulator.check_avd(): + command_context.log( + logging.WARN, + "emulator", + {}, + "AVD not found. Please run |mach bootstrap|.", + ) + return 2 + + if not emulator.is_available(): + command_context.log( + logging.WARN, + "emulator", + {}, + "Emulator binary not found.\n" + "Install the Android SDK and make sure 'emulator' is in your PATH.", + ) + return 2 + + command_context.log( + logging.INFO, + "emulator", + {}, + "Starting Android emulator running %s..." % emulator.get_avd_description(), + ) + emulator.start(gpu) + if emulator.wait_for_start(): + command_context.log( + logging.INFO, "emulator", {}, "Android emulator is running." + ) + else: + # This is unusual but the emulator may still function. + command_context.log( + logging.WARN, + "emulator", + {}, + "Unable to verify that emulator is running.", + ) + + if conditions.is_android(command_context): + command_context.log( + logging.INFO, + "emulator", + {}, + "Use 'mach install' to install or update Firefox on your emulator.", + ) + else: + command_context.log( + logging.WARN, + "emulator", + {}, + "No Firefox for Android build detected.\n" + "Switch to a Firefox for Android build context or use 'mach bootstrap'\n" + "to setup an Android build environment.", + ) + + if wait: + command_context.log( + logging.INFO, "emulator", {}, "Waiting for Android emulator to close..." + ) + rc = emulator.wait() + if rc is not None: + command_context.log( + logging.INFO, + "emulator", + {}, + "Android emulator completed with return code %d." % rc, + ) + else: + command_context.log( + logging.WARN, + "emulator", + {}, + "Unable to retrieve Android emulator return code.", + ) + return 0 diff --git a/mobile/android/modules/dbg-browser-actors.js b/mobile/android/modules/dbg-browser-actors.js new file mode 100644 index 0000000000..62c054b615 --- /dev/null +++ b/mobile/android/modules/dbg-browser-actors.js @@ -0,0 +1,87 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* eslint-env commonjs */ + +"use strict"; +/** + * Fennec-specific actors. + */ + +const { RootActor } = require("devtools/server/actors/root"); +const { + ActorRegistry, +} = require("devtools/server/actors/utils/actor-registry"); +const { + BrowserTabList, + BrowserAddonList, + sendShutdownEvent, +} = require("devtools/server/actors/webbrowser"); +const { + ServiceWorkerRegistrationActorList, +} = require("devtools/server/actors/worker/service-worker-registration-list"); +const { + WorkerDescriptorActorList, +} = require("devtools/server/actors/worker/worker-descriptor-actor-list"); + +const { ProcessActorList } = require("devtools/server/actors/process"); + +/** + * Construct a root actor appropriate for use in a server running in a + * browser on Android. The returned root actor: + * - respects the factories registered with ActorRegistry.addGlobalActor, + * - uses a MobileTabList to supply tab actors, + * - sends all navigator:browser window documents a Debugger:Shutdown event + * when it exits. + * + * * @param aConnection DevToolsServerConnection + * The conection to the client. + */ +exports.createRootActor = function createRootActor(aConnection) { + const parameters = { + tabList: new MobileTabList(aConnection), + addonList: new BrowserAddonList(aConnection), + workerList: new WorkerDescriptorActorList(aConnection, {}), + serviceWorkerRegistrationList: new ServiceWorkerRegistrationActorList( + aConnection + ), + processList: new ProcessActorList(), + globalActorFactories: ActorRegistry.globalActorFactories, + onShutdown: sendShutdownEvent, + }; + return new RootActor(aConnection, parameters); +}; + +/** + * A live list of BrowserTabActors representing the current browser tabs, + * to be provided to the root actor to answer 'listTabs' requests. + * + * This object also takes care of listening for TabClose events and + * onCloseWindow notifications, and exiting the BrowserTabActors concerned. + * + * (See the documentation for RootActor for the definition of the "live + * list" interface.) + * + * @param aConnection DevToolsServerConnection + * The connection in which this list's tab actors may participate. + * + * @see BrowserTabList for more a extensive description of how tab list objects + * work. + */ +function MobileTabList(aConnection) { + BrowserTabList.call(this, aConnection); +} + +MobileTabList.prototype = Object.create(BrowserTabList.prototype); + +MobileTabList.prototype.constructor = MobileTabList; + +MobileTabList.prototype._getSelectedBrowser = function (aWindow) { + return aWindow.browser; +}; + +MobileTabList.prototype._getChildren = function (aWindow) { + return [aWindow.browser]; +}; diff --git a/mobile/android/modules/geckoview/AndroidLog.sys.mjs b/mobile/android/modules/geckoview/AndroidLog.sys.mjs new file mode 100644 index 0000000000..f24e2bf899 --- /dev/null +++ b/mobile/android/modules/geckoview/AndroidLog.sys.mjs @@ -0,0 +1,82 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Native Android logging for JavaScript. Lets you specify a priority and tag + * in addition to the message being logged. Resembles the android.util.Log API + * <http://developer.android.com/reference/android/util/Log.html>. + * + * // Import it as a ESM: + * let Log = + * ChromeUtils.importESModule("resource://gre/modules/AndroidLog.sys.mjs") + * .AndroidLog; + * + * // Use Log.i, Log.v, Log.d, Log.w, and Log.e to log verbose, debug, info, + * // warning, and error messages, respectively. + * Log.v("MyModule", "This is a verbose message."); + * Log.d("MyModule", "This is a debug message."); + * Log.i("MyModule", "This is an info message."); + * Log.w("MyModule", "This is a warning message."); + * Log.e("MyModule", "This is an error message."); + * + * // Bind a function with a tag to replace a bespoke dump/log/debug function: + * let debug = Log.d.bind(null, "MyModule"); + * debug("This is a debug message."); + * // Outputs "D/GeckoMyModule(#####): This is a debug message." + * + * // Or "bind" the module object to a tag to automatically tag messages: + * Log = Log.bind("MyModule"); + * Log.d("This is a debug message."); + * // Outputs "D/GeckoMyModule(#####): This is a debug message." + * + * Note: the module automatically prepends "Gecko" to the tag you specify, + * since all tags used by Fennec code should start with that string; and it + * truncates tags longer than MAX_TAG_LENGTH characters (not including "Gecko"). + */ + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +// From <https://android.googlesource.com/platform/system/core/+/master/include/android/log.h>. +const ANDROID_LOG_VERBOSE = 2; +const ANDROID_LOG_DEBUG = 3; +const ANDROID_LOG_INFO = 4; +const ANDROID_LOG_WARN = 5; +const ANDROID_LOG_ERROR = 6; + +// android.util.Log.isLoggable throws IllegalArgumentException if a tag length +// exceeds 23 characters, and we prepend five characters ("Gecko") to every tag. +// However, __android_log_write itself and other android.util.Log methods don't +// seem to mind longer tags. +const MAX_TAG_LENGTH = 18; + +var liblog = ctypes.open("liblog.so"); // /system/lib/liblog.so +var __android_log_write = liblog.declare( + "__android_log_write", + ctypes.default_abi, + ctypes.int, // return value: num bytes logged + ctypes.int, // priority (ANDROID_LOG_* constant) + ctypes.char.ptr, // tag + ctypes.char.ptr +); // message + +export var AndroidLog = { + MAX_TAG_LENGTH, + v: (tag, msg) => __android_log_write(ANDROID_LOG_VERBOSE, "Gecko" + tag, msg), + d: (tag, msg) => __android_log_write(ANDROID_LOG_DEBUG, "Gecko" + tag, msg), + i: (tag, msg) => __android_log_write(ANDROID_LOG_INFO, "Gecko" + tag, msg), + w: (tag, msg) => __android_log_write(ANDROID_LOG_WARN, "Gecko" + tag, msg), + e: (tag, msg) => __android_log_write(ANDROID_LOG_ERROR, "Gecko" + tag, msg), + + bind(tag) { + return { + MAX_TAG_LENGTH, + v: AndroidLog.v.bind(null, tag), + d: AndroidLog.d.bind(null, tag), + i: AndroidLog.i.bind(null, tag), + w: AndroidLog.w.bind(null, tag), + e: AndroidLog.e.bind(null, tag), + }; + }, +}; diff --git a/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs b/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs new file mode 100644 index 0000000000..480a54e32d --- /dev/null +++ b/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs @@ -0,0 +1,21 @@ +/* -*- js-indent-level: 2; 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/. */ + +// Used by nsIBrowserUsage +export function getUniqueDomainsVisitedInPast24Hours() { + // The prompting heuristic for the storage access API looks at 1% of the + // number of the domains visited in the past 24 hours, with a minimum cap of + // 5 domains, in order to prevent prompts from showing up before a tracker is + // about to obtain tracking power over a significant portion of the user's + // cross-site browsing activity (that is, we do not want to allow automatic + // access grants over 1% of the domains). We have the + // dom.storage_access.max_concurrent_auto_grants which establishes the + // minimum cap here (set to 5 by default) so if we return 0 here the minimum + // cap would always take effect. That would only become inaccurate if the + // user has browsed more than 500 top-level eTLD's in the past 24 hours, + // which should be a very unlikely scenario on mobile anyway. + + return 0; +} diff --git a/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs b/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs new file mode 100644 index 0000000000..52a929511a --- /dev/null +++ b/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("ChildCrashHandler"); + +function getDir(name) { + const uAppDataPath = Services.dirsvc.get("UAppData", Ci.nsIFile).path; + return PathUtils.join(uAppDataPath, "Crash Reports", name); +} + +function getPendingMinidump(id) { + const pendingDir = getDir("pending"); + + return [".dmp", ".extra"].map(suffix => { + return PathUtils.join(pendingDir, `${id}${suffix}`); + }); +} + +export var ChildCrashHandler = { + // Map a child ID to a remote type. + childMap: new Map(), + + // The event listener for this is hooked up in GeckoViewStartup.jsm + observe(aSubject, aTopic, aData) { + const childID = aData; + + switch (aTopic) { + case "process-type-set": + // Intentional fall-through + case "ipc:content-created": { + const pp = aSubject.QueryInterface(Ci.nsIDOMProcessParent); + this.childMap.set(childID, pp.remoteType); + break; + } + + case "ipc:content-shutdown": + // Intentional fall-through + case "compositor:process-aborted": { + aSubject.QueryInterface(Ci.nsIPropertyBag2); + + const disableReporting = Services.env.get( + "MOZ_CRASHREPORTER_NO_REPORT" + ); + + if ( + !aSubject.get("abnormal") || + !AppConstants.MOZ_CRASHREPORTER || + disableReporting + ) { + return; + } + + // If dumpID is empty the process was likely killed by the system and + // we therefore do not want to report the crash. This includes most + // "expected" extensions process crashes on Android. + const dumpID = aSubject.get("dumpID"); + if (!dumpID) { + Services.telemetry + .getHistogramById("FX_CONTENT_CRASH_DUMP_UNAVAILABLE") + .add(1); + return; + } + + debug`Notifying child process crash, dump ID ${dumpID}`; + const [minidumpPath, extrasPath] = getPendingMinidump(dumpID); + + let remoteType = this.childMap.get(childID); + this.childMap.delete(childID); + + if (remoteType?.length) { + // Only send the remote type prefix since everything after a "=" is + // dynamic, and used to control the process pool to use. + remoteType = remoteType.split("=")[0]; + } + + // Report GPU and extension process crashes as occuring in a background + // process, and others as foreground. + const processType = + aTopic === "compositor:process-aborted" || remoteType === "extension" + ? "BACKGROUND_CHILD" + : "FOREGROUND_CHILD"; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:ChildCrashReport", + minidumpPath, + extrasPath, + success: true, + fatal: false, + processType, + remoteType, + }); + + break; + } + } + }, +}; diff --git a/mobile/android/modules/geckoview/DelayedInit.sys.mjs b/mobile/android/modules/geckoview/DelayedInit.sys.mjs new file mode 100644 index 0000000000..db5984c8ec --- /dev/null +++ b/mobile/android/modules/geckoview/DelayedInit.sys.mjs @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 DelayedInit to schedule initializers to run some time after startup. + * Initializers are added to a list of pending inits. Whenever the main thread + * message loop is idle, DelayedInit will start running initializers from the + * pending list. To prevent monopolizing the message loop, every idling period + * has a maximum duration. When that's reached, we give up the message loop and + * wait for the next idle. + * + * DelayedInit is compatible with lazy getters like those from XPCOMUtils. When + * the lazy getter is first accessed, its corresponding initializer is run + * automatically if it hasn't been run already. Each initializer also has a + * maximum wait parameter that specifies a mandatory timeout; when the timeout + * is reached, the initializer is forced to run. + * + * DelayedInit.schedule(() => Foo.init(), null, null, 5000); + * + * In the example above, Foo.init will run automatically when the message loop + * becomes idle, or when 5000ms has elapsed, whichever comes first. + * + * DelayedInit.schedule(() => Foo.init(), this, "Foo", 5000); + * + * In the example above, Foo.init will run automatically when the message loop + * becomes idle, when |this.Foo| is accessed, or when 5000ms has elapsed, + * whichever comes first. + * + * It may be simpler to have a wrapper for DelayedInit.schedule. For example, + * + * function InitLater(fn, obj, name) { + * return DelayedInit.schedule(fn, obj, name, 5000); // constant max wait + * } + * InitLater(() => Foo.init()); + * InitLater(() => Bar.init(), this, "Bar"); + */ +export var DelayedInit = { + schedule(fn, object, name, maxWait) { + return Impl.scheduleInit(fn, object, name, maxWait); + }, + + scheduleList(fns, maxWait) { + for (const fn of fns) { + Impl.scheduleInit(fn, null, null, maxWait); + } + }, +}; + +// Maximum duration for each idling period. Pending inits are run until this +// duration is exceeded; then we wait for next idling period. +const MAX_IDLE_RUN_MS = 50; + +var Impl = { + pendingInits: [], + + onIdle() { + const startTime = Cu.now(); + let time = startTime; + let nextDue; + + // Go through all the pending inits. Even if we don't run them, + // we still need to find out when the next timeout should be. + for (const init of this.pendingInits) { + if (init.complete) { + continue; + } + + if (time - startTime < MAX_IDLE_RUN_MS) { + init.maybeInit(); + time = Cu.now(); + } else { + // We ran out of time; find when the next closest due time is. + nextDue = nextDue ? Math.min(nextDue, init.due) : init.due; + } + } + + // Get rid of completed ones. + this.pendingInits = this.pendingInits.filter(init => !init.complete); + + if (nextDue !== undefined) { + // Schedule the next idle, if we still have pending inits. + ChromeUtils.idleDispatch(() => this.onIdle(), { + timeout: Math.max(0, nextDue - time), + }); + } + }, + + addPendingInit(fn, wait) { + const init = { + fn, + due: Cu.now() + wait, + complete: false, + maybeInit() { + if (this.complete) { + return false; + } + this.complete = true; + this.fn.call(); + this.fn = null; + return true; + }, + }; + + if (!this.pendingInits.length) { + // Schedule for the first idle. + ChromeUtils.idleDispatch(() => this.onIdle(), { timeout: wait }); + } + this.pendingInits.push(init); + return init; + }, + + scheduleInit(fn, object, name, wait) { + const init = this.addPendingInit(fn, wait); + + if (!object || !name) { + // No lazy getter needed. + return; + } + + // Get any existing information about the property. + let prop = Object.getOwnPropertyDescriptor(object, name) || { + configurable: true, + enumerable: true, + writable: true, + }; + + if (!prop.configurable) { + // Object.defineProperty won't work, so just perform init here. + init.maybeInit(); + return; + } + + // Define proxy getter/setter that will call first initializer first, + // before delegating the get/set to the original target. + Object.defineProperty(object, name, { + get: function proxy_getter() { + init.maybeInit(); + + // If the initializer actually ran, it may have replaced our proxy + // property with a real one, so we need to reload he property. + const newProp = Object.getOwnPropertyDescriptor(object, name); + if (newProp.get !== proxy_getter) { + // Set prop if newProp doesn't refer to our proxy property. + prop = newProp; + } else { + // Otherwise, reset to the original property. + Object.defineProperty(object, name, prop); + } + + if (prop.get) { + return prop.get.call(object); + } + return prop.value; + }, + set(newVal) { + init.maybeInit(); + + // Since our initializer already ran, + // we can get rid of our proxy property. + if (prop.get || prop.set) { + Object.defineProperty(object, name, prop); + prop.set.call(object); + return; + } + + prop.value = newVal; + Object.defineProperty(object, name, prop); + }, + configurable: true, + enumerable: true, + }); + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs new file mode 100644 index 0000000000..8b728e7544 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs @@ -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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { EventDispatcher } from "resource://gre/modules/Messaging.sys.mjs"; + +export class GeckoViewActorChild extends JSWindowActorChild { + static initLogging(aModuleName) { + const tag = aModuleName.replace("GeckoView", "") + "[C]"; + return GeckoViewUtils.initLogging(tag); + } + + actorCreated() { + this.eventDispatcher = EventDispatcher.forActor(this); + } +} + +const { debug, warn } = GeckoViewUtils.initLogging("Actor[C]"); diff --git a/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs new file mode 100644 index 0000000000..991dfe7581 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs @@ -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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const actors = new Set(); + +export var GeckoViewActorManager = { + addJSWindowActors(actors) { + for (const [actorName, actor] of Object.entries(actors)) { + this._register(actorName, actor); + } + }, + + _register(actorName, actor) { + if (actors.has(actorName)) { + // Actor already registered, nothing to do + return; + } + + ChromeUtils.registerWindowActor(actorName, actor); + actors.add(actorName); + }, +}; + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewActorManager"); diff --git a/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs new file mode 100644 index 0000000000..c4569fb052 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs @@ -0,0 +1,51 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +export class GeckoViewActorParent extends JSWindowActorParent { + static initLogging(aModuleName) { + const tag = aModuleName.replace("GeckoView", ""); + return GeckoViewUtils.initLogging(tag); + } + + get browser() { + return this.browsingContext.top.embedderElement; + } + + get window() { + const { browsingContext } = this; + // If this is a chrome actor, the chrome window will be at + // browsingContext.window. + if (!browsingContext.isContent && browsingContext.window) { + return browsingContext.window; + } + return this.browser?.ownerGlobal; + } + + get eventDispatcher() { + return this.window?.moduleManager.eventDispatcher; + } + + receiveMessage(aMessage) { + if (!this.window) { + // If we have no window, it means that this browsingContext has been + // destroyed already and there's nothing to do here for us. + debug`receiveMessage window destroyed ${aMessage.name} ${aMessage.data?.type}`; + return null; + } + + switch (aMessage.name) { + case "DispatcherMessage": + return this.eventDispatcher.sendRequest(aMessage.data); + case "DispatcherQuery": + return this.eventDispatcher.sendRequestForResult(aMessage.data); + } + + // By default messages are forwarded to the module. + return this.window.moduleManager.onMessageFromActor(this.name, aMessage); + } +} + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewActorParent"); diff --git a/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs b/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs new file mode 100644 index 0000000000..2bac20281e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs @@ -0,0 +1,730 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "LoginInfo", () => + Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" + ) +); + +export class LoginEntry { + constructor({ + origin, + formActionOrigin, + httpRealm, + username, + password, + guid, + timeCreated, + timeLastUsed, + timePasswordChanged, + timesUsed, + }) { + this.origin = origin ?? ""; + this.formActionOrigin = formActionOrigin ?? null; + this.httpRealm = httpRealm ?? null; + this.username = username ?? ""; + this.password = password ?? ""; + + // Metadata. + this.guid = guid ?? null; + // TODO: Not supported by GV. + this.timeCreated = timeCreated ?? null; + this.timeLastUsed = timeLastUsed ?? null; + this.timePasswordChanged = timePasswordChanged ?? null; + this.timesUsed = timesUsed ?? null; + } + + toLoginInfo() { + const info = new lazy.LoginInfo( + this.origin, + this.formActionOrigin, + this.httpRealm, + this.username, + this.password + ); + + // Metadata. + info.QueryInterface(Ci.nsILoginMetaInfo); + info.guid = this.guid; + info.timeCreated = this.timeCreated; + info.timeLastUsed = this.timeLastUsed; + info.timePasswordChanged = this.timePasswordChanged; + info.timesUsed = this.timesUsed; + + return info; + } + + static parse(aObj) { + const entry = new LoginEntry({}); + Object.assign(entry, aObj); + + return entry; + } + + static fromLoginInfo(aInfo) { + const entry = new LoginEntry({}); + entry.origin = aInfo.origin; + entry.formActionOrigin = aInfo.formActionOrigin; + entry.httpRealm = aInfo.httpRealm; + entry.username = aInfo.username; + entry.password = aInfo.password; + + // Metadata. + aInfo.QueryInterface(Ci.nsILoginMetaInfo); + entry.guid = aInfo.guid; + entry.timeCreated = aInfo.timeCreated; + entry.timeLastUsed = aInfo.timeLastUsed; + entry.timePasswordChanged = aInfo.timePasswordChanged; + entry.timesUsed = aInfo.timesUsed; + + return entry; + } +} + +export class Address { + constructor({ + name, + givenName, + additionalName, + familyName, + organization, + streetAddress, + addressLevel1, + addressLevel2, + addressLevel3, + postalCode, + country, + tel, + email, + guid, + timeCreated, + timeLastUsed, + timeLastModified, + timesUsed, + version, + }) { + this.name = name ?? ""; + this.givenName = givenName ?? ""; + this.additionalName = additionalName ?? ""; + this.familyName = familyName ?? ""; + this.organization = organization ?? ""; + this.streetAddress = streetAddress ?? ""; + this.addressLevel1 = addressLevel1 ?? ""; + this.addressLevel2 = addressLevel2 ?? ""; + this.addressLevel3 = addressLevel3 ?? ""; + this.postalCode = postalCode ?? ""; + this.country = country ?? ""; + this.tel = tel ?? ""; + this.email = email ?? ""; + + // Metadata. + this.guid = guid ?? null; + // TODO: Not supported by GV. + this.timeCreated = timeCreated ?? null; + this.timeLastUsed = timeLastUsed ?? null; + this.timeLastModified = timeLastModified ?? null; + this.timesUsed = timesUsed ?? null; + this.version = version ?? null; + } + + isValid() { + return ( + (this.name ?? this.givenName ?? this.familyName) !== "" && + this.streetAddress !== "" && + this.postalCode !== "" + ); + } + + static fromGecko(aObj) { + return new Address({ + version: aObj.version, + name: aObj.name, + givenName: aObj["given-name"], + additionalName: aObj["additional-name"], + familyName: aObj["family-name"], + organization: aObj.organization, + streetAddress: aObj["street-address"], + addressLevel1: aObj["address-level1"], + addressLevel2: aObj["address-level2"], + addressLevel3: aObj["address-level3"], + postalCode: aObj["postal-code"], + country: aObj.country, + tel: aObj.tel, + email: aObj.email, + guid: aObj.guid, + timeCreated: aObj.timeCreated, + timeLastUsed: aObj.timeLastUsed, + timeLastModified: aObj.timeLastModified, + timesUsed: aObj.timesUsed, + }); + } + + static parse(aObj) { + const entry = new Address({}); + Object.assign(entry, aObj); + + return entry; + } + + toGecko() { + return { + version: this.version, + name: this.name, + "given-name": this.givenName, + "additional-name": this.additionalName, + "family-name": this.familyName, + organization: this.organization, + "street-address": this.streetAddress, + "address-level1": this.addressLevel1, + "address-level2": this.addressLevel2, + "address-level3": this.addressLevel3, + "postal-code": this.postalCode, + country: this.country, + tel: this.tel, + email: this.email, + guid: this.guid, + }; + } +} + +export class CreditCard { + constructor({ + name, + number, + expMonth, + expYear, + type, + guid, + timeCreated, + timeLastUsed, + timeLastModified, + timesUsed, + version, + }) { + this.name = name ?? ""; + this.number = number ?? ""; + this.expMonth = expMonth ?? ""; + this.expYear = expYear ?? ""; + this.type = type ?? ""; + + // Metadata. + this.guid = guid ?? null; + // TODO: Not supported by GV. + this.timeCreated = timeCreated ?? null; + this.timeLastUsed = timeLastUsed ?? null; + this.timeLastModified = timeLastModified ?? null; + this.timesUsed = timesUsed ?? null; + this.version = version ?? null; + } + + isValid() { + return ( + this.name !== "" && + this.number !== "" && + this.expMonth !== "" && + this.expYear !== "" + ); + } + + static fromGecko(aObj) { + return new CreditCard({ + version: aObj.version, + name: aObj["cc-name"], + number: aObj["cc-number"], + expMonth: aObj["cc-exp-month"]?.toString(), + expYear: aObj["cc-exp-year"]?.toString(), + type: aObj["cc-type"], + guid: aObj.guid, + timeCreated: aObj.timeCreated, + timeLastUsed: aObj.timeLastUsed, + timeLastModified: aObj.timeLastModified, + timesUsed: aObj.timesUsed, + }); + } + + static parse(aObj) { + const entry = new CreditCard({}); + Object.assign(entry, aObj); + + return entry; + } + + toGecko() { + return { + version: this.version, + "cc-name": this.name, + "cc-number": this.number, + "cc-exp-month": this.expMonth, + "cc-exp-year": this.expYear, + "cc-type": this.type, + guid: this.guid, + }; + } +} + +export class SelectOption { + // Sync with Autocomplete.SelectOption.Hint in Autocomplete.java. + static Hint = { + NONE: 0, + GENERATED: 1 << 0, + INSECURE_FORM: 1 << 1, + DUPLICATE_USERNAME: 1 << 2, + MATCHING_ORIGIN: 1 << 3, + }; + + constructor({ value, hint }) { + this.value = value ?? null; + this.hint = hint ?? SelectOption.Hint.NONE; + } +} + +// Sync with Autocomplete.UsedField in Autocomplete.java. +const UsedField = { PASSWORD: 1 }; + +export const GeckoViewAutocomplete = { + /** current opened prompt */ + _prompt: null, + + /** + * Delegates login entry fetching for the given domain to the attached + * LoginStorage GeckoView delegate. + * + * @param aDomain + * The domain string to fetch login entries for. If null, all logins + * will be fetched. + * @return {Promise} + * Resolves with an array of login objects or null. + * Rejected if no delegate is attached. + * Login object string properties: + * { guid, origin, formActionOrigin, httpRealm, username, password } + */ + fetchLogins(aDomain = null) { + debug`fetchLogins for ${aDomain ?? "All domains"}`; + + return lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Autocomplete:Fetch:Login", + domain: aDomain, + }); + }, + + /** + * Delegates credit card entry fetching to the attached LoginStorage + * GeckoView delegate. + * + * @return {Promise} + * Resolves with an array of credit card objects or null. + * Rejected if no delegate is attached. + * Login object string properties: + * { guid, name, number, expMonth, expYear, type } + */ + fetchCreditCards() { + debug`fetchCreditCards`; + + return lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Autocomplete:Fetch:CreditCard", + }); + }, + + /** + * Delegates address entry fetching to the attached LoginStorage + * GeckoView delegate. + * + * @return {Promise} + * Resolves with an array of address objects or null. + * Rejected if no delegate is attached. + * Login object string properties: + * { guid, name, givenName, additionalName, familyName, + * organization, streetAddress, addressLevel1, addressLevel2, + * addressLevel3, postalCode, country, tel, email } + */ + fetchAddresses() { + debug`fetchAddresses`; + + return lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Autocomplete:Fetch:Address", + }); + }, + + /** + * Delegates credit card entry saving to the attached LoginStorage GeckoView delegate. + * Call this when a new or modified credit card entry has been submitted. + * + * @param aCreditCard The {CreditCard} to be saved. + */ + onCreditCardSave(aCreditCard) { + debug`onCreditCardSave ${aCreditCard}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Save:CreditCard", + creditCard: aCreditCard, + }); + }, + + /** + * Delegates address entry saving to the attached LoginStorage GeckoView delegate. + * Call this when a new or modified address entry has been submitted. + * + * @param aAddress The {Address} to be saved. + */ + onAddressSave(aAddress) { + debug`onAddressSave ${aAddress}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Save:Address", + address: aAddress, + }); + }, + + /** + * Delegates login entry saving to the attached LoginStorage GeckoView delegate. + * Call this when a new login entry or a new password for an existing login + * entry has been submitted. + * + * @param aLogin The {LoginEntry} to be saved. + */ + onLoginSave(aLogin) { + debug`onLoginSave ${aLogin}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Save:Login", + login: aLogin, + }); + }, + + /** + * Delegates login entry password usage to the attached LoginStorage GeckoView + * delegate. + * Call this when the password of an existing login entry, as returned by + * fetchLogins, has been used for autofill. + * + * @param aLogin The {LoginEntry} whose password was used. + */ + onLoginPasswordUsed(aLogin) { + debug`onLoginUsed ${aLogin}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Used:Login", + usedFields: UsedField.PASSWORD, + login: aLogin, + }); + }, + + _numActiveSelections: 0, + + /** + * Delegates login entry selection. + * Call this when there are multiple login entry option for a form to delegate + * the selection. + * + * @param aBrowser The browser instance the triggered the selection. + * @param aOptions The list of {SelectOption} depicting viable options. + */ + onLoginSelect(aBrowser, aOptions) { + debug`onLoginSelect ${aOptions}`; + + return new Promise((resolve, reject) => { + if (!aBrowser || !aOptions) { + debug`onLoginSelect Rejecting - no browser or options provided`; + reject(); + return; + } + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + { + type: "Autocomplete:Select:Login", + options: aOptions, + }, + result => { + if (!result || !result.selection) { + reject(); + return; + } + + const option = new SelectOption({ + value: LoginEntry.parse(result.selection.value), + hint: result.selection.hint, + }); + resolve(option); + } + ); + this._prompt = prompt; + }); + }, + + /** + * Delegates credit card entry selection. + * Call this when there are multiple credit card entry option for a form to delegate + * the selection. + * + * @param aBrowser The browser instance the triggered the selection. + * @param aOptions The list of {SelectOption} depicting viable options. + */ + onCreditCardSelect(aBrowser, aOptions) { + debug`onCreditCardSelect ${aOptions}`; + + return new Promise((resolve, reject) => { + if (!aBrowser || !aOptions) { + debug`onCreditCardSelect Rejecting - no browser or options provided`; + reject(); + return; + } + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + { + type: "Autocomplete:Select:CreditCard", + options: aOptions, + }, + result => { + if (!result || !result.selection) { + reject(); + return; + } + + const option = new SelectOption({ + value: CreditCard.parse(result.selection.value), + hint: result.selection.hint, + }); + resolve(option); + } + ); + this._prompt = prompt; + }); + }, + + /** + * Delegates address entry selection. + * Call this when there are multiple address entry option for a form to delegate + * the selection. + * + * @param aBrowser The browser instance the triggered the selection. + * @param aOptions The list of {SelectOption} depicting viable options. + */ + onAddressSelect(aBrowser, aOptions) { + debug`onAddressSelect ${aOptions}`; + + return new Promise((resolve, reject) => { + if (!aBrowser || !aOptions) { + debug`onAddressSelect Rejecting - no browser or options provided`; + reject(); + return; + } + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + { + type: "Autocomplete:Select:Address", + options: aOptions, + }, + result => { + if (!result || !result.selection) { + reject(); + return; + } + + const option = new SelectOption({ + value: Address.parse(result.selection.value), + hint: result.selection.hint, + }); + resolve(option); + } + ); + this._prompt = prompt; + }); + }, + + async delegateSelection({ + browsingContext, + options, + inputElementIdentifier, + formOrigin, + }) { + debug`delegateSelection ${options}`; + + if (!options.length) { + return; + } + + let insecureHint = SelectOption.Hint.NONE; + let loginStyle = null; + + // TODO: Replace this string with more robust mechanics. + let selectionType = null; + const selectOptions = []; + + for (const option of options) { + switch (option.style) { + case "insecureWarning": { + // We depend on the insecure warning to be the first option. + insecureHint = SelectOption.Hint.INSECURE_FORM; + break; + } + case "generatedPassword": { + selectionType = "login"; + const comment = JSON.parse(option.comment); + selectOptions.push( + new SelectOption({ + value: new LoginEntry({ + password: comment.generatedPassword, + }), + hint: SelectOption.Hint.GENERATED | insecureHint, + }) + ); + break; + } + case "login": + // Fallthrough. + case "loginWithOrigin": { + selectionType = "login"; + loginStyle = option.style; + const comment = JSON.parse(option.comment); + + let hint = SelectOption.Hint.NONE | insecureHint; + if (comment.isDuplicateUsername) { + hint |= SelectOption.Hint.DUPLICATE_USERNAME; + } + if (comment.isOriginMatched) { + hint |= SelectOption.Hint.MATCHING_ORIGIN; + } + + selectOptions.push( + new SelectOption({ + value: LoginEntry.parse(comment.login), + hint, + }) + ); + break; + } + case "autofill-profile": { + const comment = JSON.parse(option.comment); + debug`delegateSelection ${comment}`; + const creditCard = CreditCard.fromGecko(comment); + const address = Address.fromGecko(comment); + if (creditCard.isValid()) { + selectionType = "creditCard"; + selectOptions.push( + new SelectOption({ + value: creditCard, + hint: insecureHint, + }) + ); + } else if (address.isValid()) { + selectionType = "address"; + selectOptions.push( + new SelectOption({ + value: address, + hint: insecureHint, + }) + ); + } + break; + } + default: + debug`delegateSelection - ignoring unknown option style ${option.style}`; + } + } + + if (selectOptions.length < 1) { + debug`Abort delegateSelection - no valid options provided`; + return; + } + + if (this._numActiveSelections > 0) { + debug`Abort delegateSelection - there is already one delegation active`; + return; + } + + ++this._numActiveSelections; + + let selectedOption = null; + const browser = browsingContext.top.embedderElement; + if (selectionType === "login") { + selectedOption = await this.onLoginSelect(browser, selectOptions).catch( + _ => { + debug`No GV delegate attached`; + } + ); + } else if (selectionType === "creditCard") { + selectedOption = await this.onCreditCardSelect( + browser, + selectOptions + ).catch(_ => { + debug`No GV delegate attached`; + }); + } else if (selectionType === "address") { + selectedOption = await this.onAddressSelect(browser, selectOptions).catch( + _ => { + debug`No GV delegate attached`; + } + ); + } + + // prompt is closed now. + this._prompt = null; + + --this._numActiveSelections; + + debug`delegateSelection selected option: ${selectedOption}`; + + if (selectionType === "login") { + const selectedLogin = selectedOption?.value?.toLoginInfo(); + + if (!selectedLogin) { + debug`Abort delegateSelection - no login entry selected`; + return; + } + + debug`delegateSelection - filling form`; + + const actor = + browsingContext.currentWindowGlobal.getActor("LoginManager"); + + await actor.fillForm({ + browser, + inputElementIdentifier, + loginFormOrigin: formOrigin, + login: selectedLogin, + style: + selectedOption.hint & SelectOption.Hint.GENERATED + ? "generatedPassword" + : loginStyle, + }); + } else if (selectionType === "creditCard") { + const selectedCreditCard = selectedOption?.value?.toGecko(); + const actor = + browsingContext.currentWindowGlobal.getActor("FormAutofill"); + + actor.sendAsyncMessage("FormAutofill:FillForm", selectedCreditCard); + } else if (selectionType === "address") { + const selectedAddress = selectedOption?.value?.toGecko(); + const actor = + browsingContext.currentWindowGlobal.getActor("FormAutofill"); + + actor.sendAsyncMessage("FormAutofill:FillForm", selectedAddress); + } + + debug`delegateSelection - form filled`; + }, + + delegateDismiss() { + debug`delegateDismiss`; + + this._prompt?.dismiss(); + }, +}; + +const { debug } = GeckoViewUtils.initLogging("GeckoViewAutocomplete"); diff --git a/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs b/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs new file mode 100644 index 0000000000..1249685aaa --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs @@ -0,0 +1,96 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +class Autofill { + constructor(sessionId, eventDispatcher) { + this.eventDispatcher = eventDispatcher; + this.sessionId = sessionId; + } + + start() { + this.eventDispatcher.sendRequest({ + type: "GeckoView:StartAutofill", + sessionId: this.sessionId, + }); + } + + add(node) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:AddAutofill", + node, + }); + } + + focus(node) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:OnAutofillFocus", + node, + }); + } + + update(node) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:UpdateAutofill", + node, + }); + } + + commit(node) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:CommitAutofill", + node, + }); + } + + clear() { + this.eventDispatcher.sendRequest({ + type: "GeckoView:ClearAutofill", + }); + } +} + +class AutofillManager { + sessions = new Set(); + autofill = null; + + ensure(sessionId, eventDispatcher) { + if (!this.sessions.has(sessionId)) { + this.autofill = new Autofill(sessionId, eventDispatcher); + this.sessions.add(sessionId); + this.autofill.start(); + } + // This could be called for an outdated session, in which case we will just + // ignore the autofill call. + if (sessionId !== this.autofill.sessionId) { + return null; + } + return this.autofill; + } + + get(sessionId) { + if (!this.autofill || sessionId !== this.autofill.sessionId) { + warn`Disregarding old session ${sessionId}`; + // We disregard old sessions + return null; + } + return this.autofill; + } + + delete(sessionId) { + this.sessions.delete(sessionId); + if (!this.autofill || sessionId !== this.autofill.sessionId) { + // this delete call might happen *after* the next session already + // started, in that case, we can safely ignore this call. + return; + } + this.autofill.clear(); + this.autofill = null; + } +} + +export var gAutofillManager = new AutofillManager(); + +const { debug, warn } = GeckoViewUtils.initLogging("Autofill"); diff --git a/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs b/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs new file mode 100644 index 0000000000..68cff76ba7 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("Module[C]"); + +export class GeckoViewChildModule { + static initLogging(aModuleName) { + this._moduleName = aModuleName; + const tag = aModuleName.replace("GeckoView", "") + "[C]"; + return GeckoViewUtils.initLogging(tag); + } + + static create(aGlobal, aModuleName) { + return new this(aModuleName || this._moduleName, aGlobal); + } + + constructor(aModuleName, aGlobal) { + this.moduleName = aModuleName; + this.messageManager = aGlobal; + this.enabled = false; + + if (!aGlobal._gvEventDispatcher) { + aGlobal._gvEventDispatcher = GeckoViewUtils.getDispatcherForWindow( + aGlobal.content + ); + aGlobal.addEventListener( + "unload", + event => { + if (event.target === this.messageManager) { + aGlobal._gvEventDispatcher.finalize(); + } + }, + { + mozSystemGroup: true, + } + ); + } + this.eventDispatcher = aGlobal._gvEventDispatcher; + + this.messageManager.addMessageListener( + "GeckoView:UpdateModuleState", + aMsg => { + if (aMsg.data.module !== this.moduleName) { + return; + } + + const { enabled } = aMsg.data; + + if (enabled !== this.enabled) { + if (!enabled) { + this.onDisable(); + } + + this.enabled = enabled; + + if (enabled) { + this.onEnable(); + } + } + } + ); + + this.onInit(); + + this.messageManager.sendAsyncMessage("GeckoView:ContentModuleLoaded", { + module: this.moduleName, + }); + } + + // Override to initialize module. + onInit() {} + + // Override to enable module after setting a Java delegate. + onEnable() {} + + // Override to disable module after clearing the Java delegate. + onDisable() {} +} diff --git a/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs b/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs new file mode 100644 index 0000000000..2c8e55c380 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs @@ -0,0 +1,99 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs", +}); + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug } = GeckoViewUtils.initLogging("GeckoViewClipboardPermission"); + +export var GeckoViewClipboardPermission = { + confirmUserPaste(aWindowContext) { + return new Promise((resolve, reject) => { + if (!aWindowContext) { + reject( + Components.Exception("Null window context.", Cr.NS_ERROR_INVALID_ARG) + ); + return; + } + + const { document } = aWindowContext.browsingContext.topChromeWindow; + if (!document) { + reject( + Components.Exception( + "Unable to get chrome document.", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + + if (this._pendingRequest) { + reject( + Components.Exception( + "There is an ongoing request.", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + + this._pendingRequest = { resolve, reject }; + + const mouseXInCSSPixels = {}; + const mouseYInCSSPixels = {}; + const windowUtils = document.ownerGlobal.windowUtils; + windowUtils.getLastOverWindowPointerLocationInCSSPixels( + mouseXInCSSPixels, + mouseYInCSSPixels + ); + const screenRect = windowUtils.toScreenRect( + mouseXInCSSPixels.value, + mouseYInCSSPixels.value, + 0, + 0 + ); + + debug`confirmUserPaste (${screenRect.x}, ${screenRect.y})`; + + document.addEventListener("pointerdown", this); + document.ownerGlobal.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:ClipboardPermissionRequest", + screenPoint: { + x: screenRect.x, + y: screenRect.y, + }, + }).then( + allowOrDeny => { + const propBag = lazy.PromptUtils.objectToPropBag({ ok: allowOrDeny }); + this._pendingRequest.resolve(propBag); + this._pendingRequest = null; + document.removeEventListener("pointerdown", this); + }, + error => { + debug`Permission error: ${error}`; + this._pendingRequest.reject(); + this._pendingRequest = null; + document.removeEventListener("pointerdown", this); + } + ); + }); + }, + + // EventListener interface. + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "pointerdown": { + aEvent.target.ownerGlobal.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:DismissClipboardPermissionRequest", + }); + break; + } + } + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs b/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs new file mode 100644 index 0000000000..acf7a12aa2 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("Console"); + +const lazy = {}; + +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["mobile/android/geckoViewConsole.ftl"], true) +); + +export var GeckoViewConsole = { + _isEnabled: false, + + get enabled() { + return this._isEnabled; + }, + + set enabled(aVal) { + debug`enabled = ${aVal}`; + if (!!aVal === this._isEnabled) { + return; + } + + this._isEnabled = !!aVal; + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + if (this._isEnabled) { + this._consoleMessageListener = this._handleConsoleMessage.bind(this); + ConsoleAPIStorage.addLogEventListener( + this._consoleMessageListener, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + } else if (this._consoleMessageListener) { + ConsoleAPIStorage.removeLogEventListener(this._consoleMessageListener); + delete this._consoleMessageListener; + } + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed") { + this.enabled = Services.prefs.getBoolPref(aData, false); + } + }, + + _handleConsoleMessage(aMessage) { + aMessage = aMessage.wrappedJSObject; + + const mappedArguments = Array.from( + aMessage.arguments, + this.formatResult, + this + ); + const joinedArguments = mappedArguments.join(" "); + + if (aMessage.level == "error" || aMessage.level == "warn") { + const flag = + aMessage.level == "error" + ? Ci.nsIScriptError.errorFlag + : Ci.nsIScriptError.warningFlag; + const consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.init( + joinedArguments, + null, + null, + 0, + 0, + flag, + "content javascript" + ); + Services.console.logMessage(consoleMsg); + } else if (aMessage.level == "trace") { + const args = aMessage.arguments; + const msgDetails = args[0] ?? aMessage; + const filename = this.abbreviateSourceURL(msgDetails.filename); + const functionName = + msgDetails.functionName || + lazy.l10n.formatValueSync("console-stacktrace-anonymous-function"); + + let body = lazy.l10n.formatValueSync("console-stacktrace", { + filename, + functionName, + lineNumber: msgDetails.lineNumber ?? "", + }); + body += "\n"; + for (const aFrame of args) { + const functionName = + aFrame.functionName || + lazy.l10n.formatValueSync("console-stacktrace-anonymous-function"); + body += ` ${aFrame.filename} :: ${functionName} :: ${aFrame.lineNumber}\n`; + } + + Services.console.logStringMessage(body); + } else if (aMessage.level == "time" && aMessage.arguments) { + const body = lazy.l10n.formatValueSync("console-timer-start", { + name: aMessage.arguments.name ?? "", + }); + Services.console.logStringMessage(body); + } else if (aMessage.level == "timeEnd" && aMessage.arguments) { + const body = lazy.l10n.formatValueSync("console-timer-end", { + name: aMessage.arguments.name ?? "", + duration: aMessage.arguments.duration ?? "", + }); + Services.console.logStringMessage(body); + } else if ( + ["group", "groupCollapsed", "groupEnd"].includes(aMessage.level) + ) { + // Do nothing yet + } else { + Services.console.logStringMessage(joinedArguments); + } + }, + + getResultType(aResult) { + let type = aResult === null ? "null" : typeof aResult; + if (type == "object" && aResult.constructor && aResult.constructor.name) { + type = aResult.constructor.name; + } + return type.toLowerCase(); + }, + + formatResult(aResult) { + let output = ""; + const type = this.getResultType(aResult); + switch (type) { + case "string": + case "boolean": + case "date": + case "error": + case "number": + case "regexp": + output = aResult.toString(); + break; + case "null": + case "undefined": + output = type; + break; + default: + output = aResult.toString(); + break; + } + + return output; + }, + + abbreviateSourceURL(aSourceURL) { + // Remove any query parameters. + const hookIndex = aSourceURL.indexOf("?"); + if (hookIndex > -1) { + aSourceURL = aSourceURL.substring(0, hookIndex); + } + + // Remove a trailing "/". + if (aSourceURL[aSourceURL.length - 1] == "/") { + aSourceURL = aSourceURL.substring(0, aSourceURL.length - 1); + } + + // Remove all but the last path component. + const slashIndex = aSourceURL.lastIndexOf("/"); + if (slashIndex > -1) { + aSourceURL = aSourceURL.substring(slashIndex + 1); + } + + return aSourceURL; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs b/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs new file mode 100644 index 0000000000..b593a6f8e4 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs @@ -0,0 +1,762 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs", + ShoppingProduct: "chrome://global/content/shopping/ShoppingProduct.mjs", +}); + +export class GeckoViewContent extends GeckoViewModule { + onInit() { + this.registerListener([ + "GeckoViewContent:ExitFullScreen", + "GeckoView:ClearMatches", + "GeckoView:DisplayMatches", + "GeckoView:FindInPage", + "GeckoView:HasCookieBannerRuleForBrowsingContextTree", + "GeckoView:RestoreState", + "GeckoView:ContainsFormData", + "GeckoView:RequestCreateAnalysis", + "GeckoView:RequestAnalysisStatus", + "GeckoView:RequestAnalysisCreationStatus", + "GeckoView:PollForAnalysisCompleted", + "GeckoView:SendClickAttributionEvent", + "GeckoView:SendImpressionAttributionEvent", + "GeckoView:SendPlacementAttributionEvent", + "GeckoView:RequestAnalysis", + "GeckoView:RequestRecommendations", + "GeckoView:ReportBackInStock", + "GeckoView:ScrollBy", + "GeckoView:ScrollTo", + "GeckoView:SetActive", + "GeckoView:SetFocused", + "GeckoView:SetPriorityHint", + "GeckoView:UpdateInitData", + "GeckoView:ZoomToInput", + "GeckoView:IsPdfJs", + ]); + } + + onEnable() { + this.window.addEventListener( + "MozDOMFullscreen:Entered", + this, + /* capture */ true, + /* untrusted */ false + ); + this.window.addEventListener( + "MozDOMFullscreen:Exited", + this, + /* capture */ true, + /* untrusted */ false + ); + this.window.addEventListener( + "framefocusrequested", + this, + /* capture */ true, + /* untrusted */ false + ); + + this.window.addEventListener("DOMWindowClose", this); + this.window.addEventListener("pagetitlechanged", this); + this.window.addEventListener("pageinfo", this); + + this.window.addEventListener("cookiebannerdetected", this); + this.window.addEventListener("cookiebannerhandled", this); + + Services.obs.addObserver(this, "oop-frameloader-crashed"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + } + + onDisable() { + this.window.removeEventListener( + "MozDOMFullscreen:Entered", + this, + /* capture */ true + ); + this.window.removeEventListener( + "MozDOMFullscreen:Exited", + this, + /* capture */ true + ); + this.window.removeEventListener( + "framefocusrequested", + this, + /* capture */ true + ); + + this.window.removeEventListener("DOMWindowClose", this); + this.window.removeEventListener("pagetitlechanged", this); + this.window.removeEventListener("pageinfo", this); + + this.window.removeEventListener("cookiebannerdetected", this); + this.window.removeEventListener("cookiebannerhandled", this); + + Services.obs.removeObserver(this, "oop-frameloader-crashed"); + Services.obs.removeObserver(this, "ipc:content-shutdown"); + } + + get actor() { + return this.getActor("GeckoViewContent"); + } + + get isPdfJs() { + return ( + this.browser.contentPrincipal.spec === "resource://pdf.js/web/viewer.html" + ); + } + + // Goes up the browsingContext chain and sends the message every time + // we cross the process boundary so that every process in the chain is + // notified. + sendToAllChildren(aEvent, aData) { + let { browsingContext } = this.actor; + + while (browsingContext) { + if (!browsingContext.currentWindowGlobal) { + break; + } + + const currentPid = browsingContext.currentWindowGlobal.osPid; + const parentPid = browsingContext.parent?.currentWindowGlobal.osPid; + + if (currentPid != parentPid) { + const actor = + browsingContext.currentWindowGlobal.getActor("GeckoViewContent"); + actor.sendAsyncMessage(aEvent, aData); + } + + browsingContext = browsingContext.parent; + } + } + + #sendDOMFullScreenEventToAllChildren(aEvent) { + let { browsingContext } = this.actor; + + while (browsingContext) { + if (!browsingContext.currentWindowGlobal) { + break; + } + + const currentPid = browsingContext.currentWindowGlobal.osPid; + const parentPid = browsingContext.parent?.currentWindowGlobal.osPid; + + if (currentPid != parentPid) { + if (!browsingContext.parent) { + // Top level browsing context. Use origin actor (Bug 1505916). + const chromeBC = browsingContext.topChromeWindow?.browsingContext; + const requestOrigin = chromeBC?.fullscreenRequestOrigin?.get(); + if (requestOrigin) { + requestOrigin.browsingContext.currentWindowGlobal + .getActor("GeckoViewContent") + .sendAsyncMessage(aEvent, {}); + delete chromeBC.fullscreenRequestOrigin; + return; + } + } + const actor = + browsingContext.currentWindowGlobal.getActor("GeckoViewContent"); + actor.sendAsyncMessage(aEvent, {}); + } + + browsingContext = browsingContext.parent; + } + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoViewContent:ExitFullScreen": + this.browser.ownerDocument.exitFullscreen(); + break; + case "GeckoView:ClearMatches": { + if (!this.isPdfJs) { + this._clearMatches(); + } + break; + } + case "GeckoView:DisplayMatches": { + if (!this.isPdfJs) { + this._displayMatches(aData); + } + break; + } + case "GeckoView:FindInPage": { + if (!this.isPdfJs) { + this._findInPage(aData, aCallback); + } + break; + } + case "GeckoView:ZoomToInput": + // For ZoomToInput we just need to send the message to the current focused one. + const actor = + Services.focus.focusedContentBrowsingContext.currentWindowGlobal.getActor( + "GeckoViewContent" + ); + actor.sendAsyncMessage(aEvent, aData); + break; + case "GeckoView:ScrollBy": + // Unclear if that actually works with oop iframes? + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:ScrollTo": + // Unclear if that actually works with oop iframes? + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:UpdateInitData": + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:SetActive": + this.browser.docShellIsActive = !!aData.active; + break; + case "GeckoView:SetFocused": + if (aData.focused) { + this.browser.focus(); + this.browser.setAttribute("primary", "true"); + } else { + this.browser.removeAttribute("primary"); + this.browser.blur(); + } + break; + case "GeckoView:SetPriorityHint": + if (this.browser.isRemoteBrowser) { + const remoteTab = this.browser.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.priorityHint = aData.priorityHint; + } + } + break; + case "GeckoView:RestoreState": + this.actor.restoreState(aData); + break; + case "GeckoView:ContainsFormData": + this._containsFormData(aCallback); + break; + case "GeckoView:RequestAnalysis": + this._requestAnalysis(aData, aCallback); + break; + case "GeckoView:RequestCreateAnalysis": + this._requestCreateAnalysis(aData, aCallback); + break; + case "GeckoView:RequestAnalysisStatus": + this._requestAnalysisStatus(aData, aCallback); + break; + case "GeckoView:RequestAnalysisCreationStatus": + this._requestAnalysisCreationStatus(aData, aCallback); + break; + case "GeckoView:PollForAnalysisCompleted": + this._pollForAnalysisCompleted(aData, aCallback); + break; + case "GeckoView:SendClickAttributionEvent": + this._sendAttributionEvent("click", aData, aCallback); + break; + case "GeckoView:SendImpressionAttributionEvent": + this._sendAttributionEvent("impression", aData, aCallback); + break; + case "GeckoView:SendPlacementAttributionEvent": + this._sendAttributionEvent("placement", aData, aCallback); + break; + case "GeckoView:RequestRecommendations": + this._requestRecommendations(aData, aCallback); + break; + case "GeckoView:ReportBackInStock": + this._reportBackInStock(aData, aCallback); + break; + case "GeckoView:IsPdfJs": + aCallback.onSuccess(this.isPdfJs); + break; + case "GeckoView:HasCookieBannerRuleForBrowsingContextTree": + this._hasCookieBannerRuleForBrowsingContextTree(aCallback); + break; + } + } + + // DOM event handler + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "framefocusrequested": + if (this.browser != aEvent.target) { + return; + } + if (this.browser.hasAttribute("primary")) { + return; + } + this.eventDispatcher.sendRequest({ + type: "GeckoView:FocusRequest", + }); + aEvent.preventDefault(); + break; + case "MozDOMFullscreen:Entered": + if (this.browser == aEvent.target) { + // Remote browser; dispatch to content process. + this.#sendDOMFullScreenEventToAllChildren( + "GeckoView:DOMFullscreenEntered" + ); + } + break; + case "MozDOMFullscreen:Exited": + this.#sendDOMFullScreenEventToAllChildren( + "GeckoView:DOMFullscreenExited" + ); + break; + case "pagetitlechanged": + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageTitleChanged", + title: this.browser.contentTitle, + }); + break; + case "DOMWindowClose": + // We need this because we want to allow the app + // to close the window itself. If we don't preventDefault() + // here Gecko will close it immediately. + aEvent.preventDefault(); + + this.eventDispatcher.sendRequest({ + type: "GeckoView:DOMWindowClose", + }); + break; + case "pageinfo": + if (aEvent.detail.previewImageURL) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PreviewImage", + previewImageUrl: aEvent.detail.previewImageURL, + }); + } + break; + case "cookiebannerdetected": + this.eventDispatcher.sendRequest({ + type: "GeckoView:CookieBannerEvent:Detected", + }); + break; + case "cookiebannerhandled": + this.eventDispatcher.sendRequest({ + type: "GeckoView:CookieBannerEvent:Handled", + }); + break; + } + } + + // nsIObserver event handler + observe(aSubject, aTopic, aData) { + debug`observe: ${aTopic}`; + this._contentCrashed = false; + const browser = aSubject.ownerElement; + + switch (aTopic) { + case "oop-frameloader-crashed": { + if (!browser || browser != this.browser) { + return; + } + this.window.setTimeout(() => { + if (this._contentCrashed) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:ContentCrash", + }); + } else { + this.eventDispatcher.sendRequest({ + type: "GeckoView:ContentKill", + }); + } + }, 250); + break; + } + case "ipc:content-shutdown": { + aSubject.QueryInterface(Ci.nsIPropertyBag2); + if (aSubject.get("dumpID")) { + if ( + browser && + aSubject.get("childID") != browser.frameLoader.childID + ) { + return; + } + this._contentCrashed = true; + } + break; + } + } + } + + async _containsFormData(aCallback) { + aCallback.onSuccess(await this.actor.containsFormData()); + } + + async _requestAnalysis(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const analysis = { + analysis_url: "https://www.example.com/mock_analysis_url", + product_id: "ABCDEFG123", + grade: "B", + adjusted_rating: 4.5, + needs_analysis: true, + page_not_supported: true, + not_enough_reviews: true, + highlights: null, + last_analysis_time: 12345, + deleted_product_reported: true, + deleted_product: true, + }; + aCallback.onSuccess({ analysis }); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestAnalysis on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const analysis = await product.requestAnalysis(); + if (!analysis) { + aCallback.onError(`Product analysis returned null.`); + return; + } + aCallback.onSuccess({ analysis }); + } + } + + async _requestCreateAnalysis(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const status = "pending"; + aCallback.onSuccess(status); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestCreateAnalysis on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.requestCreateAnalysis(); + if (!status) { + aCallback.onError(`Creation of product analysis returned null.`); + return; + } + aCallback.onSuccess(status.status); + } + } + + async _requestAnalysisCreationStatus(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const status = "in_progress"; + aCallback.onSuccess(status); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError( + `Cannot requestAnalysisCreationStatus on a non-product url.` + ); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.requestAnalysisCreationStatus(); + if (!status) { + aCallback.onError( + `Status of creation of product analysis returned null.` + ); + return; + } + aCallback.onSuccess(status.status); + } + } + + async _requestAnalysisStatus(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const status = { status: "in_progress", progress: 90.9 }; + aCallback.onSuccess({ status }); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestAnalysisStatus on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.requestAnalysisCreationStatus(); + if (!status) { + aCallback.onError(`Status of product analysis returned null.`); + return; + } + aCallback.onSuccess({ status }); + } + } + + async _pollForAnalysisCompleted(aData, aCallback) { + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError( + `Cannot pollForAnalysisCompleted on a non-product url.` + ); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.pollForAnalysisCompleted(); + if (!status) { + aCallback.onError( + `Polling the status of creation of product analysis returned null.` + ); + return; + } + aCallback.onSuccess(status.status); + } + } + + async _sendAttributionEvent(aEvent, aData, aCallback) { + let result; + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + result = { TEST_AID: "TEST_AID_RESPONSE" }; + } else { + result = await lazy.ShoppingProduct.sendAttributionEvent( + aEvent, + aData.aid, + "geckoview_android" + ); + } + if (!result || !(aData.aid in result) || !result[aData.aid]) { + aCallback.onSuccess(false); + return; + } + aCallback.onSuccess(true); + } + + async _requestRecommendations(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const recommendations = [ + { + name: "Mock Product", + url: "https://example.com/mock_url", + image_url: "https://example.com/mock_image_url", + price: "450", + currency: "USD", + grade: "C", + adjusted_rating: 3.5, + analysis_url: "https://example.com/mock_analysis_url", + sponsored: true, + aid: "mock_aid", + }, + ]; + aCallback.onSuccess({ recommendations }); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestRecommendations on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const recommendations = await product.requestRecommendations(); + if (!recommendations) { + aCallback.onError(`Product recommendations returned null.`); + return; + } + aCallback.onSuccess({ recommendations }); + } + } + + async _reportBackInStock(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const message = "report created"; + aCallback.onSuccess(message); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot reportBackInStock on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const message = await product.sendReport(); + if (!message) { + aCallback.onError(`Reporting back in stock returned null.`); + return; + } + aCallback.onSuccess(message.message); + } + } + + async _hasCookieBannerRuleForBrowsingContextTree(aCallback) { + const { browsingContext } = this.actor; + aCallback.onSuccess( + Services.cookieBanners.hasRuleForBrowsingContextTree(browsingContext) + ); + } + + _findInPage(aData, aCallback) { + debug`findInPage: data=${aData} callback=${aCallback && "non-null"}`; + + let finder; + try { + finder = this.browser.finder; + } catch (e) { + if (aCallback) { + aCallback.onError(`No finder: ${e}`); + } + return; + } + + if (this._finderListener) { + finder.removeResultListener(this._finderListener); + } + + this._finderListener = { + response: { + found: false, + wrapped: false, + current: 0, + total: -1, + searchString: aData.searchString || finder.searchString, + linkURL: null, + clientRect: null, + flags: { + backwards: !!aData.backwards, + linksOnly: !!aData.linksOnly, + matchCase: !!aData.matchCase, + wholeWord: !!aData.wholeWord, + }, + }, + + onFindResult(aOptions) { + if (!aCallback || aOptions.searchString !== aData.searchString) { + // Result from a previous search. + return; + } + + Object.assign(this.response, { + found: aOptions.result !== Ci.nsITypeAheadFind.FIND_NOTFOUND, + wrapped: aOptions.result !== Ci.nsITypeAheadFind.FIND_FOUND, + linkURL: aOptions.linkURL, + clientRect: aOptions.rect && { + left: aOptions.rect.left, + top: aOptions.rect.top, + right: aOptions.rect.right, + bottom: aOptions.rect.bottom, + }, + flags: { + backwards: aOptions.findBackwards, + linksOnly: aOptions.linksOnly, + matchCase: this.response.flags.matchCase, + wholeWord: this.response.flags.wholeWord, + }, + }); + + if (!this.response.found) { + this.response.current = 0; + this.response.total = 0; + } + + // Only send response if we have a count. + if (!this.response.found || this.response.current !== 0) { + debug`onFindResult: ${this.response}`; + aCallback.onSuccess(this.response); + aCallback = undefined; + } + }, + + onMatchesCountResult(aResult) { + if (!aCallback || finder.searchString !== aData.searchString) { + // Result from a previous search. + return; + } + + Object.assign(this.response, { + current: aResult.current, + total: aResult.total, + }); + + // Only send response if we have a result. `found` and `wrapped` are + // both false only when we haven't received a result yet. + if (this.response.found || this.response.wrapped) { + debug`onMatchesCountResult: ${this.response}`; + aCallback.onSuccess(this.response); + aCallback = undefined; + } + }, + + onCurrentSelection() {}, + + onHighlightFinished() {}, + }; + + finder.caseSensitive = !!aData.matchCase; + finder.entireWord = !!aData.wholeWord; + finder.matchDiacritics = !!aData.matchDiacritics; + finder.addResultListener(this._finderListener); + + const drawOutline = + this._matchDisplayOptions && !!this._matchDisplayOptions.drawOutline; + + if (!aData.searchString || aData.searchString === finder.searchString) { + // Search again. + aData.searchString = finder.searchString; + finder.findAgain( + aData.searchString, + !!aData.backwards, + !!aData.linksOnly, + drawOutline + ); + } else { + finder.fastFind(aData.searchString, !!aData.linksOnly, drawOutline); + } + } + + _clearMatches() { + debug`clearMatches`; + + let finder; + try { + finder = this.browser.finder; + } catch (e) { + return; + } + + finder.removeSelection(); + finder.highlight(false); + + if (this._finderListener) { + finder.removeResultListener(this._finderListener); + this._finderListener = null; + } + } + + _displayMatches(aData) { + debug`displayMatches: data=${aData}`; + + let finder; + try { + finder = this.browser.finder; + } catch (e) { + return; + } + + this._matchDisplayOptions = aData; + finder.onModalHighlightChange(!!aData.dimPage); + finder.onHighlightAllChange(!!aData.highlightAll); + + if (!aData.highlightAll && !aData.dimPage) { + finder.highlight(false); + return; + } + + if (!this._finderListener || !finder.searchString) { + return; + } + const linksOnly = this._finderListener.response.linksOnly; + finder.highlight(true, finder.searchString, linksOnly, !!aData.drawOutline); + } +} + +const { debug, warn } = GeckoViewContent.initLogging("GeckoViewContent"); diff --git a/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs b/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs new file mode 100644 index 0000000000..d5d125444f --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs @@ -0,0 +1,113 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewContentBlocking extends GeckoViewModule { + onEnable() { + const flags = Ci.nsIWebProgress.NOTIFY_CONTENT_BLOCKING; + this.progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + this.progressFilter.addProgressListener(this, flags); + this.browser.addProgressListener(this.progressFilter, flags); + + this.registerListener(["ContentBlocking:RequestLog"]); + } + + onDisable() { + if (this.progressFilter) { + this.progressFilter.removeProgressListener(this); + this.browser.removeProgressListener(this.progressFilter); + delete this.progressFilter; + } + + this.unregisterListener(["ContentBlocking:RequestLog"]); + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "ContentBlocking:RequestLog": { + let bc = this.browser.browsingContext; + + if (!bc) { + warn`Failed to export content blocking log.`; + break; + } + + // Get the top-level browsingContext. The ContentBlockingLog is located + // in its current window global. + bc = bc.top; + + const topWindowGlobal = bc.currentWindowGlobal; + + if (!topWindowGlobal) { + warn`Failed to export content blocking log.`; + break; + } + + const log = JSON.parse(topWindowGlobal.contentBlockingLog); + const res = Object.keys(log).map(key => { + const blockData = log[key].map(data => { + return { + category: data[0], + blocked: data[1], + count: data[2], + }; + }); + return { + origin: key, + blockData, + }; + }); + + aCallback.onSuccess({ log: res }); + break; + } + } + } + + onContentBlockingEvent(aWebProgress, aRequest, aEvent) { + debug`onContentBlockingEvent ${aEvent.toString(16)}`; + + if (!(aRequest instanceof Ci.nsIClassifiedChannel)) { + return; + } + + const channel = aRequest.QueryInterface(Ci.nsIChannel); + const uri = channel.URI && channel.URI.spec; + + if (!uri) { + return; + } + + const classChannel = aRequest.QueryInterface(Ci.nsIClassifiedChannel); + const blockedList = classChannel.matchedList || null; + let loadedLists = []; + + if (aRequest instanceof Ci.nsIHttpChannel) { + loadedLists = classChannel.matchedTrackingLists || []; + } + + debug`onContentBlockingEvent matchedList: ${blockedList}`; + debug`onContentBlockingEvent matchedTrackingLists: ${loadedLists}`; + + const message = { + type: "GeckoView:ContentBlockingEvent", + uri, + category: aEvent, + blockedList, + loadedLists, + }; + + this.eventDispatcher.sendRequest(message); + } +} + +const { debug, warn } = GeckoViewContentBlocking.initLogging( + "GeckoViewContentBlocking" +); diff --git a/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs b/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs new file mode 100644 index 0000000000..043415122e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs @@ -0,0 +1,89 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +export const GeckoViewIdentityCredential = { + async onShowProviderPrompt(aBrowser, providers, resolve, reject) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + debug`onShowProviderPrompt`; + + prompt.asyncShowPrompt( + { + type: "IdentityCredential:Select:Provider", + providers, + }, + result => { + if (result && result.providerIndex != null) { + debug`onShowProviderPrompt resolve with ${result.providerIndex}`; + resolve(result.providerIndex); + } else { + debug`onShowProviderPrompt rejected`; + reject(); + } + } + ); + }, + async onShowAccountsPrompt(aBrowser, accounts, resolve, reject) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + debug`onShowAccountsPrompt`; + + prompt.asyncShowPrompt( + { + type: "IdentityCredential:Select:Account", + accounts, + }, + result => { + if (result && result.accountIndex != null) { + debug`onShowAccountsPrompt resolve with ${result.accountIndex}`; + resolve(result.accountIndex); + } else { + debug`onShowAccountsPrompt rejected`; + reject(); + } + } + ); + }, + async onShowPolicyPrompt( + aBrowser, + privacyPolicyUrl, + termsOfServiceUrl, + providerDomain, + host, + icon, + resolve, + reject + ) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + debug`onShowPolicyPrompt`; + + prompt.asyncShowPrompt( + { + type: "IdentityCredential:Show:Policy", + privacyPolicyUrl, + termsOfServiceUrl, + providerDomain, + host, + icon, + }, + result => { + if (result && result.accept != null) { + debug`onShowPolicyPrompt resolve with ${result.accept}`; + resolve(result.accept); + } else { + debug`onShowPolicyPrompt rejected`; + reject(); + } + } + ); + }, +}; + +const { debug } = GeckoViewUtils.initLogging("GeckoViewIdentityCredential"); diff --git a/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs b/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs new file mode 100644 index 0000000000..1b39125fce --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs @@ -0,0 +1,234 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewMediaControl extends GeckoViewModule { + onInit() { + debug`onInit`; + } + + onInitBrowser() { + debug`onInitBrowser`; + + const options = { + mozSystemGroup: true, + capture: false, + }; + + this.controller.addEventListener("activated", this, options); + this.controller.addEventListener("deactivated", this, options); + this.controller.addEventListener("supportedkeyschange", this, options); + this.controller.addEventListener("positionstatechange", this, options); + this.controller.addEventListener("metadatachange", this, options); + this.controller.addEventListener("playbackstatechange", this, options); + } + + onDestroyBrowser() { + debug`onDestroyBrowser`; + + this.controller.removeEventListener("activated", this); + this.controller.removeEventListener("deactivated", this); + this.controller.removeEventListener("supportedkeyschange", this); + this.controller.removeEventListener("positionstatechange", this); + this.controller.removeEventListener("metadatachange", this); + this.controller.removeEventListener("playbackstatechange", this); + } + + onEnable() { + debug`onEnable`; + + if (this.controller.isActive) { + this.handleActivated(); + } + + this.registerListener([ + "GeckoView:MediaSession:Play", + "GeckoView:MediaSession:Pause", + "GeckoView:MediaSession:Stop", + "GeckoView:MediaSession:NextTrack", + "GeckoView:MediaSession:PrevTrack", + "GeckoView:MediaSession:SeekForward", + "GeckoView:MediaSession:SeekBackward", + "GeckoView:MediaSession:SkipAd", + "GeckoView:MediaSession:SeekTo", + "GeckoView:MediaSession:MuteAudio", + ]); + } + + onDisable() { + debug`onDisable`; + + this.unregisterListener(); + } + + get controller() { + return this.browser.browsingContext.mediaController; + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:MediaSession:Play": + this.controller.play(); + break; + case "GeckoView:MediaSession:Pause": + this.controller.pause(); + break; + case "GeckoView:MediaSession:Stop": + this.controller.stop(); + break; + case "GeckoView:MediaSession:NextTrack": + this.controller.nextTrack(); + break; + case "GeckoView:MediaSession:PrevTrack": + this.controller.prevTrack(); + break; + case "GeckoView:MediaSession:SeekForward": + this.controller.seekForward(); + break; + case "GeckoView:MediaSession:SeekBackward": + this.controller.seekBackward(); + break; + case "GeckoView:MediaSession:SkipAd": + this.controller.skipAd(); + break; + case "GeckoView:MediaSession:SeekTo": + this.controller.seekTo(aData.time, aData.fast); + break; + case "GeckoView:MediaSession:MuteAudio": + if (aData.mute) { + this.browser.mute(); + } else { + this.browser.unmute(); + } + break; + } + } + + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "activated": + this.handleActivated(); + break; + case "deactivated": + this.handleDeactivated(); + break; + case "supportedkeyschange": + this.handleSupportedKeysChanged(); + break; + case "positionstatechange": + this.handlePositionStateChanged(aEvent); + break; + case "metadatachange": + this.handleMetadataChanged(); + break; + case "playbackstatechange": + this.handlePlaybackStateChanged(); + break; + default: + warn`Unknown event type ${aEvent.type}`; + break; + } + } + + handleActivated() { + debug`handleActivated`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Activated", + }); + } + + handleDeactivated() { + debug`handleDeactivated`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Deactivated", + }); + } + + handlePositionStateChanged(aEvent) { + debug`handlePositionStateChanged`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:PositionState", + state: { + duration: aEvent.duration, + playbackRate: aEvent.playbackRate, + position: aEvent.position, + }, + }); + } + + handleSupportedKeysChanged() { + const supported = this.controller.supportedKeys; + + debug`handleSupportedKeysChanged ${supported}`; + + // Mapping it to a key-value store for compatibility with the JNI + // implementation for now. + const features = new Map(); + supported.forEach(key => { + features[key] = true; + }); + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Features", + features, + }); + } + + handleMetadataChanged() { + let metadata = null; + try { + metadata = this.controller.getMetadata(); + } catch (e) { + warn`Metadata not available`; + } + debug`handleMetadataChanged ${metadata}`; + + if (metadata) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Metadata", + metadata, + }); + } + } + + handlePlaybackStateChanged() { + const state = this.controller.playbackState; + let type = null; + + debug`handlePlaybackStateChanged ${state}`; + + switch (state) { + case "none": + type = "GeckoView:MediaSession:Playback:None"; + break; + case "paused": + type = "GeckoView:MediaSession:Playback:Paused"; + break; + case "playing": + type = "GeckoView:MediaSession:Playback:Playing"; + break; + } + + if (!type) { + return; + } + + this.eventDispatcher.sendRequest({ + type, + }); + } +} + +const { debug, warn } = GeckoViewMediaControl.initLogging( + "GeckoViewMediaControl" +); diff --git a/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs b/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs new file mode 100644 index 0000000000..87ad35d817 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("Module"); + +export class GeckoViewModule { + static initLogging(aModuleName) { + const tag = aModuleName.replace("GeckoView", ""); + return GeckoViewUtils.initLogging(tag); + } + + constructor(aModuleInfo) { + this._info = aModuleInfo; + + this._isContentLoaded = false; + this._eventProxy = new EventProxy(this, this.eventDispatcher); + + this.onInitBrowser(); + } + + get name() { + return this._info.name; + } + + get enabled() { + return this._info.enabled; + } + + get window() { + return this.moduleManager.window; + } + + getActor(aActorName) { + return this.moduleManager.getActor(aActorName); + } + + get browser() { + return this.moduleManager.browser; + } + + get messageManager() { + return this.moduleManager.messageManager; + } + + get eventDispatcher() { + return this.moduleManager.eventDispatcher; + } + + get settings() { + return this.moduleManager.settings; + } + + get moduleManager() { + return this._info.manager; + } + + // Override to initialize the browser before it is bound to the window. + onInitBrowser() {} + + // Override to cleanup when the browser is destroyed. + onDestroyBrowser() {} + + // Override to initialize module. + onInit() {} + + // Override to cleanup when the window is closed + onDestroy() {} + + // Override to detect settings change. Access settings via this.settings. + onSettingsUpdate() {} + + // Override to enable module after setting a Java delegate. + onEnable() {} + + // Override to disable module after clearing the Java delegate. + onDisable() {} + + // Override to perform actions when content module has started loading; + // by default, pause events so events that depend on content modules can work. + onLoadContentModule() { + this._eventProxy.enableQueuing(true); + } + + // Override to perform actions when content module has finished loading; + // by default, un-pause events and flush queued events. + onContentModuleLoaded() { + this._eventProxy.enableQueuing(false); + this._eventProxy.dispatchQueuedEvents(); + } + + registerListener(aEventList) { + this._eventProxy.registerListener(aEventList); + } + + unregisterListener(aEventList) { + this._eventProxy.unregisterListener(aEventList); + } +} + +class EventProxy { + constructor(aListener, aEventDispatcher) { + this.listener = aListener; + this.eventDispatcher = aEventDispatcher; + this._eventQueue = []; + this._registeredEvents = []; + this._enableQueuing = false; + } + + registerListener(aEventList) { + debug`registerListener ${aEventList}`; + this.eventDispatcher.registerListener(this, aEventList); + this._registeredEvents = this._registeredEvents.concat(aEventList); + } + + unregisterListener(aEventList) { + debug`unregisterListener`; + if (this._registeredEvents.length === 0) { + return; + } + + if (!aEventList) { + this.eventDispatcher.unregisterListener(this, this._registeredEvents); + this._registeredEvents = []; + } else { + this.eventDispatcher.unregisterListener(this, aEventList); + this._registeredEvents = this._registeredEvents.filter( + e => !aEventList.includes(e) + ); + } + } + + onEvent(aEvent, aData, aCallback) { + if (this._enableQueuing) { + debug`queue ${aEvent}, data=${aData}`; + this._eventQueue.unshift(arguments); + } else { + this._dispatch(...arguments); + } + } + + enableQueuing(aEnable) { + debug`enableQueuing ${aEnable}`; + this._enableQueuing = aEnable; + } + + _dispatch(aEvent, aData, aCallback) { + debug`dispatch ${aEvent}, data=${aData}`; + if (this.listener.onEvent) { + this.listener.onEvent(...arguments); + } else { + this.listener(...arguments); + } + } + + dispatchQueuedEvents() { + debug`dispatchQueued`; + while (this._eventQueue.length) { + const args = this._eventQueue.pop(); + this._dispatch(...args); + } + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs b/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs new file mode 100644 index 0000000000..287a605dff --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs @@ -0,0 +1,659 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.sys.mjs", + isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs", + TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "ReferrerInfo", () => + Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ) +); + +// Filter out request headers as per discussion in Bug #1567549 +// CONNECTION: Used by Gecko to manage connections +// HOST: Relates to how gecko will ultimately interpret the resulting resource as that +// determines the effective request URI +const BAD_HEADERS = ["connection", "host"]; + +// Headers use |\r\n| as separator so these characters cannot appear +// in the header name or value +const FORBIDDEN_HEADER_CHARACTERS = ["\n", "\r"]; + +// Keep in sync with GeckoSession.java +const HEADER_FILTER_CORS_SAFELISTED = 1; +// eslint-disable-next-line no-unused-vars +const HEADER_FILTER_UNRESTRICTED_UNSAFE = 2; + +// Create default ReferrerInfo instance for the given referrer URI string. +const createReferrerInfo = aReferrer => { + let referrerUri; + try { + referrerUri = Services.io.newURI(aReferrer); + } catch (ignored) {} + + return new lazy.ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, referrerUri); +}; + +function convertFlags(aFlags) { + let navFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (!aFlags) { + return navFlags; + } + // These need to match the values in GeckoSession.LOAD_FLAGS_* + if (aFlags & (1 << 0)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + if (aFlags & (1 << 1)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY; + } + if (aFlags & (1 << 2)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL; + } + if (aFlags & (1 << 3)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_POPUPS; + } + if (aFlags & (1 << 4)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CLASSIFIER; + } + if (aFlags & (1 << 5)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FORCE_ALLOW_DATA_URI; + } + if (aFlags & (1 << 6)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + } + if (aFlags & (1 << 7)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE; + } + return navFlags; +} + +// Handles navigation requests between Gecko and a GeckoView. +// Handles GeckoView:GoBack and :GoForward requests dispatched by +// GeckoView.goBack and .goForward. +// Dispatches GeckoView:LocationChange to the GeckoView on location change when +// active. +// Implements nsIBrowserDOMWindow. +export class GeckoViewNavigation extends GeckoViewModule { + onInitBrowser() { + this.window.browserDOMWindow = this; + + debug`sessionContextId=${this.settings.sessionContextId}`; + + if (this.settings.sessionContextId !== null) { + // Gecko may have issues with strings containing special characters, + // so we restrict the string format to a specific pattern. + if (!/^gvctx(-)?([a-f0-9]+)$/.test(this.settings.sessionContextId)) { + throw new Error("sessionContextId has illegal format"); + } + + this.browser.setAttribute( + "geckoViewSessionContextId", + this.settings.sessionContextId + ); + } + + // There may be a GeckoViewNavigation module in another window waiting for + // us to create a browser so it can set openWindowInfo, so allow them to do + // that now. + Services.obs.notifyObservers(this.window, "geckoview-window-created"); + } + + onInit() { + debug`onInit`; + + this.registerListener([ + "GeckoView:GoBack", + "GeckoView:GoForward", + "GeckoView:GotoHistoryIndex", + "GeckoView:LoadUri", + "GeckoView:Reload", + "GeckoView:Stop", + "GeckoView:PurgeHistory", + "GeckoView:DotPrintFinish", + ]); + + this._initialAboutBlank = true; + } + + validateHeader(key, value, filter) { + if (!key) { + // Key cannot be empty + return false; + } + + for (const c of FORBIDDEN_HEADER_CHARACTERS) { + if (key.includes(c) || value?.includes(c)) { + return false; + } + } + + if (BAD_HEADERS.includes(key.toLowerCase().trim())) { + return false; + } + + if ( + filter == HEADER_FILTER_CORS_SAFELISTED && + !this.window.windowUtils.isCORSSafelistedRequestHeader(key, value) + ) { + return false; + } + + return true; + } + + // Bundle event handler. + async onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:GoBack": + this.browser.goBack(aData.userInteraction); + break; + case "GeckoView:GoForward": + this.browser.goForward(aData.userInteraction); + break; + case "GeckoView:GotoHistoryIndex": + this.browser.gotoIndex(aData.index); + break; + case "GeckoView:LoadUri": + const { + uri, + referrerUri, + referrerSessionId, + flags, + headers, + headerFilter, + } = aData; + + let navFlags = convertFlags(flags); + // For performance reasons we don't call the LoadUriDelegate.loadUri + // from Gecko, and instead we call it directly in the loadUri Java API. + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE; + + let triggeringPrincipal, referrerInfo, csp; + if (referrerSessionId) { + const referrerWindow = Services.ww.getWindowByName(referrerSessionId); + triggeringPrincipal = referrerWindow.browser.contentPrincipal; + csp = referrerWindow.browser.csp; + + const referrerPolicy = referrerWindow.browser.referrerInfo + ? referrerWindow.browser.referrerInfo.referrerPolicy + : Ci.nsIReferrerInfo.EMPTY; + + referrerInfo = new lazy.ReferrerInfo( + referrerPolicy, + true, + referrerWindow.browser.documentURI + ); + } else if (referrerUri) { + referrerInfo = createReferrerInfo(referrerUri); + } else { + // External apps are treated like web pages, so they should not get + // a privileged principal. + const isExternal = + navFlags & Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL; + if (!isExternal || Services.io.newURI(uri).schemeIs("content")) { + // Always use the system principal as the triggering principal + // for user-initiated (ie. no referrer session and not external) + // loads. See discussion in bug 1573860. + triggeringPrincipal = + Services.scriptSecurityManager.getSystemPrincipal(); + } + } + + if (!triggeringPrincipal) { + triggeringPrincipal = + Services.scriptSecurityManager.createNullPrincipal({}); + } + + let additionalHeaders = null; + if (headers) { + additionalHeaders = ""; + for (const [key, value] of Object.entries(headers)) { + if (!this.validateHeader(key, value, headerFilter)) { + console.error(`Ignoring invalid header '${key}'='${value}'.`); + continue; + } + + additionalHeaders += `${key}:${value ?? ""}\r\n`; + } + + if (additionalHeaders != "") { + additionalHeaders = + lazy.E10SUtils.makeInputStream(additionalHeaders); + } else { + additionalHeaders = null; + } + } + + // For any navigation here, we should have an appropriate triggeringPrincipal: + // + // 1) If we have a referring session, triggeringPrincipal is the contentPrincipal from the + // referring document. + // 2) For certain URI schemes listed above, we will have a codebase principal. + // 3) In all other cases, we create a NullPrincipal. + // + // The navigation flags are driven by the app. We purposely do not propagate these from + // the referring document, but expect that the app will in most cases. + // + // The referrerInfo is derived from the referring document, if present, by propagating any + // referrer policy. If we only have the referrerUri from the app, we create a referrerInfo + // with the specified URI and no policy set. If no referrerUri is present and we have no + // referring session, the referrerInfo is null. + // + // csp is only present if we have a referring document, null otherwise. + this.browser.fixupAndLoadURIString(uri, { + flags: navFlags, + referrerInfo, + triggeringPrincipal, + headers: additionalHeaders, + csp, + }); + break; + case "GeckoView:Reload": + // At the moment, GeckoView only supports one reload, which uses + // nsIWebNavigation.LOAD_FLAGS_NONE flag, and the telemetry doesn't + // do anything to differentiate reloads (i.e normal vs skip caches) + // So whenever we add more reload methods, please make sure the + // telemetry probe is adjusted + this.browser.reloadWithFlags(convertFlags(aData.flags)); + break; + case "GeckoView:Stop": + this.browser.stop(); + break; + case "GeckoView:PurgeHistory": + this.browser.purgeSessionHistory(); + break; + case "GeckoView:DotPrintFinish": + var printActor = this.moduleManager.getActor("GeckoViewPrintDelegate"); + printActor.clearStaticClone(); + break; + } + } + + waitAndSetupWindow(aSessionId, aOpenWindowInfo, aName) { + if (!aSessionId) { + return Promise.resolve(null); + } + + return new Promise(resolve => { + const handler = { + observe(aSubject, aTopic, aData) { + if ( + aTopic === "geckoview-window-created" && + aSubject.name === aSessionId + ) { + // This value will be read by nsFrameLoader while it is being initialized. + aSubject.browser.openWindowInfo = aOpenWindowInfo; + + // Gecko will use this attribute to set the name of the opened window. + if (aName) { + aSubject.browser.setAttribute("name", aName); + } + + if ( + !aOpenWindowInfo.isRemote && + aSubject.browser.hasAttribute("remote") + ) { + // We cannot start in remote mode when we have an opener. + aSubject.browser.setAttribute("remote", "false"); + aSubject.browser.removeAttribute("remoteType"); + } + Services.obs.removeObserver(handler, "geckoview-window-created"); + resolve(aSubject); + } + }, + }; + + // This event is emitted from createBrowser() in geckoview.js + Services.obs.addObserver(handler, "geckoview-window-created"); + }); + } + + handleNewSession(aUri, aOpenWindowInfo, aWhere, aFlags, aName) { + debug`handleNewSession: uri=${aUri && aUri.spec} + where=${aWhere} flags=${aFlags}`; + + if (!this.enabled) { + return null; + } + + const newSessionId = Services.uuid + .generateUUID() + .toString() + .slice(1, -1) + .replace(/-/g, ""); + + const message = { + type: "GeckoView:OnNewSession", + uri: aUri ? aUri.displaySpec : "", + newSessionId, + }; + + // The window might be already open by the time we get the response from + // the Java layer, so we need to start waiting before sending the message. + const setupPromise = this.waitAndSetupWindow( + newSessionId, + aOpenWindowInfo, + aName + ); + + let browser = undefined; + this.eventDispatcher + .sendRequestForResult(message) + .then(didOpenSession => { + if (!didOpenSession) { + return Promise.reject(); + } + return setupPromise; + }) + .then( + window => { + browser = window.browser; + }, + () => { + browser = null; + } + ); + + // Wait indefinitely for app to respond with a browser or null + Services.tm.spinEventLoopUntil( + "GeckoViewNavigation.jsm:handleNewSession", + () => this.window.closed || browser !== undefined + ); + return browser || null; + } + + // nsIBrowserDOMWindow. + createContentWindow( + aUri, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp + ) { + debug`createContentWindow: uri=${aUri && aUri.spec} + where=${aWhere} flags=${aFlags}`; + + if ( + lazy.LoadURIDelegate.load( + this.window, + this.eventDispatcher, + aUri, + aWhere, + aFlags, + aTriggeringPrincipal + ) + ) { + // The app has handled the load, abort open-window handling. + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + const browser = this.handleNewSession( + aUri, + aOpenWindowInfo, + aWhere, + aFlags, + null + ); + if (!browser) { + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + return browser.browsingContext; + } + + // nsIBrowserDOMWindow. + createContentWindowInFrame(aUri, aParams, aWhere, aFlags, aName) { + debug`createContentWindowInFrame: uri=${aUri && aUri.spec} + where=${aWhere} flags=${aFlags} + name=${aName}`; + + if (aWhere === Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) { + return this.window.moduleManager.onPrintWindow(aParams); + } + + if ( + lazy.LoadURIDelegate.load( + this.window, + this.eventDispatcher, + aUri, + aWhere, + aFlags, + aParams.triggeringPrincipal + ) + ) { + // The app has handled the load, abort open-window handling. + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + const browser = this.handleNewSession( + aUri, + aParams.openWindowInfo, + aWhere, + aFlags, + aName + ); + if (!browser) { + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + return browser; + } + + handleOpenUri({ + uri, + openWindowInfo, + where, + flags, + triggeringPrincipal, + csp, + referrerInfo = null, + name = null, + }) { + debug`handleOpenUri: uri=${uri && uri.spec} + where=${where} flags=${flags}`; + + if ( + lazy.LoadURIDelegate.load( + this.window, + this.eventDispatcher, + uri, + where, + flags, + triggeringPrincipal + ) + ) { + return null; + } + + let browser = this.browser; + + if ( + where === Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW || + where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB || + where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB_BACKGROUND + ) { + browser = this.handleNewSession(uri, openWindowInfo, where, flags, name); + } + + if (!browser) { + // Should we throw? + return null; + } + + // 3) We have a new session and a browser element, load the requested URI. + browser.loadURI(uri, { + triggeringPrincipal, + csp, + referrerInfo, + }); + return browser; + } + + // nsIBrowserDOMWindow. + openURI(aUri, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) { + const browser = this.handleOpenUri({ + uri: aUri, + openWindowInfo: aOpenWindowInfo, + where: aWhere, + flags: aFlags, + triggeringPrincipal: aTriggeringPrincipal, + csp: aCsp, + }); + return browser && browser.browsingContext; + } + + // nsIBrowserDOMWindow. + openURIInFrame(aUri, aParams, aWhere, aFlags, aName) { + const browser = this.handleOpenUri({ + uri: aUri, + openWindowInfo: aParams.openWindowInfo, + where: aWhere, + flags: aFlags, + triggeringPrincipal: aParams.triggeringPrincipal, + csp: aParams.csp, + referrerInfo: aParams.referrerInfo, + name: aName, + }); + return browser; + } + + // nsIBrowserDOMWindow. + canClose() { + debug`canClose`; + return true; + } + + onEnable() { + debug`onEnable`; + + const flags = Ci.nsIWebProgress.NOTIFY_LOCATION; + this.progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + this.progressFilter.addProgressListener(this, flags); + this.browser.addProgressListener(this.progressFilter, flags); + } + + onDisable() { + debug`onDisable`; + + if (!this.progressFilter) { + return; + } + this.progressFilter.removeProgressListener(this); + this.browser.removeProgressListener(this.progressFilter); + } + + serializePermission({ type, capability, principal }) { + const { URI, originAttributes, privateBrowsingId } = principal; + return { + uri: Services.io.createExposableURI(URI).displaySpec, + principal: lazy.E10SUtils.serializePrincipal(principal), + perm: type, + value: capability, + contextId: originAttributes.geckoViewSessionContextId, + privateMode: privateBrowsingId != 0, + }; + } + + async isProductURL(aLocationURI) { + if (lazy.isProductURL(aLocationURI)) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:OnProductUrl", + }); + } + } + + // WebProgress event handler. + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + debug`onLocationChange`; + + let fixedURI = aLocationURI; + + try { + fixedURI = Services.io.createExposableURI(aLocationURI); + } catch (ex) {} + + // We manually fire the initial about:blank messages to make sure that we + // consistently send them so there's nothing to do here. + const ignore = this._initialAboutBlank && fixedURI.spec === "about:blank"; + this._initialAboutBlank = false; + + if (ignore) { + return; + } + + const { contentPrincipal } = this.browser; + let permissions; + if ( + contentPrincipal && + lazy.GeckoViewUtils.isSupportedPermissionsPrincipal(contentPrincipal) + ) { + let rawPerms = []; + try { + rawPerms = Services.perms.getAllForPrincipal(contentPrincipal); + } catch (ex) { + warn`Could not get permissions for principal. ${ex}`; + } + permissions = rawPerms.map(this.serializePermission); + + // The only way for apps to set permissions is to get hold of an existing + // permission and change its value. + // Tracking protection exception permissions are only present when + // explicitly added by the app, so if one is not present, we need to send + // a DENY_ACTION tracking protection permission so that apps can use it + // to add tracking protection exceptions. + const trackingProtectionPermission = + contentPrincipal.privateBrowsingId == 0 + ? "trackingprotection" + : "trackingprotection-pb"; + if ( + contentPrincipal.isContentPrincipal && + rawPerms.findIndex(p => p.type == trackingProtectionPermission) == -1 + ) { + permissions.push( + this.serializePermission({ + type: trackingProtectionPermission, + capability: Ci.nsIPermissionManager.DENY_ACTION, + principal: contentPrincipal, + }) + ); + } + } + + const message = { + type: "GeckoView:LocationChange", + uri: fixedURI.displaySpec, + canGoBack: this.browser.canGoBack, + canGoForward: this.browser.canGoForward, + isTopLevel: aWebProgress.isTopLevel, + permissions, + }; + lazy.TranslationsParent.onLocationChange(this.browser); + this.eventDispatcher.sendRequest(message); + + this.isProductURL(aLocationURI); + } +} + +const { debug, warn } = GeckoViewNavigation.initLogging("GeckoViewNavigation"); diff --git a/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs new file mode 100644 index 0000000000..7f6f14a29e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs @@ -0,0 +1,210 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewProcessHangMonitor extends GeckoViewModule { + constructor(aModuleInfo) { + super(aModuleInfo); + + /** + * Collection of hang reports that haven't expired or been dismissed + * by the user. These are nsIHangReports. + */ + this._activeReports = new Set(); + + /** + * Collection of hang reports that have been suppressed for a short + * period of time. Keys are nsIHangReports. Values are timeouts for + * when the wait time expires. + */ + this._pausedReports = new Map(); + + /** + * Simple index used for report identification + */ + this._nextIndex = 0; + + /** + * Map of report IDs to report objects. + * Keys are numbers. Values are nsIHangReports. + */ + this._reportIndex = new Map(); + + /** + * Map of report objects to report IDs. + * Keys are nsIHangReports. Values are numbers. + */ + this._reportLookupIndex = new Map(); + } + + onInit() { + debug`onInit`; + Services.obs.addObserver(this, "process-hang-report"); + Services.obs.addObserver(this, "clear-hang-report"); + } + + onDestroy() { + debug`onDestroy`; + Services.obs.removeObserver(this, "process-hang-report"); + Services.obs.removeObserver(this, "clear-hang-report"); + } + + onEnable() { + debug`onEnable`; + this.registerListener([ + "GeckoView:HangReportStop", + "GeckoView:HangReportWait", + ]); + } + + onDisable() { + debug`onDisable`; + this.unregisterListener(); + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + if (this._reportIndex.has(aData.hangId)) { + const report = this._reportIndex.get(aData.hangId); + switch (aEvent) { + case "GeckoView:HangReportStop": + this.stopHang(report); + break; + case "GeckoView:HangReportWait": + this.pauseHang(report); + break; + } + } else { + debug`Report not found: reportIndex=${this._reportIndex}`; + } + } + + // nsIObserver event handler + observe(aSubject, aTopic, aData) { + debug`observe(aTopic=${aTopic})`; + aSubject.QueryInterface(Ci.nsIHangReport); + if (!aSubject.isReportForBrowserOrChildren(this.browser.frameLoader)) { + return; + } + + switch (aTopic) { + case "process-hang-report": { + this.reportHang(aSubject); + break; + } + case "clear-hang-report": { + this.clearHang(aSubject); + break; + } + } + } + + /** + * This timeout is the wait period applied after a user selects "Wait" in + * an existing notification. + */ + get WAIT_EXPIRATION_TIME() { + try { + return Services.prefs.getIntPref("browser.hangNotification.waitPeriod"); + } catch (ex) { + return 10000; + } + } + + /** + * Terminate whatever is causing this report, be it an add-on or page script. + * This is done without updating any report notifications. + */ + stopHang(report) { + report.terminateScript(); + } + + /** + * + */ + pauseHang(report) { + this._activeReports.delete(report); + + // Create a new timeout with notify callback + const timer = this.window.setTimeout(() => { + for (const [stashedReport, otherTimer] of this._pausedReports) { + if (otherTimer === timer) { + this._pausedReports.delete(stashedReport); + + // We're still hung, so move the report back to the active + // list. + this._activeReports.add(report); + break; + } + } + }, this.WAIT_EXPIRATION_TIME); + + this._pausedReports.set(report, timer); + } + + /** + * construct an information bundle + */ + notifyReport(report) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:HangReport", + hangId: this._reportLookupIndex.get(report), + scriptFileName: report.scriptFileName, + }); + } + + /** + * Handle a potentially new hang report. + */ + reportHang(report) { + // if we aren't enabled then default to stopping the script + if (!this.enabled) { + this.stopHang(report); + return; + } + + // if we have already notified, remind + if (this._activeReports.has(report)) { + this.notifyReport(report); + return; + } + + // If this hang was already reported and paused by the user then ignore it. + if (this._pausedReports.has(report)) { + return; + } + + const index = this._nextIndex++; + this._reportLookupIndex.set(report, index); + this._reportIndex.set(index, report); + this._activeReports.add(report); + + // Actually notify the new report + this.notifyReport(report); + } + + clearHang(report) { + this._activeReports.delete(report); + + const timer = this._pausedReports.get(report); + if (timer) { + this.window.clearTimeout(timer); + } + this._pausedReports.delete(report); + + if (this._reportLookupIndex.has(report)) { + const index = this._reportLookupIndex.get(report); + this._reportIndex.delete(index); + } + this._reportLookupIndex.delete(report); + report.userCanceled(); + } +} + +const { debug, warn } = GeckoViewProcessHangMonitor.initLogging( + "GeckoViewProcessHangMonitor" +); diff --git a/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs b/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs new file mode 100644 index 0000000000..66aceb974c --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs @@ -0,0 +1,636 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "OverrideService", + "@mozilla.org/security/certoverride;1", + "nsICertOverrideService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "IDNService", + "@mozilla.org/network/idn-service;1", + "nsIIDNService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserTelemetryUtils: "resource://gre/modules/BrowserTelemetryUtils.sys.mjs", + GleanStopwatch: "resource://gre/modules/GeckoViewTelemetry.sys.mjs", +}); + +var IdentityHandler = { + // The definitions below should be kept in sync with those in GeckoView.ProgressListener.SecurityInformation + // No trusted identity information. No site identity icon is shown. + IDENTITY_MODE_UNKNOWN: 0, + + // Domain-Validation SSL CA-signed domain verification (DV). + IDENTITY_MODE_IDENTIFIED: 1, + + // Extended-Validation SSL CA-signed identity information (EV). A more rigorous validation process. + IDENTITY_MODE_VERIFIED: 2, + + // The following mixed content modes are only used if "security.mixed_content.block_active_content" + // is enabled. Our Java frontend coalesces them into one indicator. + + // No mixed content information. No mixed content icon is shown. + MIXED_MODE_UNKNOWN: 0, + + // Blocked active mixed content. + MIXED_MODE_CONTENT_BLOCKED: 1, + + // Loaded active mixed content. + MIXED_MODE_CONTENT_LOADED: 2, + + /** + * Determines the identity mode corresponding to the icon we show in the urlbar. + */ + getIdentityMode: function getIdentityMode(aState) { + if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) { + return this.IDENTITY_MODE_VERIFIED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_IS_SECURE) { + return this.IDENTITY_MODE_IDENTIFIED; + } + + return this.IDENTITY_MODE_UNKNOWN; + }, + + getMixedDisplayMode: function getMixedDisplayMode(aState) { + if (aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT) { + return this.MIXED_MODE_CONTENT_LOADED; + } + + if ( + aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT + ) { + return this.MIXED_MODE_CONTENT_BLOCKED; + } + + return this.MIXED_MODE_UNKNOWN; + }, + + getMixedActiveMode: function getActiveDisplayMode(aState) { + // Only show an indicator for loaded mixed content if the pref to block it is enabled + if ( + aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT && + !Services.prefs.getBoolPref("security.mixed_content.block_active_content") + ) { + return this.MIXED_MODE_CONTENT_LOADED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT) { + return this.MIXED_MODE_CONTENT_BLOCKED; + } + + return this.MIXED_MODE_UNKNOWN; + }, + + /** + * Determine the identity of the page being displayed by examining its SSL cert + * (if available). Return the data needed to update the UI. + */ + checkIdentity: function checkIdentity(aState, aBrowser) { + const identityMode = this.getIdentityMode(aState); + const mixedDisplay = this.getMixedDisplayMode(aState); + const mixedActive = this.getMixedActiveMode(aState); + const result = { + mode: { + identity: identityMode, + mixed_display: mixedDisplay, + mixed_active: mixedActive, + }, + }; + + if (aBrowser.contentPrincipal) { + result.origin = aBrowser.contentPrincipal.originNoSuffix; + } + + // Don't show identity data for pages with an unknown identity or if any + // mixed content is loaded (mixed display content is loaded by default). + if ( + identityMode === this.IDENTITY_MODE_UNKNOWN || + aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN || + aState & Ci.nsIWebProgressListener.STATE_IS_INSECURE + ) { + result.secure = false; + return result; + } + + result.secure = true; + + let uri = aBrowser.currentURI || {}; + try { + uri = Services.io.createExposableURI(uri); + } catch (e) {} + + try { + result.host = lazy.IDNService.convertToDisplayIDN(uri.host, {}); + } catch (e) { + result.host = uri.host; + } + + const cert = aBrowser.securityUI.secInfo.serverCert; + + result.certificate = + aBrowser.securityUI.secInfo.serverCert.getBase64DERString(); + + try { + result.securityException = lazy.OverrideService.hasMatchingOverride( + uri.host, + uri.port, + {}, + cert, + {} + ); + + // If an override exists, the connection is being allowed but should not + // be considered secure. + result.secure = !result.securityException; + } catch (e) {} + + return result; + }, +}; + +class Tracker { + constructor(aModule) { + this._module = aModule; + } + + get eventDispatcher() { + return this._module.eventDispatcher; + } + + get browser() { + return this._module.browser; + } + + QueryInterface = ChromeUtils.generateQI(["nsIWebProgressListener"]); +} + +class ProgressTracker extends Tracker { + constructor(aModule) { + super(aModule); + + this.pageLoadStopwatch = new lazy.GleanStopwatch( + Glean.geckoview.pageLoadTime + ); + this.pageReloadStopwatch = new lazy.GleanStopwatch( + Glean.geckoview.pageReloadTime + ); + this.pageLoadProgressStopwatch = new lazy.GleanStopwatch( + Glean.geckoview.pageLoadProgressTime + ); + + this.clear(); + this._eventReceived = null; + } + + start(aUri) { + debug`ProgressTracker start ${aUri}`; + + if (this._eventReceived) { + // A request was already in process, let's cancel it + this.stop(/* isSuccess */ false); + } + + this._eventReceived = new Set(); + this.clear(); + const data = this._data; + + if (aUri === "about:blank") { + data.uri = null; + return; + } + + this.pageLoadProgressStopwatch.start(); + + data.uri = aUri; + data.pageStart = true; + this.updateProgress(); + } + + changeLocation(aUri) { + debug`ProgressTracker changeLocation ${aUri}`; + + const data = this._data; + data.locationChange = true; + data.uri = aUri; + } + + stop(aIsSuccess) { + debug`ProgressTracker stop`; + + if (!this._eventReceived) { + // No request in progress + return; + } + + if (aIsSuccess) { + this.pageLoadProgressStopwatch.finish(); + } else { + this.pageLoadProgressStopwatch.cancel(); + } + + const data = this._data; + data.pageStop = true; + this.updateProgress(); + this._eventReceived = null; + } + + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + debug`ProgressTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel}, + flags=${aStateFlags}, status=${aStatus}`; + + if (!aWebProgress || !aWebProgress.isTopLevel) { + return; + } + + const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI; + + if (aRequest.URI.schemeIs("about")) { + return; + } + + debug`ProgressTracker onStateChange: uri=${displaySpec}`; + + const isPageReload = + (aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) != 0; + const stopwatch = isPageReload + ? this.pageReloadStopwatch + : this.pageLoadStopwatch; + + const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0; + const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0; + const isRedirecting = + (aStateFlags & Ci.nsIWebProgressListener.STATE_REDIRECTING) != 0; + + if (isStart) { + stopwatch.start(); + this.start(displaySpec); + } else if (isStop && !aWebProgress.isLoadingDocument) { + stopwatch.finish(); + this.stop(aStatus == Cr.NS_OK); + } else if (isRedirecting) { + stopwatch.start(); + this.start(displaySpec); + } + + // During history naviation, global window is recycled, so pagetitlechanged isn't fired + // Although Firefox Desktop always set title by onLocationChange, to reduce title change call, + // we only send title during history navigation. + if ((aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) != 0) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageTitleChanged", + title: this.browser.contentTitle, + }); + } + } + + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + if ( + !aWebProgress || + !aWebProgress.isTopLevel || + !aLocationURI || + aLocationURI.schemeIs("about") + ) { + return; + } + + debug`ProgressTracker onLocationChange: location=${aLocationURI.displaySpec}, + flags=${aFlags}`; + + if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { + this.stop(/* isSuccess */ false); + } else { + this.changeLocation(aLocationURI.displaySpec); + } + } + + handleEvent(aEvent) { + if (!this._eventReceived || this._eventReceived.has(aEvent.name)) { + // Either we're not tracking or we have received this event already + return; + } + + const data = this._data; + + if (!data.uri || data.uri !== aEvent.data?.uri) { + return; + } + + debug`ProgressTracker handleEvent: ${aEvent.name}`; + + let needsUpdate = false; + + switch (aEvent.name) { + case "DOMContentLoaded": + needsUpdate = needsUpdate || !data.parsed; + data.parsed = true; + break; + case "MozAfterPaint": + needsUpdate = needsUpdate || !data.firstPaint; + data.firstPaint = true; + break; + case "pageshow": + needsUpdate = needsUpdate || !data.pageShow; + data.pageShow = true; + break; + } + + this._eventReceived.add(aEvent.name); + + if (needsUpdate) { + this.updateProgress(); + } + } + + clear() { + this._data = { + prev: 0, + uri: null, + locationChange: false, + pageStart: false, + pageStop: false, + firstPaint: false, + pageShow: false, + parsed: false, + }; + } + + _debugData() { + return { + prev: this._data.prev, + uri: this._data.uri, + locationChange: this._data.locationChange, + pageStart: this._data.pageStart, + pageStop: this._data.pageStop, + firstPaint: this._data.firstPaint, + pageShow: this._data.pageShow, + parsed: this._data.parsed, + }; + } + + updateProgress() { + debug`ProgressTracker updateProgress`; + + const data = this._data; + + if (!this._eventReceived || !data.uri) { + return; + } + + let progress = 0; + if (data.pageStop || data.pageShow) { + progress = 100; + } else if (data.firstPaint) { + progress = 80; + } else if (data.parsed) { + progress = 55; + } else if (data.locationChange) { + progress = 30; + } else if (data.pageStart) { + progress = 15; + } + + if (data.prev >= progress) { + return; + } + + debug`ProgressTracker updateProgress data=${this._debugData()} + progress=${progress}`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:ProgressChanged", + progress, + }); + + data.prev = progress; + } +} + +class StateTracker extends Tracker { + constructor(aModule) { + super(aModule); + this._inProgress = false; + this._uri = null; + } + + start(aUri) { + this._inProgress = true; + this._uri = aUri; + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStart", + uri: aUri, + }); + } + + stop(aIsSuccess) { + if (!this._inProgress) { + // No request in progress + return; + } + + this._inProgress = false; + this._uri = null; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStop", + success: aIsSuccess, + }); + + lazy.BrowserTelemetryUtils.recordSiteOriginTelemetry( + Services.wm.getEnumerator("navigator:geckoview"), + true + ); + } + + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + debug`StateTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel}, + flags=${aStateFlags}, status=${aStatus} + loadType=${aWebProgress.loadType}`; + + if (!aWebProgress.isTopLevel) { + return; + } + + const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI; + const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0; + const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0; + + if (isStart) { + this.start(displaySpec); + } else if (isStop && !aWebProgress.isLoadingDocument) { + this.stop(aStatus == Cr.NS_OK); + } + } +} + +class SecurityTracker extends Tracker { + constructor(aModule) { + super(aModule); + this._hostChanged = false; + } + + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + debug`SecurityTracker onLocationChange: location=${aLocationURI.displaySpec}, + flags=${aFlags}`; + + this._hostChanged = true; + } + + onSecurityChange(aWebProgress, aRequest, aState) { + debug`onSecurityChange`; + + // Don't need to do anything if the data we use to update the UI hasn't changed + if (this._state === aState && !this._hostChanged) { + return; + } + + this._state = aState; + this._hostChanged = false; + + const identity = IdentityHandler.checkIdentity(aState, this.browser); + + this.eventDispatcher.sendRequest({ + type: "GeckoView:SecurityChanged", + identity, + }); + } +} + +export class GeckoViewProgress extends GeckoViewModule { + onEnable() { + debug`onEnable`; + + this._fireInitialLoad(); + this._initialAboutBlank = true; + + this._progressTracker = new ProgressTracker(this); + this._securityTracker = new SecurityTracker(this); + this._stateTracker = new StateTracker(this); + + const flags = + Ci.nsIWebProgress.NOTIFY_STATE_NETWORK | + Ci.nsIWebProgress.NOTIFY_SECURITY | + Ci.nsIWebProgress.NOTIFY_LOCATION; + this.progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + this.progressFilter.addProgressListener(this, flags); + this.browser.addProgressListener(this.progressFilter, flags); + Services.obs.addObserver(this, "oop-frameloader-crashed"); + this.registerListener("GeckoView:FlushSessionState"); + } + + onDisable() { + debug`onDisable`; + + if (this.progressFilter) { + this.progressFilter.removeProgressListener(this); + this.browser.removeProgressListener(this.progressFilter); + } + + Services.obs.removeObserver(this, "oop-frameloader-crashed"); + this.unregisterListener("GeckoView:FlushSessionState"); + } + + receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "DOMContentLoaded": // fall-through + case "MozAfterPaint": // fall-through + case "pageshow": { + this._progressTracker?.handleEvent(aMsg); + break; + } + } + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:FlushSessionState": + this.messageManager.sendAsyncMessage("GeckoView:FlushSessionState"); + break; + } + } + + onStateChange(...args) { + // GeckoView never gets PageStart or PageStop for about:blank because we + // set nodefaultsrc to true unconditionally so we can assume here that + // we're starting a page load for a non-blank page (or a consumer-initiated + // about:blank load). + this._initialAboutBlank = false; + + this._progressTracker.onStateChange(...args); + this._stateTracker.onStateChange(...args); + } + + onSecurityChange(...args) { + // We don't report messages about the initial about:blank + if (this._initialAboutBlank) { + return; + } + + this._securityTracker.onSecurityChange(...args); + } + + onLocationChange(...args) { + this._securityTracker.onLocationChange(...args); + this._progressTracker.onLocationChange(...args); + } + + // The initial about:blank load events are unreliable because docShell starts + // up concurrently with loading geckoview.js so we're never guaranteed to get + // the events. + // What we do instead is ignore all initial about:blank events and fire them + // manually once the child process has booted up. + _fireInitialLoad() { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStart", + uri: "about:blank", + }); + this.eventDispatcher.sendRequest({ + type: "GeckoView:LocationChange", + uri: "about:blank", + canGoBack: false, + canGoForward: false, + isTopLevel: true, + }); + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStop", + success: true, + }); + } + + // nsIObserver event handler + observe(aSubject, aTopic, aData) { + debug`observe: topic=${aTopic}`; + + switch (aTopic) { + case "oop-frameloader-crashed": { + const browser = aSubject.ownerElement; + if (!browser || browser != this.browser) { + return; + } + + this._progressTracker?.stop(/* isSuccess */ false); + this._stateTracker?.stop(/* isSuccess */ false); + } + } + } +} + +const { debug, warn } = GeckoViewProgress.initLogging("GeckoViewProgress"); diff --git a/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs b/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs new file mode 100644 index 0000000000..da2d7d04e9 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs @@ -0,0 +1,70 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "PushNotifier", + "@mozilla.org/push/Notifier;1", + "nsIPushNotifier" +); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPushController"); + +function createScopeAndPrincipal(scopeAndAttrs) { + const principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + scopeAndAttrs + ); + const scope = principal.URI.spec; + + return [scope, principal]; +} + +export const GeckoViewPushController = { + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:PushEvent": { + const { scope, data } = aData; + + const [url, principal] = createScopeAndPrincipal(scope); + + if ( + Services.perms.testPermissionFromPrincipal( + principal, + "desktop-notification" + ) != Services.perms.ALLOW_ACTION + ) { + return; + } + + if (!data) { + lazy.PushNotifier.notifyPush(url, principal, ""); + return; + } + + const payload = new Uint8Array( + ChromeUtils.base64URLDecode(data, { padding: "ignore" }) + ); + + lazy.PushNotifier.notifyPushWithData(url, principal, "", payload); + break; + } + case "GeckoView:PushSubscriptionChanged": { + const { scope } = aData; + + const [url, principal] = createScopeAndPrincipal(scope); + + lazy.PushNotifier.notifySubscriptionChange(url, principal); + break; + } + } + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs new file mode 100644 index 0000000000..c4d6bf7fb5 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs @@ -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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "require", () => { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return require; +}); + +ChromeUtils.defineLazyGetter(lazy, "DevToolsServer", () => { + const { DevToolsServer } = lazy.require("devtools/server/devtools-server"); + return DevToolsServer; +}); + +ChromeUtils.defineLazyGetter(lazy, "SocketListener", () => { + const { SocketListener } = lazy.require("devtools/shared/security/socket"); + return SocketListener; +}); + +const { debug, warn } = GeckoViewUtils.initLogging("RemoteDebugger"); + +export var GeckoViewRemoteDebugger = { + observe(aSubject, aTopic, aData) { + if (aTopic !== "nsPref:changed") { + return; + } + + if (Services.prefs.getBoolPref(aData, false)) { + this.onEnable(); + } else { + this.onDisable(); + } + }, + + onInit() { + debug`onInit`; + this._isEnabled = false; + this._usbDebugger = new USBRemoteDebugger(); + }, + + onEnable() { + if (this._isEnabled) { + return; + } + + debug`onEnable`; + lazy.DevToolsServer.init(); + lazy.DevToolsServer.registerAllActors(); + const { createRootActor } = lazy.require( + "resource://gre/modules/dbg-browser-actors.js" + ); + lazy.DevToolsServer.setRootActor(createRootActor); + lazy.DevToolsServer.allowChromeProcess = true; + lazy.DevToolsServer.chromeWindowType = "navigator:geckoview"; + // Force the Server to stay alive even if there are no connections at the moment. + lazy.DevToolsServer.keepAlive = true; + + // Socket address for USB remote debugger expects + // @ANDROID_PACKAGE_NAME/firefox-debugger-socket. + // In /proc/net/unix, it will be outputed as + // @org.mozilla.geckoview_example/firefox-debugger-socket + // + // If package name isn't available, it will be "@firefox-debugger-socket". + + let packageName = Services.env.get("MOZ_ANDROID_PACKAGE_NAME"); + if (packageName) { + packageName = packageName + "/"; + } else { + warn`Missing env MOZ_ANDROID_PACKAGE_NAME. Unable to get package name`; + } + + this._isEnabled = true; + this._usbDebugger.stop(); + + const portOrPath = packageName + "firefox-debugger-socket"; + this._usbDebugger.start(portOrPath); + }, + + onDisable() { + if (!this._isEnabled) { + return; + } + + debug`onDisable`; + this._isEnabled = false; + this._usbDebugger.stop(); + }, +}; + +class USBRemoteDebugger { + start(aPortOrPath) { + try { + const AuthenticatorType = + lazy.DevToolsServer.Authenticators.get("PROMPT"); + const authenticator = new AuthenticatorType.Server(); + authenticator.allowConnection = this.allowConnection.bind(this); + const socketOptions = { + authenticator, + portOrPath: aPortOrPath, + }; + this._listener = new lazy.SocketListener( + lazy.DevToolsServer, + socketOptions + ); + this._listener.open(); + debug`USB remote debugger - listening on ${aPortOrPath}`; + } catch (e) { + warn`Unable to start USB debugger server: ${e}`; + } + } + + stop() { + if (!this._listener) { + return; + } + + try { + this._listener.close(); + this._listener = null; + } catch (e) { + warn`Unable to stop USB debugger server: ${e}`; + } + } + + allowConnection(aSession) { + if (!this._listener) { + return lazy.DevToolsServer.AuthenticationResult.DENY; + } + + if (aSession.server.port) { + return lazy.DevToolsServer.AuthenticationResult.DENY; + } + return lazy.DevToolsServer.AuthenticationResult.ALLOW; + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs new file mode 100644 index 0000000000..07498e4b00 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs @@ -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/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewSelectionAction extends GeckoViewModule { + onEnable() { + debug`onEnable`; + this.registerListener(["GeckoView:ExecuteSelectionAction"]); + } + + onDisable() { + debug`onDisable`; + this.unregisterListener(); + } + + get actor() { + return this.getActor("SelectionActionDelegate"); + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: ${aEvent}`; + + switch (aEvent) { + case "GeckoView:ExecuteSelectionAction": { + this.actor.executeSelectionAction(aData); + } + } + } +} + +const { debug, warn } = GeckoViewSelectionAction.initLogging( + "GeckoViewSelectionAction" +); diff --git a/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs new file mode 100644 index 0000000000..584429295e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs @@ -0,0 +1,187 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("SessionStore"); +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +class SHistoryListener { + constructor(browsingContext) { + this.QueryInterface = ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]); + + this._browserId = browsingContext.browserId; + this._fromIndex = kNoIndex; + } + + unregister(permanentKey) { + const bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + bc?.sessionHistory?.removeSHistoryListener(this); + GeckoViewSessionStore._browserSHistoryListener?.delete(permanentKey); + } + + collect( + permanentKey, // eslint-disable-line no-shadow + browsingContext, // eslint-disable-line no-shadow + { collectFull = true, writeToCache = false } + ) { + // Don't bother doing anything if we haven't seen any navigations. + if (!collectFull && this._fromIndex === kNoIndex) { + return null; + } + + const fromIndex = collectFull ? -1 : this._fromIndex; + this._fromIndex = kNoIndex; + + const historychange = lazy.SessionHistory.collectFromParent( + browsingContext.currentURI?.spec, + true, // Bug 1704574 + browsingContext.sessionHistory, + fromIndex + ); + + if (writeToCache) { + const win = + browsingContext.embedderElement?.ownerGlobal || + browsingContext.currentWindowGlobal?.browsingContext?.window; + + GeckoViewSessionStore.onTabStateUpdate(permanentKey, win, { + data: { historychange }, + }); + } + + return historychange; + } + + collectFrom(index) { + if (this._fromIndex <= index) { + // If we already know that we need to update history from index N we + // can ignore any changes that happened with an element with index + // larger than N. + // + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which + // means we don't ignore anything here, and in case of navigation in + // the history back and forth cases we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + const bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + if (bc?.embedderElement?.frameLoader) { + this._fromIndex = index; + + // Queue a tab state update on the |browser.sessionstore.interval| + // timer. We'll call this.collect() when we receive the update. + bc.embedderElement.frameLoader.requestSHistoryUpdate(); + } + } + + OnHistoryNewEntry(newURI, oldIndex) { + // We use oldIndex - 1 to collect the current entry as well. This makes + // sure to collect any changes that were made to the entry while the + // document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + OnHistoryGotoIndex() { + this.collectFrom(kLastIndex); + } + OnHistoryPurge() { + this.collectFrom(-1); + } + OnHistoryReload() { + this.collectFrom(-1); + return true; + } + OnHistoryReplaceEntry() { + this.collectFrom(-1); + } +} + +export var GeckoViewSessionStore = { + // For each <browser> element, records the SHistoryListener. + _browserSHistoryListener: new WeakMap(), + + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + + switch (aTopic) { + case "browsing-context-did-set-embedder": { + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + const permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener + .get(permanentKey) + ?.unregister(permanentKey); + + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + } + break; + } + case "browsing-context-discarded": + const permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener + .get(permanentKey) + ?.unregister(permanentKey); + } + break; + } + }, + + onTabStateUpdate(permanentKey, win, data) { + win.WindowEventDispatcher.sendRequest({ + type: "GeckoView:StateUpdated", + data: data.data, + }); + }, + + getOrCreateSHistoryListener( + permanentKey, + browsingContext, + collectImmediately = false + ) { + if (!permanentKey || browsingContext !== browsingContext.top) { + return null; + } + + const sessionHistory = browsingContext.sessionHistory; + if (!sessionHistory) { + return null; + } + + let listener = this._browserSHistoryListener.get(permanentKey); + if (listener) { + return listener; + } + + listener = new SHistoryListener(browsingContext); + sessionHistory.addSHistoryListener(listener); + this._browserSHistoryListener.set(permanentKey, listener); + + if ( + collectImmediately && + (!(browsingContext.currentURI?.spec === "about:blank") || + sessionHistory.count !== 0) + ) { + listener.collect(permanentKey, browsingContext, { writeToCache: true }); + } + + return listener; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs new file mode 100644 index 0000000000..ec927b0af6 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "MOBILE_USER_AGENT", function () { + return Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).userAgent; +}); + +ChromeUtils.defineLazyGetter(lazy, "DESKTOP_USER_AGENT", function () { + return lazy.MOBILE_USER_AGENT.replace( + /Android \d.+?; [a-zA-Z]+/, + "X11; Linux x86_64" + ).replace(/Gecko\/[0-9\.]+/, "Gecko/20100101"); +}); + +ChromeUtils.defineLazyGetter(lazy, "VR_USER_AGENT", function () { + return lazy.MOBILE_USER_AGENT.replace(/Mobile/, "Mobile VR"); +}); + +// This needs to match GeckoSessionSettings.java +const USER_AGENT_MODE_MOBILE = 0; +const USER_AGENT_MODE_DESKTOP = 1; +const USER_AGENT_MODE_VR = 2; + +// This needs to match GeckoSessionSettings.java +const DISPLAY_MODE_BROWSER = 0; +const DISPLAY_MODE_MINIMAL_UI = 1; +const DISPLAY_MODE_STANDALONE = 2; +const DISPLAY_MODE_FULLSCREEN = 3; + +// This needs to match GeckoSessionSettings.java +// eslint-disable-next-line no-unused-vars +const VIEWPORT_MODE_MOBILE = 0; +const VIEWPORT_MODE_DESKTOP = 1; + +// Handles GeckoSession settings. +export class GeckoViewSettings extends GeckoViewModule { + onInit() { + debug`onInit`; + this._userAgentMode = USER_AGENT_MODE_MOBILE; + this._userAgentOverride = null; + this._sessionContextId = null; + + this.registerListener(["GeckoView:GetUserAgent"]); + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:GetUserAgent": { + aCallback.onSuccess(this.customUserAgent ?? lazy.MOBILE_USER_AGENT); + } + } + } + + onSettingsUpdate() { + const { settings } = this; + debug`onSettingsUpdate: ${settings}`; + + this.displayMode = settings.displayMode; + this.unsafeSessionContextId = settings.unsafeSessionContextId; + this.userAgentMode = settings.userAgentMode; + this.userAgentOverride = settings.userAgentOverride; + this.sessionContextId = settings.sessionContextId; + this.suspendMediaWhenInactive = settings.suspendMediaWhenInactive; + this.allowJavascript = settings.allowJavascript; + this.viewportMode = settings.viewportMode; + this.useTrackingProtection = !!settings.useTrackingProtection; + + // When the page is loading from the main process (e.g. from an extension + // page) we won't be able to query the actor here. + this.getActor("GeckoViewSettings")?.sendAsyncMessage( + "SettingsUpdate", + settings + ); + } + + get allowJavascript() { + return this.browsingContext.allowJavascript; + } + + set allowJavascript(aAllowJavascript) { + this.browsingContext.allowJavascript = aAllowJavascript; + } + + get customUserAgent() { + if (this.userAgentOverride !== null) { + return this.userAgentOverride; + } + if (this.userAgentMode === USER_AGENT_MODE_DESKTOP) { + return lazy.DESKTOP_USER_AGENT; + } + if (this.userAgentMode === USER_AGENT_MODE_VR) { + return lazy.VR_USER_AGENT; + } + return null; + } + + set useTrackingProtection(aUse) { + this.browsingContext.useTrackingProtection = aUse; + } + + set viewportMode(aViewportMode) { + this.browsingContext.forceDesktopViewport = + aViewportMode == VIEWPORT_MODE_DESKTOP; + } + + get userAgentMode() { + return this._userAgentMode; + } + + set userAgentMode(aMode) { + if (this.userAgentMode === aMode) { + return; + } + this._userAgentMode = aMode; + this.browsingContext.customUserAgent = this.customUserAgent; + } + + get browsingContext() { + return this.browser.browsingContext.top; + } + + get userAgentOverride() { + return this._userAgentOverride; + } + + set userAgentOverride(aUserAgent) { + if (aUserAgent === this.userAgentOverride) { + return; + } + this._userAgentOverride = aUserAgent; + this.browsingContext.customUserAgent = this.customUserAgent; + } + + get suspendMediaWhenInactive() { + return this.browser.suspendMediaWhenInactive; + } + + set suspendMediaWhenInactive(aSuspendMediaWhenInactive) { + if (aSuspendMediaWhenInactive != this.browser.suspendMediaWhenInactive) { + this.browser.suspendMediaWhenInactive = aSuspendMediaWhenInactive; + } + } + + displayModeSettingToValue(aSetting) { + switch (aSetting) { + case DISPLAY_MODE_BROWSER: + return "browser"; + case DISPLAY_MODE_MINIMAL_UI: + return "minimal-ui"; + case DISPLAY_MODE_STANDALONE: + return "standalone"; + case DISPLAY_MODE_FULLSCREEN: + return "fullscreen"; + default: + warn`Invalid displayMode value ${aSetting}.`; + return "browser"; + } + } + + set displayMode(aMode) { + this.browsingContext.displayMode = this.displayModeSettingToValue(aMode); + } + + set sessionContextId(aAttribute) { + this._sessionContextId = aAttribute; + } + + get sessionContextId() { + return this._sessionContextId; + } +} + +const { debug, warn } = GeckoViewSettings.initLogging("GeckoViewSettings"); diff --git a/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs b/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs new file mode 100644 index 0000000000..e69ad3b973 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs @@ -0,0 +1,351 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + PrincipalsCollector: "resource://gre/modules/PrincipalsCollector.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serviceMode", + "cookiebanners.service.mode", + Ci.nsICookieBannerService.MODE_DISABLED +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serviceModePBM", + "cookiebanners.service.mode.privateBrowsing", + Ci.nsICookieBannerService.MODE_DISABLED +); + +const { debug, warn } = GeckoViewUtils.initLogging( + "GeckoViewStorageController" +); + +// Keep in sync with StorageController.ClearFlags and nsIClearDataService.idl. +const ClearFlags = [ + [ + // COOKIES + 1 << 0, + Ci.nsIClearDataService.CLEAR_COOKIES | + Ci.nsIClearDataService.CLEAR_MEDIA_DEVICES | + Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD | + Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE | + Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE, + ], + [ + // NETWORK_CACHE + 1 << 1, + Ci.nsIClearDataService.CLEAR_NETWORK_CACHE, + ], + [ + // IMAGE_CACHE + 1 << 2, + Ci.nsIClearDataService.CLEAR_IMAGE_CACHE, + ], + [ + // HISTORY + 1 << 3, + Ci.nsIClearDataService.CLEAR_HISTORY | + Ci.nsIClearDataService.CLEAR_SESSION_HISTORY, + ], + [ + // DOM_STORAGES + 1 << 4, + Ci.nsIClearDataService.CLEAR_DOM_QUOTA | + Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS | + Ci.nsIClearDataService.CLEAR_REPORTS | + Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD | + Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE | + Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE, + ], + [ + // AUTH_SESSIONS + 1 << 5, + Ci.nsIClearDataService.CLEAR_AUTH_TOKENS | + Ci.nsIClearDataService.CLEAR_AUTH_CACHE, + ], + [ + // PERMISSIONS + 1 << 6, + Ci.nsIClearDataService.CLEAR_PERMISSIONS, + ], + [ + // SITE_SETTINGS + 1 << 7, + Ci.nsIClearDataService.CLEAR_CONTENT_PREFERENCES | + Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS | + // former a part of SECURITY_SETTINGS_CLEANER + Ci.nsIClearDataService.CLEAR_CLIENT_AUTH_REMEMBER_SERVICE | + Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE, + ], + [ + // SITE_DATA + 1 << 8, + Ci.nsIClearDataService.CLEAR_EME, + // former a part of SECURITY_SETTINGS_CLEANER + Ci.nsIClearDataService.CLEAR_HSTS, + ], + [ + // ALL + 1 << 9, + Ci.nsIClearDataService.CLEAR_ALL, + ], +]; + +function convertFlags(aJavaFlags) { + const flags = ClearFlags.filter(cf => { + return cf[0] & aJavaFlags; + }).reduce((acc, cf) => { + return acc | cf[1]; + }, 0); + return flags; +} + +export const GeckoViewStorageController = { + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:ClearData": { + this.clearData(aData.flags, aCallback); + break; + } + case "GeckoView:ClearSessionContextData": { + this.clearSessionContextData(aData.contextId); + break; + } + case "GeckoView:ClearHostData": { + this.clearHostData(aData.host, aData.flags, aCallback); + break; + } + case "GeckoView:ClearBaseDomainData": { + this.clearBaseDomainData(aData.baseDomain, aData.flags, aCallback); + break; + } + case "GeckoView:GetAllPermissions": { + const rawPerms = Services.perms.all; + const permissions = rawPerms.map(p => { + return { + uri: Services.io.createExposableURI(p.principal.URI).displaySpec, + principal: lazy.E10SUtils.serializePrincipal(p.principal), + perm: p.type, + value: p.capability, + contextId: p.principal.originAttributes.geckoViewSessionContextId, + privateMode: p.principal.privateBrowsingId != 0, + }; + }); + aCallback.onSuccess({ permissions }); + break; + } + case "GeckoView:GetPermissionsByURI": { + const uri = Services.io.newURI(aData.uri); + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + aData.contextId + ? { + geckoViewSessionContextId: aData.contextId, + privateBrowsingId: aData.privateBrowsingId, + } + : { privateBrowsingId: aData.privateBrowsingId } + ); + const rawPerms = Services.perms.getAllForPrincipal(principal); + const permissions = rawPerms.map(p => { + return { + uri: Services.io.createExposableURI(p.principal.URI).displaySpec, + principal: lazy.E10SUtils.serializePrincipal(p.principal), + perm: p.type, + value: p.capability, + contextId: p.principal.originAttributes.geckoViewSessionContextId, + privateMode: p.principal.privateBrowsingId != 0, + }; + }); + aCallback.onSuccess({ permissions }); + break; + } + case "GeckoView:SetPermission": { + const principal = lazy.E10SUtils.deserializePrincipal(aData.principal); + let key = aData.perm; + if (key == "storage-access") { + key = "3rdPartyFrameStorage^" + aData.thirdPartyOrigin; + } + if (aData.allowPermanentPrivateBrowsing) { + Services.perms.addFromPrincipalAndPersistInPrivateBrowsing( + principal, + key, + aData.newValue + ); + } else { + const expirePolicy = aData.privateMode + ? Ci.nsIPermissionManager.EXPIRE_SESSION + : Ci.nsIPermissionManager.EXPIRE_NEVER; + Services.perms.addFromPrincipal( + principal, + key, + aData.newValue, + expirePolicy + ); + } + break; + } + case "GeckoView:SetPermissionByURI": { + const uri = Services.io.newURI(aData.uri); + const expirePolicy = aData.privateId + ? Ci.nsIPermissionManager.EXPIRE_SESSION + : Ci.nsIPermissionManager.EXPIRE_NEVER; + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + { + geckoViewSessionContextId: aData.contextId ?? undefined, + privateBrowsingId: aData.privateId, + } + ); + Services.perms.addFromPrincipal( + principal, + aData.perm, + aData.newValue, + expirePolicy + ); + break; + } + + case "GeckoView:SetCookieBannerModeForDomain": { + let exceptionLabel = "SetCookieBannerModeForDomain"; + try { + const uri = Services.io.newURI(aData.uri); + if (aData.allowPermanentPrivateBrowsing) { + exceptionLabel = "setDomainPrefAndPersistInPrivateBrowsing"; + Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing( + uri, + aData.mode + ); + } else { + Services.cookieBanners.setDomainPref( + uri, + aData.mode, + aData.isPrivateBrowsing + ); + } + aCallback.onSuccess(); + } catch (ex) { + debug`Failed ${exceptionLabel} ${ex}`; + } + break; + } + + case "GeckoView:RemoveCookieBannerModeForDomain": { + try { + const uri = Services.io.newURI(aData.uri); + Services.cookieBanners.removeDomainPref(uri, aData.isPrivateBrowsing); + aCallback.onSuccess(); + } catch (ex) { + debug`Failed RemoveCookieBannerModeForDomain ${ex}`; + } + break; + } + + case "GeckoView:GetCookieBannerModeForDomain": { + try { + let globalMode; + if (aData.isPrivateBrowsing) { + globalMode = lazy.serviceModePBM; + } else { + globalMode = lazy.serviceMode; + } + + if (globalMode === Ci.nsICookieBannerService.MODE_DISABLED) { + aCallback.onSuccess({ mode: globalMode }); + return; + } + + const uri = Services.io.newURI(aData.uri); + const mode = Services.cookieBanners.getDomainPref( + uri, + aData.isPrivateBrowsing + ); + if (mode !== Ci.nsICookieBannerService.MODE_UNSET) { + aCallback.onSuccess({ mode }); + } else { + aCallback.onSuccess({ mode: globalMode }); + } + } catch (ex) { + aCallback.onError(`Unexpected error: ${ex}`); + debug`Failed GetCookieBannerModeForDomain ${ex}`; + } + break; + } + } + }, + + async clearData(aFlags, aCallback) { + const flags = convertFlags(aFlags); + + // storageAccessAPI permissions record every site that the user + // interacted with and thus mirror history quite closely. It makes + // sense to clear them when we clear history. However, since their absence + // indicates that we can purge cookies and site data for tracking origins without + // user interaction, we need to ensure that we only delete those permissions that + // do not have any existing storage. + if (flags & Ci.nsIClearDataService.CLEAR_HISTORY) { + const principalsCollector = new lazy.PrincipalsCollector(); + const principals = await principalsCollector.getAllPrincipals(); + await new Promise(resolve => { + Services.clearData.deleteUserInteractionForClearingHistory( + principals, + 0, + resolve + ); + }); + } + + new Promise(resolve => { + Services.clearData.deleteData(flags, resolve); + }).then(resultFlags => { + aCallback.onSuccess(); + }); + }, + + clearHostData(aHost, aFlags, aCallback) { + new Promise(resolve => { + Services.clearData.deleteDataFromHost( + aHost, + /* isUserRequest */ true, + convertFlags(aFlags), + resolve + ); + }).then(resultFlags => { + aCallback.onSuccess(); + }); + }, + + clearBaseDomainData(aBaseDomain, aFlags, aCallback) { + new Promise(resolve => { + Services.clearData.deleteDataFromBaseDomain( + aBaseDomain, + /* isUserRequest */ true, + convertFlags(aFlags), + resolve + ); + }).then(resultFlags => { + aCallback.onSuccess(); + }); + }, + + clearSessionContextData(aContextId) { + const pattern = { geckoViewSessionContextId: aContextId }; + debug`clearSessionContextData ${pattern}`; + Services.clearData.deleteDataFromOriginAttributesPattern(pattern); + // Call QMS explicitly to work around bug 1537882. + Services.qms.clearStoragesForOriginAttributesPattern( + JSON.stringify(pattern) + ); + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs new file mode 100644 index 0000000000..53f43f153c --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs @@ -0,0 +1,219 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +class Tab { + constructor(window) { + this.id = GeckoViewTabBridge.windowIdToTabId(window.docShell.outerWindowID); + this.browser = window.browser; + this.active = false; + } + + get linkedBrowser() { + return this.browser; + } + + getActive() { + return this.active; + } + + get userContextId() { + return this.browser.ownerGlobal.moduleManager.settings + .unsafeSessionContextId; + } +} + +// Because of bug 1410749, we can't use 0, though, and just to be safe +// we choose a value that is unlikely to overlap with Fennec's tab IDs. +const TAB_ID_BASE = 10000; + +export const GeckoViewTabBridge = { + /** + * Converts windowId to tabId as in GeckoView every browser window has exactly one tab. + * + * @param {number} windowId outerWindowId + * + * @returns {number} tabId + */ + windowIdToTabId(windowId) { + return TAB_ID_BASE + windowId; + }, + + /** + * Converts tabId to windowId. + * + * @param {number} tabId + * + * @returns {number} + * outerWindowId of browser window to which the tab belongs. + */ + tabIdToWindowId(tabId) { + return tabId - TAB_ID_BASE; + }, + + /** + * Delegates openOptionsPage handling to the app. + * + * @param {number} extensionId + * The ID of the extension requesting the options menu. + * + * @returns {Promise<Void>} + * A promise resolved after successful handling. + */ + async openOptionsPage(extensionId) { + debug`openOptionsPage for extensionId ${extensionId}`; + + try { + await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:OpenOptionsPage", + extensionId, + }); + } catch (errorMessage) { + // The error message coming from GeckoView is about :OpenOptionsPage not + // being registered so we need to have one that's extension friendly + // here. + throw new ExtensionError("runtime.openOptionsPage is not supported"); + } + }, + + /** + * Request the GeckoView App to create a new tab (GeckoSession). + * + * @param {object} options + * @param {string} options.extensionId + * The ID of the extension that requested a new tab. + * @param {object} options.createProperties + * The properties for the new tab, see tabs.create reference for details. + * + * @returns {Promise<Tab>} + * A promise resolved to the newly created tab. + * @throws {Error} + * Throws an error if the GeckoView app doesn't support tabs.create or fails to handle the request. + */ + async createNewTab({ extensionId, createProperties } = {}) { + debug`createNewTab`; + + const newSessionId = Services.uuid + .generateUUID() + .toString() + .slice(1, -1) + .replace(/-/g, ""); + + // The window might already be open by the time we get the response, so we + // need to start waiting before we send the message. + const windowPromise = new Promise(resolve => { + const handler = { + observe(aSubject, aTopic, aData) { + if ( + aTopic === "geckoview-window-created" && + aSubject.name === newSessionId + ) { + Services.obs.removeObserver(handler, "geckoview-window-created"); + resolve(aSubject); + } + }, + }; + Services.obs.addObserver(handler, "geckoview-window-created"); + }); + + let didOpenSession = false; + try { + didOpenSession = await lazy.EventDispatcher.instance.sendRequestForResult( + { + type: "GeckoView:WebExtension:NewTab", + extensionId, + createProperties, + newSessionId, + } + ); + } catch (errorMessage) { + // The error message coming from GeckoView is about :NewTab not being + // registered so we need to have one that's extension friendly here. + throw new ExtensionError("tabs.create is not supported"); + } + + if (!didOpenSession) { + throw new ExtensionError("Cannot create new tab"); + } + + const window = await windowPromise; + if (!window.tab) { + window.tab = new Tab(window); + } + return window.tab; + }, + + /** + * Request the GeckoView App to close a tab (GeckoSession). + * + * + * @param {object} options + * @param {Window} options.window The window owning the tab to close + * @param {string} options.extensionId + * + * @returns {Promise<Void>} + * A promise resolved after GeckoSession is closed. + * @throws {Error} + * Throws an error if the GeckoView app doesn't allow extension to close tab. + */ + async closeTab({ window, extensionId } = {}) { + try { + await window.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:WebExtension:CloseTab", + extensionId, + }); + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + }, + + async updateTab({ window, extensionId, updateProperties } = {}) { + try { + await window.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:WebExtension:UpdateTab", + extensionId, + updateProperties, + }); + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + }, +}; + +export class GeckoViewTab extends GeckoViewModule { + onInit() { + const { window } = this; + if (!window.tab) { + window.tab = new Tab(window); + } + + this.registerListener(["GeckoView:WebExtension:SetTabActive"]); + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:SetTabActive": { + const { active } = aData; + lazy.mobileWindowTracker.setTabActive(this.window, active); + break; + } + } + } +} + +const { debug, warn } = GeckoViewTab.initLogging("GeckoViewTab"); diff --git a/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs new file mode 100644 index 0000000000..bb7074ced8 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs @@ -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/. */ + +export var InitializationTracker = { + initialized: false, + onInitialized(profilerTime) { + if (!this.initialized) { + this.initialized = true; + ChromeUtils.addProfilerMarker( + "GeckoView Initialization END", + profilerTime + ); + } + }, +}; + +// A helper for timing_distribution metrics. +export class GleanStopwatch { + constructor(aTimingDistribution) { + this._metric = aTimingDistribution; + } + + isRunning() { + return !!this._timerId; + } + + start() { + if (this.isRunning()) { + this.cancel(); + } + this._timerId = this._metric.start(); + } + + finish() { + this._metric.stopAndAccumulate(this._timerId); + this._timerId = null; + } + + cancel() { + this._metric.cancel(this._timerId); + this._timerId = null; + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs new file mode 100644 index 0000000000..6c5113cdc2 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +export const GeckoViewTabUtil = { + /** + * Creates a new tab through service worker delegate. + * Needs to be ran in a parent process. + * + * @param {string} url + * @returns {Tab} + * @throws {Error} Throws an error if the tab cannot be created. + */ + async createNewTab(url = "about:blank") { + let sessionId = ""; + const windowPromise = new Promise(resolve => { + const openingObserver = (subject, topic, data) => { + if (subject.name === sessionId) { + Services.obs.removeObserver( + openingObserver, + "geckoview-window-created" + ); + resolve(subject); + } + }; + Services.obs.addObserver(openingObserver, "geckoview-window-created"); + }); + + try { + sessionId = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Test:NewTab", + url, + }); + } catch (errorMessage) { + throw new Error( + errorMessage + " GeckoView:Test:NewTab is not supported." + ); + } + + if (!sessionId) { + throw new Error("Could not open a session for the new tab."); + } + + const window = await windowPromise; + + // Immediately load the URI in the browser after creating the new tab to + // load into. This isn't done from the Java side to align with the + // ServiceWorkerOpenWindow infrastructure which this is built on top of. + window.browser.fixupAndLoadURIString(url, { + flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + return window.tab; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs new file mode 100644 index 0000000000..3db694a64a --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs @@ -0,0 +1,572 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", +}); + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewTranslations extends GeckoViewModule { + onInit() { + debug`onInit`; + this.registerListener([ + "GeckoView:Translations:Translate", + "GeckoView:Translations:RestorePage", + "GeckoView:Translations:GetNeverTranslateSite", + "GeckoView:Translations:SetNeverTranslateSite", + ]); + } + + onEnable() { + debug`onEnable`; + this.window.addEventListener("TranslationsParent:OfferTranslation", this); + this.window.addEventListener("TranslationsParent:LanguageState", this); + } + + onDisable() { + debug`onDisable`; + this.window.removeEventListener( + "TranslationsParent:OfferTranslation", + this + ); + this.window.removeEventListener("TranslationsParent:LanguageState", this); + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + switch (aEvent) { + case "GeckoView:Translations:Translate": + try { + const fromLanguage = + GeckoViewTranslationsSettings._checkValidLanguageTagAndMinimize( + aData.fromLanguage + ); + const toLanguage = + GeckoViewTranslationsSettings._checkValidLanguageTagAndMinimize( + aData.toLanguage + ); + try { + this.getActor("Translations").translate(fromLanguage, toLanguage); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError(`Could not translate: ${error}`); + } + } catch (error) { + aCallback.onError( + `The language tag ${aData.fromLanguage} or ${aData.toLanguage} is not valid: ${error}` + ); + } + break; + + case "GeckoView:Translations:RestorePage": + try { + this.getActor("Translations").restorePage(); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError(`Could not restore page: ${error}`); + } + break; + + case "GeckoView:Translations:GetNeverTranslateSite": + try { + var value = this.getActor("Translations").shouldNeverTranslateSite(); + aCallback.onSuccess(value); + } catch (error) { + aCallback.onError(`Could not set site setting: ${error}`); + } + break; + + case "GeckoView:Translations:SetNeverTranslateSite": + try { + this.getActor("Translations").setNeverTranslateSitePermissions( + aData.neverTranslate + ); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError(`Could not set site setting: ${error}`); + } + break; + } + } + + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "TranslationsParent:OfferTranslation": + this.eventDispatcher.sendRequest({ + type: "GeckoView:Translations:Offer", + }); + break; + case "TranslationsParent:LanguageState": + const { + detectedLanguages, + requestedTranslationPair, + error, + isEngineReady, + } = aEvent.detail.actor.languageState; + + const data = { + detectedLanguages, + requestedTranslationPair, + error, + isEngineReady, + }; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:Translations:StateChange", + data, + }); + break; + } + } +} + +// Runtime functionality +export const GeckoViewTranslationsSettings = { + // Helper method for retrieving language setting state and corresponding string name. + _getLanguageSettingName(langTag) { + const isAlways = lazy.TranslationsParent.shouldAlwaysTranslateLanguage({ + docLangTag: langTag, + userLangTag: new Intl.Locale(Services.locale.appLocaleAsBCP47).language, + }); + const isNever = + lazy.TranslationsParent.shouldNeverTranslateLanguage(langTag); + // Default setting is offer. + var setting = "offer"; + + if (isAlways & !isNever) { + setting = "always"; + } + + if (isNever & !isAlways) { + setting = "never"; + } + return setting; + }, + + // Helper method to validate BCP 47 tags and reduced to only the language portion. For example, en-US will be reduced to en. + _checkValidLanguageTagAndMinimize(langTag) { + // Formats the langTag into a locale, may throw an error + var canonicalTag = new Intl.Locale(Intl.getCanonicalLocales(langTag)[0]); + return canonicalTag.minimize().toString(); + }, + /* eslint-disable complexity */ + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:Translations:IsTranslationEngineSupported": { + try { + aCallback.onSuccess( + lazy.TranslationsParent.getIsTranslationsEngineSupported() + ); + } catch (error) { + aCallback.onError( + `An issue occurred while checking the translations engine: ${error}` + ); + } + return; + } + case "GeckoView:Translations:PreferredLanguages": { + aCallback.onSuccess({ + preferredLanguages: lazy.TranslationsParent.getPreferredLanguages(), + }); + return; + } + case "GeckoView:Translations:ManageModel": { + const { language, operation, operationLevel } = aData; + if (operation === "delete") { + if (operationLevel === "all") { + lazy.TranslationsParent.deleteAllLanguageFiles().then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DELETE - An issue occurred while deleting all language files: ${error}` + ); + } + ); + return; + } + if (operationLevel === "language") { + if (language === undefined) { + aCallback.onError( + `LANGUAGE_REQUIRED - A specified language is required language level operations.` + ); + return; + } + lazy.TranslationsParent.deleteLanguageFiles(language).then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DELETE - An issue occurred while deleting a language file: ${error}` + ); + } + ); + return; + } + } + if (operation === "download") { + if (operationLevel === "all") { + lazy.TranslationsParent.downloadAllFiles().then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DOWNLOAD - An issue occurred while downloading all language files: ${error}` + ); + } + ); + return; + } + if (operationLevel === "language") { + if (language === undefined) { + aCallback.onError( + `LANGUAGE_REQUIRED - A specified language is required language level operations.` + ); + return; + } + lazy.TranslationsParent.downloadLanguageFiles(language).then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DOWNLOAD - An issue occurred while downloading a language files: ${error}` + ); + } + ); + } + } + break; + } + case "GeckoView:Translations:TranslationInformation": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + const mockResult = { + languagePairs: [ + { fromLang: "en", toLang: "es" }, + { fromLang: "es", toLang: "en" }, + ], + fromLanguages: [ + { langTag: "en", displayName: "English" }, + { langTag: "es", displayName: "Spanish" }, + ], + toLanguages: [ + { langTag: "en", displayName: "English" }, + { langTag: "es", displayName: "Spanish" }, + ], + }; + aCallback.onSuccess(mockResult); + return; + } + + lazy.TranslationsParent.getSupportedLanguages().then( + function (value) { + aCallback.onSuccess(value); + }, + function (error) { + aCallback.onError( + `Could not retrieve requested information: ${error}` + ); + } + ); + break; + } + case "GeckoView:Translations:ModelInformation": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + const mockResult = { + models: [ + { + langTag: "es", + displayName: "Spanish", + isDownloaded: false, + size: 12345, + }, + { + langTag: "de", + displayName: "German", + isDownloaded: false, + size: 12345, + }, + ], + }; + aCallback.onSuccess(mockResult); + return; + } + + // Helper function to process remote server records size and download state for GV use + async function _processLanguageModelData(language, remoteRecords) { + // Aggregate size of downloads, e.g., one language has many model binary files + var size = 0; + remoteRecords.forEach(item => { + size += parseInt(item.attachment.size); + }); + // Check if required files are downloaded + var isDownloaded = + await lazy.TranslationsParent.hasAllFilesForLanguage( + language.langTag + ); + var model = { + langTag: language.langTag, + displayName: language.displayName, + isDownloaded, + size, + }; + return model; + } + + // Main call to toolkit + lazy.TranslationsParent.getSupportedLanguages().then( + // Retrieve supported languages + async function (supportedLanguages) { + // Get language display information, + const languageList = + lazy.TranslationsParent.getLanguageList(supportedLanguages); + var results = []; + // For each language, process the related remote server model records + languageList.forEach(language => { + const recordsResult = + lazy.TranslationsParent.getRecordsForTranslatingToAndFromAppLanguage( + language.langTag, + false + ).then( + async function (records) { + return _processLanguageModelData(language, records); + }, + function (recordError) { + aCallback.onError( + `An issue occurred while aggregating information: ${recordError}` + ); + }, + language + ); + results.push(recordsResult); + }); + // Aggregate records + Promise.all(results).then(models => { + var response = []; + models.forEach(item => { + response.push(item); + }); + aCallback.onSuccess({ models: response }); + }); + }, + function (languageError) { + aCallback.onError( + `An issue occurred while retrieving the supported languages: ${languageError}` + ); + } + ); + break; + } + + case "GeckoView:Translations:GetLanguageSetting": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + aCallback.onSuccess("always"); + return; + } + + try { + var setting = this._getLanguageSettingName(aData.language); + aCallback.onSuccess(setting); + } catch (error) { + aCallback.onError(`Could not get language setting: ${error}`); + } + break; + } + + case "GeckoView:Translations:GetLanguageSettings": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + const mockResult = { + settings: [ + { langTag: "fr", displayName: "French", setting: "always" }, + { langTag: "de", displayName: "German", setting: "offer" }, + { langTag: "es", displayName: "Spanish", setting: "never" }, + ], + }; + aCallback.onSuccess(mockResult); + return; + } + + lazy.TranslationsParent.getSupportedLanguages().then( + function (supportedLanguages) { + const languageList = + lazy.TranslationsParent.getLanguageList(supportedLanguages); + + languageList.forEach(language => { + language.setting = this._getLanguageSettingName(language.langTag); + }); + + aCallback.onSuccess({ settings: languageList }); + }.bind(this), + function (error) { + aCallback.onError( + `Could not retrieve language setting information: ${error}` + ); + } + ); + break; + } + + case "GeckoView:Translations:SetLanguageSettings": { + var { language, languageSetting } = aData; + languageSetting = languageSetting.toLowerCase(); + + try { + language = this._checkValidLanguageTagAndMinimize(language); + } catch (error) { + aCallback.onError( + `The language tag ${language} is not valid: ${error}` + ); + return; + } + + const ALWAYS = lazy.TranslationsParent.ALWAYS_TRANSLATE_LANGS_PREF; + const NEVER = lazy.TranslationsParent.NEVER_TRANSLATE_LANGS_PREF; + + switch (languageSetting) { + case "always": { + try { + lazy.TranslationsParent.removeLangTagFromPref(language, NEVER); + lazy.TranslationsParent.addLangTagToPref(language, ALWAYS); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set language preference to always: ${error}` + ); + } + break; + } + + case "never": { + try { + lazy.TranslationsParent.removeLangTagFromPref(language, ALWAYS); + lazy.TranslationsParent.addLangTagToPref(language, NEVER); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set language preference to never: ${error}` + ); + } + break; + } + + case "offer": { + try { + // Reverting to default settings, so ensure nothing is set. + lazy.TranslationsParent.removeLangTagFromPref(language, NEVER); + lazy.TranslationsParent.removeLangTagFromPref(language, ALWAYS); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set language preference to offer: ${error}` + ); + } + break; + } + } + break; + } + + case "GeckoView:Translations:GetNeverTranslateSpecifiedSites": + try { + const neverTranslateList = + lazy.TranslationsParent.listNeverTranslateSites(); + aCallback.onSuccess({ sites: neverTranslateList }); + } catch (error) { + aCallback.onError( + `Could not get list of never translate sites: ${error}` + ); + } + break; + + case "GeckoView:Translations:SetNeverTranslateSpecifiedSite": + try { + lazy.TranslationsParent.setNeverTranslateSiteByOrigin( + aData.neverTranslate, + aData.origin + ); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set never translate site setting: ${error}` + ); + } + break; + case "GeckoView:Translations:GetTranslateDownloadSize": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + aCallback.onSuccess({ bytes: 1234567 }); + return; + } + + try { + const fromLanguage = this._checkValidLanguageTagAndMinimize( + aData.fromLanguage + ); + const toLanguage = this._checkValidLanguageTagAndMinimize( + aData.toLanguage + ); + + lazy.TranslationsParent.getExpectedTranslationDownloadSize( + fromLanguage, + toLanguage + ).then( + function (bytes) { + aCallback.onSuccess({ bytes }); + }, + function (error) { + aCallback.onError(`Could not get the download size: ${error}`); + } + ); + } catch (error) { + aCallback.onError( + `The language tag ${aData.fromLanguage} or ${aData.toLanguage} is not valid: ${error}` + ); + } + break; + } + } + }, +}; + +const { debug, warn } = GeckoViewTranslations.initLogging( + "GeckoViewTranslations" +); diff --git a/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs new file mode 100644 index 0000000000..ddfb40c1a1 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs @@ -0,0 +1,510 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AndroidLog: "resource://gre/modules/AndroidLog.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** + * A formatter that does not prepend time/name/level information to messages, + * because those fields are logged separately when using the Android logger. + */ +class AndroidFormatter extends Log.BasicFormatter { + format(message) { + return this.formatText(message); + } +} + +/* + * AndroidAppender + * Logs to Android logcat using AndroidLog.jsm + */ +class AndroidAppender extends Log.Appender { + constructor(aFormatter) { + super(aFormatter || new AndroidFormatter()); + this._name = "AndroidAppender"; + + // Map log level to AndroidLog.foo method. + this._mapping = { + [Log.Level.Fatal]: "e", + [Log.Level.Error]: "e", + [Log.Level.Warn]: "w", + [Log.Level.Info]: "i", + [Log.Level.Config]: "d", + [Log.Level.Debug]: "d", + [Log.Level.Trace]: "v", + }; + } + + append(aMessage) { + if (!aMessage) { + return; + } + + // AndroidLog.jsm always prepends "Gecko" to the tag, so we strip any + // leading "Gecko" here. Also strip dots to save space. + const tag = aMessage.loggerName.replace(/^Gecko|\./g, ""); + const msg = this._formatter.format(aMessage); + lazy.AndroidLog[this._mapping[aMessage.level]](tag, msg); + } +} + +export var GeckoViewUtils = { + /** + * Define a lazy getter that loads an object from external code, and + * optionally handles observer and/or message manager notifications for the + * object, so the object only loads when a notification is received. + * + * @param scope Scope for holding the loaded object. + * @param name Name of the object to load. + * @param service If specified, load the object from a JS component; the + * component must include the line + * "this.wrappedJSObject = this;" in its constructor. + * @param module If specified, load the object from a JS module. + * @param init Optional post-load initialization function. + * @param observers If specified, listen to specified observer notifications. + * @param ppmm If specified, listen to specified process messages. + * @param mm If specified, listen to specified frame messages. + * @param ged If specified, listen to specified global EventDispatcher events. + * @param once if true, only listen to the specified + * events/messages/notifications once. + */ + addLazyGetter( + scope, + name, + { service, module, handler, observers, ppmm, mm, ged, init, once } + ) { + ChromeUtils.defineLazyGetter(scope, name, _ => { + let ret = undefined; + if (module) { + ret = ChromeUtils.importESModule(module)[name]; + } else if (service) { + ret = Cc[service].getService(Ci.nsISupports).wrappedJSObject; + } else if (typeof handler === "function") { + ret = { + handleEvent: handler, + observe: handler, + onEvent: handler, + receiveMessage: handler, + }; + } else if (handler) { + ret = handler; + } + if (ret && init) { + init.call(scope, ret); + } + return ret; + }); + + if (observers) { + const observer = (subject, topic, data) => { + Services.obs.removeObserver(observer, topic); + if (!once) { + Services.obs.addObserver(scope[name], topic); + } + scope[name].observe(subject, topic, data); // Explicitly notify new observer + }; + observers.forEach(topic => Services.obs.addObserver(observer, topic)); + } + + if (!this.IS_PARENT_PROCESS) { + // ppmm, mm, and ged are only available in the parent process. + return; + } + + const addMMListener = (target, names) => { + const listener = msg => { + target.removeMessageListener(msg.name, listener); + if (!once) { + target.addMessageListener(msg.name, scope[name]); + } + scope[name].receiveMessage(msg); + }; + names.forEach(msg => target.addMessageListener(msg, listener)); + }; + if (ppmm) { + addMMListener(Services.ppmm, ppmm); + } + if (mm) { + addMMListener(Services.mm, mm); + } + + if (ged) { + const listener = (event, data, callback) => { + lazy.EventDispatcher.instance.unregisterListener(listener, event); + if (!once) { + lazy.EventDispatcher.instance.registerListener(scope[name], event); + } + scope[name].onEvent(event, data, callback); + }; + lazy.EventDispatcher.instance.registerListener(listener, ged); + } + }, + + _addLazyListeners(events, handler, scope, name, addFn, handleFn) { + if (!handler) { + handler = _ => + Array.isArray(name) ? name.map(n => scope[n]) : scope[name]; + } + const listener = (...args) => { + let handlers = handler(...args); + if (!handlers) { + return; + } + if (!Array.isArray(handlers)) { + handlers = [handlers]; + } + handleFn(handlers, listener, args); + }; + if (Array.isArray(events)) { + addFn(events, listener); + } else { + addFn([events], listener); + } + }, + + /** + * Add lazy event listeners that only load the actual handler when an event + * is being handled. + * + * @param target Event target for the event listeners. + * @param events Event name as a string or array. + * @param handler If specified, function that, for a given event, returns the + * actual event handler as an object or an array of objects. + * If handler is not specified, the actual event handler is + * specified using the scope and name pair. + * @param scope See handler. + * @param name See handler. + * @param options Options for addEventListener. + */ + addLazyEventListener(target, events, { handler, scope, name, options }) { + this._addLazyListeners( + events, + handler, + scope, + name, + (events, listener) => { + events.forEach(event => + target.addEventListener(event, listener, options) + ); + }, + (handlers, listener, args) => { + if (!options || !options.once) { + target.removeEventListener(args[0].type, listener, options); + handlers.forEach(handler => + target.addEventListener(args[0].type, handler, options) + ); + } + handlers.forEach(handler => handler.handleEvent(args[0])); + } + ); + }, + + /** + * Add lazy pref observers, and only load the actual handler once the pref + * value changes from default, and every time the pref value changes + * afterwards. + * + * @param aPrefs Prefs as an object or array. Each pref object has fields + * "name" and "default", indicating the name and default value + * of the pref, respectively. + * @param handler If specified, function that, for a given pref, returns the + * actual event handler as an object or an array of objects. + * If handler is not specified, the actual event handler is + * specified using the scope and name pair. + * @param scope See handler. + * @param name See handler. + * @param once If true, only observe the specified prefs once. + */ + addLazyPrefObserver(aPrefs, { handler, scope, name, once }) { + this._addLazyListeners( + aPrefs, + handler, + scope, + name, + (prefs, observer) => { + prefs.forEach(pref => Services.prefs.addObserver(pref.name, observer)); + prefs.forEach(pref => { + if (pref.default === undefined) { + return; + } + let value; + switch (typeof pref.default) { + case "string": + value = Services.prefs.getCharPref(pref.name, pref.default); + break; + case "number": + value = Services.prefs.getIntPref(pref.name, pref.default); + break; + case "boolean": + value = Services.prefs.getBoolPref(pref.name, pref.default); + break; + } + if (pref.default !== value) { + // Notify observer if value already changed from default. + observer(Services.prefs, "nsPref:changed", pref.name); + } + }); + }, + (handlers, observer, args) => { + if (!once) { + Services.prefs.removeObserver(args[2], observer); + handlers.forEach(handler => + Services.prefs.addObserver(args[2], observer) + ); + } + handlers.forEach(handler => handler.observe(...args)); + } + ); + }, + + getRootDocShell(aWin) { + if (!aWin) { + return null; + } + let docShell; + try { + docShell = aWin.QueryInterface(Ci.nsIDocShell); + } catch (e) { + docShell = aWin.docShell; + } + return docShell.rootTreeItem.QueryInterface(Ci.nsIInterfaceRequestor); + }, + + /** + * Return the outermost chrome DOM window (the XUL window) for a given DOM + * window, in the parent process. + * + * @param aWin a DOM window. + */ + getChromeWindow(aWin) { + const docShell = this.getRootDocShell(aWin); + return docShell && docShell.domWindow; + }, + + /** + * Return the content frame message manager (aka the frame script global + * object) for a given DOM window, in a child process. + * + * @param aWin a DOM window. + */ + getContentFrameMessageManager(aWin) { + const docShell = this.getRootDocShell(aWin); + return docShell && docShell.getInterface(Ci.nsIBrowserChild).messageManager; + }, + + /** + * Return the per-nsWindow EventDispatcher for a given DOM window, in either + * the parent process or a child process. + * + * @param aWin a DOM window. + */ + getDispatcherForWindow(aWin) { + try { + if (!this.IS_PARENT_PROCESS) { + const mm = this.getContentFrameMessageManager(aWin.top || aWin); + return mm && lazy.EventDispatcher.forMessageManager(mm); + } + const win = this.getChromeWindow(aWin.top || aWin); + if (!win.closed) { + return win.WindowEventDispatcher || lazy.EventDispatcher.for(win); + } + } catch (e) {} + return null; + }, + + /** + * Return promise for waiting for finishing PanZoomState. + * + * @param aWindow a DOM window. + * @return promise + */ + waitForPanZoomState(aWindow) { + return new Promise((resolve, reject) => { + if ( + !aWindow?.windowUtils.asyncPanZoomEnabled || + !Services.prefs.getBoolPref("apz.zoom-to-focused-input.enabled") + ) { + // No zoomToFocusedInput. + resolve(); + return; + } + + let timerId = 0; + + const panZoomState = (aSubject, aTopic, aData) => { + if (timerId != 0) { + // aWindow may be dead object now. + try { + lazy.clearTimeout(timerId); + } catch (e) {} + timerId = 0; + } + + if (aData === "NOTHING") { + Services.obs.removeObserver(panZoomState, "PanZoom:StateChange"); + resolve(); + } + }; + + Services.obs.addObserver(panZoomState, "PanZoom:StateChange"); + + // "GeckoView:ZoomToInput" has the timeout as 500ms when window isn't + // resized (it means on-screen-keyboard is already shown). + // So after up to 500ms, APZ event is sent. So we need to wait for more + // 500ms. + timerId = lazy.setTimeout(() => { + // PanZoom state isn't changed. zoomToFocusedInput will return error. + Services.obs.removeObserver(panZoomState, "PanZoom:StateChange"); + reject(); + }, 600); + }); + }, + + /** + * Add logging functions to the specified scope that forward to the given + * Log.sys.mjs logger. Currently "debug" and "warn" functions are supported. To + * log something, call the function through a template literal: + * + * function foo(bar, baz) { + * debug `hello world`; + * debug `foo called with ${bar} as bar`; + * warn `this is a warning for ${baz}`; + * } + * + * An inline format can also be used for logging: + * + * let bar = 42; + * do_something(bar); // No log. + * do_something(debug.foo = bar); // Output "foo = 42" to the log. + * + * @param aTag Name of the Log.jsm logger to forward logs to. + * @param aScope Scope to add the logging functions to. + */ + initLogging(aTag, aScope) { + aScope = aScope || {}; + const tag = "GeckoView." + aTag.replace(/^GeckoView\.?/, ""); + + // Only provide two levels for simplicity. + // For "info", use "debug" instead. + // For "error", throw an actual JS error instead. + for (const level of ["DEBUG", "WARN"]) { + const log = (strings, ...exprs) => + this._log(log.logger, level, strings, exprs); + + ChromeUtils.defineLazyGetter(log, "logger", _ => { + const logger = Log.repository.getLogger(tag); + logger.parent = this.rootLogger; + return logger; + }); + + aScope[level.toLowerCase()] = new Proxy(log, { + set: (obj, prop, value) => obj([prop + " = ", ""], value) || true, + }); + } + return aScope; + }, + + get rootLogger() { + if (!this._rootLogger) { + this._rootLogger = Log.repository.getLogger("GeckoView"); + this._rootLogger.addAppender(new AndroidAppender()); + this._rootLogger.manageLevelFromPref("geckoview.logging"); + } + return this._rootLogger; + }, + + _log(aLogger, aLevel, aStrings, aExprs) { + if (!Array.isArray(aStrings)) { + const [, file, line] = new Error().stack.match(/.*\n.*\n.*@(.*):(\d+):/); + throw Error( + `Expecting template literal: ${aLevel} \`foo \${bar}\``, + file, + +line + ); + } + + if (aLogger.level > Log.Level.Numbers[aLevel]) { + // Log disabled. + return; + } + + // Do some GeckoView-specific formatting: + // * Remove newlines so long log lines can be put into multiple lines: + // debug `foo=${foo} + // bar=${bar}`; + const strs = Array.from(aStrings); + const regex = /\n\s*/g; + for (let i = 0; i < strs.length; i++) { + strs[i] = strs[i].replace(regex, " "); + } + + // * Heuristically format flags as hex. + // * Heuristically format nsresult as string name or hex. + for (let i = 0; i < aExprs.length; i++) { + const expr = aExprs[i]; + switch (typeof expr) { + case "number": + if (expr > 0 && /\ba?[fF]lags?[\s=:]+$/.test(strs[i])) { + // Likely a flag; display in hex. + aExprs[i] = `0x${expr.toString(0x10)}`; + } else if (expr >= 0 && /\b(a?[sS]tatus|rv)[\s=:]+$/.test(strs[i])) { + // Likely an nsresult; display in name or hex. + aExprs[i] = `0x${expr.toString(0x10)}`; + for (const name in Cr) { + if (expr === Cr[name]) { + aExprs[i] = name; + break; + } + } + } + break; + } + } + + aLogger[aLevel.toLowerCase()](strs, ...aExprs); + }, + + /** + * Checks whether the principal is supported for permissions. + * + * @param {nsIPrincipal} principal + * The principal to check. + * + * @return {boolean} if the principal is supported. + */ + isSupportedPermissionsPrincipal(principal) { + if (!principal) { + return false; + } + if (!(principal instanceof Ci.nsIPrincipal)) { + throw new Error( + "Argument passed as principal is not an instance of Ci.nsIPrincipal" + ); + } + return this.isSupportedPermissionsScheme(principal.scheme); + }, + + /** + * Checks whether we support managing permissions for a specific scheme. + * @param {string} scheme - Scheme to test. + * @returns {boolean} Whether the scheme is supported. + */ + isSupportedPermissionsScheme(scheme) { + return ["http", "https", "moz-extension", "file"].includes(scheme); + }, +}; + +ChromeUtils.defineLazyGetter( + GeckoViewUtils, + "IS_PARENT_PROCESS", + _ => Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT +); diff --git a/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs new file mode 100644 index 0000000000..ae821a3656 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs @@ -0,0 +1,1367 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const PRIVATE_BROWSING_PERMISSION = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], +}; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + ExtensionProcessCrashObserver: "resource://gre/modules/Extension.sys.mjs", + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("Console"); + +export var DownloadTracker = new (class extends EventEmitter { + constructor() { + super(); + + // maps numeric IDs to DownloadItem objects + this._downloads = new Map(); + } + + onEvent(event, data, callback) { + switch (event) { + case "GeckoView:WebExtension:DownloadChanged": { + const downloadItem = this.getDownloadItemById(data.downloadItemId); + + if (!downloadItem) { + callback.onError("Error: Trying to update unknown download"); + return; + } + + const delta = downloadItem.update(data); + if (delta) { + this.emit("download-changed", { + delta, + downloadItem, + }); + } + } + } + } + + addDownloadItem(item) { + this._downloads.set(item.id, item); + } + + /** + * Finds and returns a DownloadItem with a certain numeric ID + * + * @param {number} id + * @returns {DownloadItem} download item + */ + getDownloadItemById(id) { + return this._downloads.get(id); + } +})(); + +/** Provides common logic between page and browser actions */ +export class ExtensionActionHelper { + constructor({ + tabTracker, + windowTracker, + tabContext, + properties, + extension, + }) { + this.tabTracker = tabTracker; + this.windowTracker = windowTracker; + this.tabContext = tabContext; + this.properties = properties; + this.extension = extension; + } + + getTab(aTabId) { + if (aTabId !== null) { + return this.tabTracker.getTab(aTabId); + } + return null; + } + + getWindow(aWindowId) { + if (aWindowId !== null) { + return this.windowTracker.getWindow(aWindowId); + } + return null; + } + + extractProperties(aAction) { + const merged = {}; + for (const p of this.properties) { + merged[p] = aAction[p]; + } + return merged; + } + + eventDispatcherFor(aTabId) { + if (!aTabId) { + return lazy.EventDispatcher.instance; + } + + const windowId = lazy.GeckoViewTabBridge.tabIdToWindowId(aTabId); + const window = this.windowTracker.getWindow(windowId); + return window.WindowEventDispatcher; + } + + sendRequest(aTabId, aData) { + return this.eventDispatcherFor(aTabId).sendRequest({ + ...aData, + aTabId, + extensionId: this.extension.id, + }); + } +} + +class EmbedderPort { + constructor(portId, messenger) { + this.id = portId; + this.messenger = messenger; + this.dispatcher = lazy.EventDispatcher.byName(`port:${portId}`); + this.dispatcher.registerListener(this, [ + "GeckoView:WebExtension:PortMessageFromApp", + "GeckoView:WebExtension:PortDisconnect", + ]); + } + close() { + this.dispatcher.unregisterListener(this, [ + "GeckoView:WebExtension:PortMessageFromApp", + "GeckoView:WebExtension:PortDisconnect", + ]); + } + onPortDisconnect() { + this.dispatcher.sendRequest({ + type: "GeckoView:WebExtension:Disconnect", + sender: this.sender, + }); + this.close(); + } + onPortMessage(holder) { + this.dispatcher.sendRequest({ + type: "GeckoView:WebExtension:PortMessage", + data: holder.deserialize({}), + }); + } + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:PortMessageFromApp": { + const holder = new StructuredCloneHolder( + "GeckoView:WebExtension:PortMessageFromApp", + null, + aData.message + ); + this.messenger.sendPortMessage(this.id, holder); + break; + } + + case "GeckoView:WebExtension:PortDisconnect": { + this.messenger.sendPortDisconnect(this.id); + this.close(); + break; + } + } + } +} + +export class GeckoViewConnection { + constructor(sender, target, nativeApp, allowContentMessaging) { + this.sender = sender; + this.target = target; + this.nativeApp = nativeApp; + this.allowContentMessaging = allowContentMessaging; + + if (!allowContentMessaging && sender.envType !== "addon_child") { + throw new Error(`Unexpected messaging sender: ${JSON.stringify(sender)}`); + } + } + + get dispatcher() { + if (this.sender.envType === "addon_child") { + // If this is a WebExtension Page we will have a GeckoSession associated + // to it and thus a dispatcher. + const dispatcher = GeckoViewUtils.getDispatcherForWindow( + this.target.ownerGlobal + ); + if (dispatcher) { + return dispatcher; + } + + // No dispatcher means this message is coming from a background script, + // use the global event handler + return lazy.EventDispatcher.instance; + } else if ( + this.sender.envType === "content_child" && + this.allowContentMessaging + ) { + // If this message came from a content script, send the message to + // the corresponding tab messenger so that GeckoSession can pick it + // up. + return GeckoViewUtils.getDispatcherForWindow(this.target.ownerGlobal); + } + + throw new Error(`Uknown sender envType: ${this.sender.envType}`); + } + + _sendMessage({ type, portId, data }) { + const message = { + type, + sender: this.sender, + data, + portId, + extensionId: this.sender.id, + nativeApp: this.nativeApp, + }; + + return this.dispatcher.sendRequestForResult(message); + } + + sendMessage(data) { + return this._sendMessage({ + type: "GeckoView:WebExtension:Message", + data: data.deserialize({}), + }); + } + + onConnect(portId, messenger) { + const port = new EmbedderPort(portId, messenger); + + this._sendMessage({ + type: "GeckoView:WebExtension:Connect", + data: {}, + portId: port.id, + }); + + return port; + } +} + +async function filterPromptPermissions(aPermissions) { + if (!aPermissions) { + return []; + } + const promptPermissions = []; + for (const permission of aPermissions) { + if (!(await lazy.Extension.shouldPromptFor(permission))) { + continue; + } + promptPermissions.push(permission); + } + return promptPermissions; +} + +// Keep in sync with WebExtension.java +const FLAG_NONE = 0; +const FLAG_ALLOW_CONTENT_MESSAGING = 1 << 0; + +function exportFlags(aPolicy) { + let flags = FLAG_NONE; + if (!aPolicy) { + return flags; + } + const { extension } = aPolicy; + if (extension.hasPermission("nativeMessagingFromContent")) { + flags |= FLAG_ALLOW_CONTENT_MESSAGING; + } + return flags; +} + +async function exportExtension(aAddon, aPermissions, aSourceURI) { + // First, let's make sure the policy is ready if present + let policy = WebExtensionPolicy.getByID(aAddon.id); + if (policy?.readyPromise) { + policy = await policy.readyPromise; + } + const { + amoListingURL, + averageRating, + blocklistState, + creator, + description, + embedderDisabled, + fullDescription, + homepageURL, + icons, + id, + incognito, + isActive, + isBuiltin, + isCorrectlySigned, + isRecommended, + name, + optionsType, + optionsURL, + reviewCount, + reviewURL, + signedState, + sourceURI, + temporarilyInstalled, + userDisabled, + version, + } = aAddon; + let creatorName = null; + let creatorURL = null; + if (creator) { + const { name, url } = creator; + creatorName = name; + creatorURL = url; + } + const openOptionsPageInTab = + optionsType === lazy.AddonManager.OPTIONS_TYPE_TAB; + const disabledFlags = []; + if (userDisabled) { + disabledFlags.push("userDisabled"); + } + if (blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED) { + disabledFlags.push("blocklistDisabled"); + } + if (embedderDisabled) { + disabledFlags.push("appDisabled"); + } + // Add-ons without an `isCorrectlySigned` property are correctly signed as + // they aren't the correct type for signing. + if (lazy.AddonSettings.REQUIRE_SIGNING && isCorrectlySigned === false) { + disabledFlags.push("signatureDisabled"); + } + if (lazy.AddonManager.checkCompatibility && !aAddon.isCompatible) { + disabledFlags.push("appVersionDisabled"); + } + const baseURL = policy ? policy.getURL() : ""; + const privateBrowsingAllowed = policy ? policy.privateBrowsingAllowed : false; + const promptPermissions = aPermissions + ? await filterPromptPermissions(aPermissions.permissions) + : []; + + let updateDate; + try { + updateDate = aAddon.updateDate?.toISOString(); + } catch { + // `installDate` is used as a fallback for `updateDate` but only when the + // add-on is installed. Before that, `installDate` might be undefined, + // which would cause `updateDate` (and `installDate`) to be an "invalid + // date". + updateDate = null; + } + + return { + webExtensionId: id, + locationURI: aSourceURI != null ? aSourceURI.spec : "", + isBuiltIn: isBuiltin, + webExtensionFlags: exportFlags(policy), + metaData: { + amoListingURL, + averageRating, + baseURL, + blocklistState, + creatorName, + creatorURL, + description, + disabledFlags, + downloadUrl: sourceURI?.displaySpec, + enabled: isActive, + fullDescription, + homepageURL, + icons, + incognito, + isRecommended, + name, + openOptionsPageInTab, + optionsPageURL: optionsURL, + origins: aPermissions ? aPermissions.origins : [], + privateBrowsingAllowed, + promptPermissions, + reviewCount, + reviewURL, + signedState, + temporary: temporarilyInstalled, + updateDate, + version, + }, + }; +} + +class ExtensionInstallListener { + constructor(aResolve, aInstall, aInstallId) { + this.install = aInstall; + this.installId = aInstallId; + this.resolve = result => { + aResolve(result); + lazy.EventDispatcher.instance.unregisterListener(this, [ + "GeckoView:WebExtension:CancelInstall", + ]); + }; + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:WebExtension:CancelInstall", + ]); + } + + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:CancelInstall": { + const { installId } = aData; + if (this.installId !== installId) { + return; + } + this.cancelling = true; + let cancelled = false; + try { + this.install.cancel(); + cancelled = true; + } catch (ex) { + // install may have already failed or been cancelled + debug`Unable to cancel the install installId ${installId}, Error: ${ex}`; + // When we attempt to cancel an install but the cancellation fails for + // some reasons (e.g., because it is too late), we need to revert this + // boolean property to allow another cancellation to be possible. + // Otherwise, events like `onDownloadCancelled` won't resolve and that + // will cause problems in the embedder. + this.cancelling = false; + } + aCallback.onSuccess({ cancelled }); + break; + } + } + } + + onDownloadCancelled(aInstall) { + debug`onDownloadCancelled state=${aInstall.state}`; + // Do not resolve we were told to CancelInstall, + // to prevent racing with that handler. + if (!this.cancelling) { + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + } + + onDownloadFailed(aInstall) { + debug`onDownloadFailed state=${aInstall.state}`; + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + onDownloadEnded() { + // Nothing to do + } + + onInstallCancelled(aInstall, aCancelledByUser) { + debug`onInstallCancelled state=${aInstall.state} cancelledByUser=${aCancelledByUser}`; + // Do not resolve we were told to CancelInstall, + // to prevent racing with that handler. + if (!this.cancelling) { + const { error: installError, state } = aInstall; + // An install can be cancelled by the user OR something else, e.g. when + // the blocklist prevents the install of a blocked add-on. + this.resolve({ installError, state, cancelledByUser: aCancelledByUser }); + } + } + + onInstallFailed(aInstall) { + debug`onInstallFailed state=${aInstall.state}`; + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + onInstallPostponed(aInstall) { + debug`onInstallPostponed state=${aInstall.state}`; + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + async onInstallEnded(aInstall, aAddon) { + debug`onInstallEnded addonId=${aAddon.id}`; + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + aInstall.sourceURI + ); + this.resolve({ extension }); + } +} + +class ExtensionPromptObserver { + constructor() { + Services.obs.addObserver(this, "webextension-permission-prompt"); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + } + + async permissionPrompt(aInstall, aAddon, aInfo) { + const { sourceURI } = aInstall; + const { permissions } = aInfo; + const extension = await exportExtension(aAddon, permissions, sourceURI); + const response = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:InstallPrompt", + extension, + }); + + if (response.allow) { + aInfo.resolve(); + } else { + aInfo.reject(); + } + } + + async optionalPermissionPrompt(aExtensionId, aPermissions, resolve) { + const response = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:OptionalPrompt", + extensionId: aExtensionId, + permissions: aPermissions, + }); + resolve(response.allow); + } + + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + + switch (aTopic) { + case "webextension-permission-prompt": { + const { info } = aSubject.wrappedJSObject; + const { addon, install } = info; + this.permissionPrompt(install, addon, info); + break; + } + case "webextension-optional-permission-prompt": { + const { id, permissions, resolve } = aSubject.wrappedJSObject; + this.optionalPermissionPrompt(id, permissions, resolve); + break; + } + } + } +} + +class AddonInstallObserver { + constructor() { + Services.obs.addObserver(this, "addon-install-failed"); + } + + async onInstallationFailed(aAddon, aAddonName, aError) { + // aAddon could be null if we have a network error where we can't download the xpi file. + // aAddon could also be a valid object without an ID when the xpi file is corrupt. + let extension = null; + if (aAddon?.id) { + extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + } + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnInstallationFailed", + extension, + addonName: aAddonName, + error: aError, + }); + } + + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + switch (aTopic) { + case "addon-install-failed": { + aSubject.wrappedJSObject.installs.forEach(install => { + const { addon, error, name } = install; + // For some errors, we have a valid `addon` but not the `name` set on + // the `install` object yet so we check both here. + const addonName = name || addon?.name; + + this.onInstallationFailed(addon, addonName, error); + }); + break; + } + } + } +} + +new ExtensionPromptObserver(); +new AddonInstallObserver(); + +class AddonManagerListener { + constructor() { + lazy.AddonManager.addAddonListener(this); + // Some extension properties are not going to be available right away after the extension + // have been installed (e.g. in particular metaData.optionsPageURL), the GeckoView event + // dispatched from onExtensionReady listener will be providing updated extension metadata to + // the GeckoView side when it is actually going to be available. + this.onExtensionReady = this.onExtensionReady.bind(this); + lazy.Management.on("ready", this.onExtensionReady); + } + + async onExtensionReady(name, extInstance) { + // In xpcshell tests there wil be test extensions that trigger this event while the + // AddonManager has not been started at all, on the contrary on a regular browser + // instance the AddonManager is expected to be already fully started for an extension + // for the extension to be able to reach the "ready" state, and so we just silently + // early exit here if the AddonManager is not ready. + if (!lazy.AddonManager.isReady) { + return; + } + + debug`onExtensionReady ${extInstance.id}`; + + const addonWrapper = await lazy.AddonManager.getAddonByID(extInstance.id); + if (!addonWrapper) { + return; + } + + const extension = await exportExtension( + addonWrapper, + addonWrapper.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnReady", + extension, + }); + } + + async onDisabling(aAddon) { + debug`onDisabling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnDisabling", + extension, + }); + } + + async onDisabled(aAddon) { + debug`onDisabled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnDisabled", + extension, + }); + } + + async onEnabling(aAddon) { + debug`onEnabling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnEnabling", + extension, + }); + } + + async onEnabled(aAddon) { + debug`onEnabled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnEnabled", + extension, + }); + } + + async onUninstalling(aAddon) { + debug`onUninstalling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnUninstalling", + extension, + }); + } + + async onUninstalled(aAddon) { + debug`onUninstalled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnUninstalled", + extension, + }); + } + + async onInstalling(aAddon) { + debug`onInstalling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnInstalling", + extension, + }); + } + + async onInstalled(aAddon) { + debug`onInstalled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnInstalled", + extension, + }); + } +} + +new AddonManagerListener(); + +class ExtensionProcessListener { + constructor() { + this.onExtensionProcessCrash = this.onExtensionProcessCrash.bind(this); + lazy.Management.on("extension-process-crash", this.onExtensionProcessCrash); + + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:WebExtension:EnableProcessSpawning", + "GeckoView:WebExtension:DisableProcessSpawning", + ]); + } + + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:EnableProcessSpawning": { + debug`Extension process crash -> re-enable process spawning`; + lazy.ExtensionProcessCrashObserver.enableProcessSpawning(); + break; + } + } + } + + async onExtensionProcessCrash(name, { childID, processSpawningDisabled }) { + debug`Extension process crash -> childID=${childID} processSpawningDisabled=${processSpawningDisabled}`; + + // When an extension process has crashed too many times, Gecko will set + // `processSpawningDisabled` and no longer allow the extension process + // spawning. We only want to send a request to the embedder when we are + // disabling the process spawning. If process spawning is still enabled + // then we short circuit and don't notify the embedder. + if (!processSpawningDisabled) { + return; + } + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnDisabledProcessSpawning", + }); + } +} + +new ExtensionProcessListener(); + +class MobileWindowTracker extends EventEmitter { + constructor() { + super(); + this._topWindow = null; + this._topNonPBWindow = null; + } + + get topWindow() { + if (this._topWindow) { + return this._topWindow.get(); + } + return null; + } + + get topNonPBWindow() { + if (this._topNonPBWindow) { + return this._topNonPBWindow.get(); + } + return null; + } + + setTabActive(aWindow, aActive) { + const { browser, tab: nativeTab, docShell } = aWindow; + nativeTab.active = aActive; + + if (aActive) { + this._topWindow = Cu.getWeakReference(aWindow); + const isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser); + if (!isPrivate) { + this._topNonPBWindow = this._topWindow; + } + this.emit("tab-activated", { + windowId: docShell.outerWindowID, + tabId: nativeTab.id, + isPrivate, + nativeTab, + }); + } + } +} + +export var mobileWindowTracker = new MobileWindowTracker(); + +async function updatePromptHandler(aInfo) { + const oldPerms = aInfo.existingAddon.userPermissions; + if (!oldPerms) { + // Updating from a legacy add-on, let it proceed + return; + } + + const newPerms = aInfo.addon.userPermissions; + + const difference = lazy.Extension.comparePermissions(oldPerms, newPerms); + + // We only care about permissions that we can prompt the user for + const newPermissions = await filterPromptPermissions(difference.permissions); + const { origins: newOrigins } = difference; + + // If there are no new permissions, just proceed + if (!newOrigins.length && !newPermissions.length) { + return; + } + + const currentlyInstalled = await exportExtension( + aInfo.existingAddon, + oldPerms + ); + const updatedExtension = await exportExtension(aInfo.addon, newPerms); + const response = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:UpdatePrompt", + currentlyInstalled, + updatedExtension, + newPermissions, + newOrigins, + }); + + if (!response.allow) { + throw new Error("Extension update rejected."); + } +} + +export var GeckoViewWebExtension = { + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + + switch (aTopic) { + case "testing-installed-addon": + case "testing-uninstalled-addon": { + // We pretend devtools installed/uninstalled this addon so we don't + // have to add an API just for internal testing. + // TODO: assert this is under a test + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:DebuggerListUpdated", + }); + break; + } + + case "devtools-installed-addon": { + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:DebuggerListUpdated", + }); + break; + } + } + }, + + async extensionById(aId) { + const addon = await lazy.AddonManager.getAddonByID(aId); + if (!addon) { + debug`Could not find extension with id=${aId}`; + return null; + } + return addon; + }, + + async ensureBuiltIn(aUri, aId) { + await lazy.AddonManager.readyPromise; + // Although the add-on is privileged in practice due to it being installed + // as a built-in extension, we pass isPrivileged=false since the exact flag + // doesn't matter as we are only using ExtensionData to read the version. + const extensionData = new lazy.ExtensionData(aUri, false); + const [extensionVersion, extension] = await Promise.all([ + extensionData.getExtensionVersionWithoutValidation(), + this.extensionById(aId), + ]); + + if (!extension || extensionVersion != extension.version) { + return this.installBuiltIn(aUri); + } + + const exported = await exportExtension( + extension, + extension.userPermissions, + aUri + ); + return { extension: exported }; + }, + + async installBuiltIn(aUri) { + await lazy.AddonManager.readyPromise; + const addon = await lazy.AddonManager.installBuiltinAddon(aUri.spec); + const exported = await exportExtension(addon, addon.userPermissions, aUri); + return { extension: exported }; + }, + + async installWebExtension(aInstallId, aUri, installMethod) { + const install = await lazy.AddonManager.getInstallForURL(aUri.spec, { + telemetryInfo: { + source: "geckoview-app", + method: installMethod || undefined, + }, + }); + const promise = new Promise(resolve => { + install.addListener( + new ExtensionInstallListener(resolve, install, aInstallId) + ); + }); + + lazy.AddonManager.installAddonFromAOM(null, aUri, install); + + return promise; + }, + + async setPrivateBrowsingAllowed(aId, aAllowed) { + if (aAllowed) { + await lazy.ExtensionPermissions.add(aId, PRIVATE_BROWSING_PERMISSION); + } else { + await lazy.ExtensionPermissions.remove(aId, PRIVATE_BROWSING_PERMISSION); + } + + // Reload the extension if it is already enabled. This ensures any change + // on the private browsing permission is properly handled. + const addon = await this.extensionById(aId); + if (addon.isActive) { + await addon.reload(); + } + + return exportExtension(addon, addon.userPermissions, /* aSourceURI */ null); + }, + + async uninstallWebExtension(aId) { + const extension = await this.extensionById(aId); + if (!extension) { + throw new Error(`Could not find an extension with id='${aId}'.`); + } + + return extension.uninstall(); + }, + + async browserActionClick(aId) { + const policy = WebExtensionPolicy.getByID(aId); + if (!policy) { + return undefined; + } + + const browserAction = this.browserActions.get(policy.extension); + if (!browserAction) { + return undefined; + } + + return browserAction.triggerClickOrPopup(); + }, + + async pageActionClick(aId) { + const policy = WebExtensionPolicy.getByID(aId); + if (!policy) { + return undefined; + } + + const pageAction = this.pageActions.get(policy.extension); + if (!pageAction) { + return undefined; + } + + return pageAction.triggerClickOrPopup(); + }, + + async actionDelegateAttached(aId) { + const policy = WebExtensionPolicy.getByID(aId); + if (!policy) { + debug`Could not find extension with id=${aId}`; + return; + } + + const { extension } = policy; + + const browserAction = this.browserActions.get(extension); + if (browserAction) { + // Send information about this action to the delegate + browserAction.updateOnChange(null); + } + + const pageAction = this.pageActions.get(extension); + if (pageAction) { + pageAction.updateOnChange(null); + } + }, + + async enableWebExtension(aId, aSource) { + const extension = await this.extensionById(aId); + if (aSource === "user") { + await extension.enable(); + } else if (aSource === "app") { + await extension.setEmbedderDisabled(false); + } + return exportExtension( + extension, + extension.userPermissions, + /* aSourceURI */ null + ); + }, + + async disableWebExtension(aId, aSource) { + const extension = await this.extensionById(aId); + if (aSource === "user") { + await extension.disable(); + } else if (aSource === "app") { + await extension.setEmbedderDisabled(true); + } + return exportExtension( + extension, + extension.userPermissions, + /* aSourceURI */ null + ); + }, + + /** + * @return A promise resolved with either an AddonInstall object if an update + * is available or null if no update is found. + */ + checkForUpdate(aAddon) { + return new Promise(resolve => { + const listener = { + onUpdateAvailable(aAddon, install) { + install.promptHandler = updatePromptHandler; + resolve(install); + }, + onNoUpdateAvailable() { + resolve(null); + }, + }; + aAddon.findUpdates( + listener, + lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED + ); + }); + }, + + async updateWebExtension(aId) { + // Refresh the cached metadata when necessary. This allows us to always + // export relatively recent metadata to the embedder. + if (lazy.AddonRepository.isMetadataStale()) { + // We use a promise to avoid more than one call to `backgroundUpdateCheck()` + // when `updateWebExtension()` is called for multiple add-ons in parallel. + if (!this._promiseAddonRepositoryUpdate) { + this._promiseAddonRepositoryUpdate = + lazy.AddonRepository.backgroundUpdateCheck().finally(() => { + this._promiseAddonRepositoryUpdate = null; + }); + } + await this._promiseAddonRepositoryUpdate; + } + + // Early-return when extension updates are disabled. + if (!lazy.AddonManager.updateEnabled) { + return null; + } + + const extension = await this.extensionById(aId); + + const install = await this.checkForUpdate(extension); + if (!install) { + return null; + } + const promise = new Promise(resolve => { + install.addListener(new ExtensionInstallListener(resolve)); + }); + install.install(); + return promise; + }, + + validateBuiltInLocation(aLocationUri, aCallback) { + let uri; + try { + uri = Services.io.newURI(aLocationUri); + } catch (ex) { + aCallback.onError(`Could not parse uri: ${aLocationUri}. Error: ${ex}`); + return null; + } + + if (uri.scheme !== "resource" || uri.host !== "android") { + aCallback.onError(`Only resource://android/... URIs are allowed.`); + return null; + } + + if (uri.fileName !== "") { + aCallback.onError( + `This URI does not point to a folder. Note: folders URIs must end with a "/".` + ); + return null; + } + + return uri; + }, + + /* eslint-disable complexity */ + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:BrowserAction:Click": { + const popupUrl = await this.browserActionClick(aData.extensionId); + aCallback.onSuccess(popupUrl); + break; + } + case "GeckoView:PageAction:Click": { + const popupUrl = await this.pageActionClick(aData.extensionId); + aCallback.onSuccess(popupUrl); + break; + } + case "GeckoView:WebExtension:MenuClick": { + aCallback.onError(`Not implemented`); + break; + } + case "GeckoView:WebExtension:MenuShow": { + aCallback.onError(`Not implemented`); + break; + } + case "GeckoView:WebExtension:MenuHide": { + aCallback.onError(`Not implemented`); + break; + } + + case "GeckoView:ActionDelegate:Attached": { + this.actionDelegateAttached(aData.extensionId); + break; + } + + case "GeckoView:WebExtension:Get": { + const extension = await this.extensionById(aData.extensionId); + if (!extension) { + aCallback.onError( + `Could not find extension with id: ${aData.extensionId}` + ); + return; + } + + aCallback.onSuccess({ + extension: await exportExtension( + extension, + extension.userPermissions, + /* aSourceURI */ null + ), + }); + break; + } + + case "GeckoView:WebExtension:SetPBAllowed": { + const { extensionId, allowed } = aData; + try { + const extension = await this.setPrivateBrowsingAllowed( + extensionId, + allowed + ); + aCallback.onSuccess({ extension }); + } catch (ex) { + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:Install": { + const { locationUri, installId, installMethod } = aData; + let uri; + try { + uri = Services.io.newURI(locationUri); + } catch (ex) { + aCallback.onError(`Could not parse uri: ${locationUri}`); + return; + } + + try { + const result = await this.installWebExtension( + installId, + uri, + installMethod + ); + if (result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Install exception error ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + + break; + } + + case "GeckoView:WebExtension:EnsureBuiltIn": { + const { locationUri, webExtensionId } = aData; + const uri = this.validateBuiltInLocation(locationUri, aCallback); + if (!uri) { + return; + } + + try { + const result = await this.ensureBuiltIn(uri, webExtensionId); + if (result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Install exception error ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + + break; + } + + case "GeckoView:WebExtension:InstallBuiltIn": { + const uri = this.validateBuiltInLocation(aData.locationUri, aCallback); + if (!uri) { + return; + } + + try { + const result = await this.installBuiltIn(uri); + if (result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Install exception error ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + + break; + } + + case "GeckoView:WebExtension:Uninstall": { + try { + await this.uninstallWebExtension(aData.webExtensionId); + aCallback.onSuccess(); + } catch (ex) { + debug`Failed uninstall ${ex}`; + aCallback.onError( + `This extension cannot be uninstalled. Error: ${ex}.` + ); + } + break; + } + + case "GeckoView:WebExtension:Enable": { + try { + const { source, webExtensionId } = aData; + if (source !== "user" && source !== "app") { + throw new Error("Illegal source parameter"); + } + const extension = await this.enableWebExtension( + webExtensionId, + source + ); + aCallback.onSuccess({ extension }); + } catch (ex) { + debug`Failed enable ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:Disable": { + try { + const { source, webExtensionId } = aData; + if (source !== "user" && source !== "app") { + throw new Error("Illegal source parameter"); + } + const extension = await this.disableWebExtension( + webExtensionId, + source + ); + aCallback.onSuccess({ extension }); + } catch (ex) { + debug`Failed disable ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:List": { + try { + await lazy.AddonManager.readyPromise; + const addons = await lazy.AddonManager.getAddonsByTypes([ + "extension", + ]); + const extensions = await Promise.all( + addons.map(addon => + exportExtension(addon, addon.userPermissions, null) + ) + ); + + aCallback.onSuccess({ extensions }); + } catch (ex) { + debug`Failed list ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:Update": { + try { + const { webExtensionId } = aData; + const result = await this.updateWebExtension(webExtensionId); + if (result === null || result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Failed update ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + } + }, +}; + +// WeakMap[Extension -> BrowserAction] +GeckoViewWebExtension.browserActions = new WeakMap(); +// WeakMap[Extension -> PageAction] +GeckoViewWebExtension.pageActions = new WeakMap(); diff --git a/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs b/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs new file mode 100644 index 0000000000..5769fcafe8 --- /dev/null +++ b/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs @@ -0,0 +1,99 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("LoadURIDelegate"); + +export const LoadURIDelegate = { + // Delegate URI loading to the app. + // Return whether the loading has been handled. + load(aWindow, aEventDispatcher, aUri, aWhere, aFlags, aTriggeringPrincipal) { + if (!aWindow) { + return false; + } + + const triggerUri = + aTriggeringPrincipal && + (aTriggeringPrincipal.isNullPrincipal ? null : aTriggeringPrincipal.URI); + + const message = { + type: "GeckoView:OnLoadRequest", + uri: aUri ? aUri.displaySpec : "", + where: aWhere, + flags: aFlags, + triggerUri: triggerUri && triggerUri.displaySpec, + hasUserGesture: aWindow.document.hasValidTransientUserGestureActivation, + }; + + let handled = undefined; + aEventDispatcher.sendRequestForResult(message).then( + response => { + handled = response; + }, + () => { + // There was an error or listener was not registered in GeckoSession, + // treat as unhandled. + handled = false; + } + ); + Services.tm.spinEventLoopUntil( + "LoadURIDelegate.jsm:load", + () => aWindow.closed || handled !== undefined + ); + + return handled || false; + }, + + handleLoadError(aWindow, aEventDispatcher, aUri, aError, aErrorModule) { + let errorClass = 0; + try { + const nssErrorsService = Cc[ + "@mozilla.org/nss_errors_service;1" + ].getService(Ci.nsINSSErrorsService); + errorClass = nssErrorsService.getErrorClass(aError); + } catch (e) {} + + const msg = { + type: "GeckoView:OnLoadError", + uri: aUri && aUri.spec, + error: aError, + errorModule: aErrorModule, + errorClass, + }; + + let errorPageURI = undefined; + aEventDispatcher.sendRequestForResult(msg).then( + response => { + try { + errorPageURI = response ? Services.io.newURI(response) : null; + } catch (e) { + warn`Failed to parse URI '${response}`; + errorPageURI = null; + Components.returnCode = Cr.NS_ERROR_ABORT; + } + }, + e => { + errorPageURI = null; + Components.returnCode = Cr.NS_ERROR_ABORT; + } + ); + Services.tm.spinEventLoopUntil( + "LoadURIDelegate.jsm:handleLoadError", + () => aWindow.closed || errorPageURI !== undefined + ); + + return errorPageURI; + }, + + isSafeBrowsingError(aError) { + return ( + aError === Cr.NS_ERROR_PHISHING_URI || + aError === Cr.NS_ERROR_MALWARE_URI || + aError === Cr.NS_ERROR_HARMFUL_URI || + aError === Cr.NS_ERROR_UNWANTED_URI + ); + }, +}; diff --git a/mobile/android/modules/geckoview/MediaUtils.sys.mjs b/mobile/android/modules/geckoview/MediaUtils.sys.mjs new file mode 100644 index 0000000000..81dc35a567 --- /dev/null +++ b/mobile/android/modules/geckoview/MediaUtils.sys.mjs @@ -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/. */ + +export const MediaUtils = { + getMetadata(aElement) { + if (!aElement) { + return null; + } + return { + src: aElement.currentSrc ?? aElement.src, + width: aElement.videoWidth ?? 0, + height: aElement.videoHeight ?? 0, + duration: aElement.duration, + seekable: !!aElement.seekable, + audioTrackCount: + aElement.audioTracks?.length ?? + aElement.mozHasAudio ?? + aElement.webkitAudioDecodedByteCount ?? + MediaUtils.isAudioElement(aElement) + ? 1 + : 0, + videoTrackCount: + aElement.videoTracks?.length ?? MediaUtils.isVideoElement(aElement) + ? 1 + : 0, + }; + }, + + isVideoElement(aElement) { + return ( + aElement && ChromeUtils.getClassName(aElement) === "HTMLVideoElement" + ); + }, + + isAudioElement(aElement) { + return ( + aElement && ChromeUtils.getClassName(aElement) === "HTMLAudioElement" + ); + }, + + isMediaElement(aElement) { + return ( + MediaUtils.isVideoElement(aElement) || MediaUtils.isAudioElement(aElement) + ); + }, + + findMediaElement(aElement) { + return ( + MediaUtils.findVideoElement(aElement) ?? + MediaUtils.findAudioElement(aElement) + ); + }, + + findVideoElement(aElement) { + if (!aElement) { + return null; + } + if (MediaUtils.isVideoElement(aElement)) { + return aElement; + } + const childrenMedia = aElement.getElementsByTagName("video"); + if (childrenMedia && childrenMedia.length) { + return childrenMedia[0]; + } + return null; + }, + + findAudioElement(aElement) { + if (!aElement || MediaUtils.isAudioElement(aElement)) { + return aElement; + } + const childrenMedia = aElement.getElementsByTagName("audio"); + if (childrenMedia && childrenMedia.length) { + return childrenMedia[0]; + } + return null; + }, +}; diff --git a/mobile/android/modules/geckoview/Messaging.sys.mjs b/mobile/android/modules/geckoview/Messaging.sys.mjs new file mode 100644 index 0000000000..e67161fede --- /dev/null +++ b/mobile/android/modules/geckoview/Messaging.sys.mjs @@ -0,0 +1,319 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 IS_PARENT_PROCESS = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT; + +class ChildActorDispatcher { + constructor(actor) { + this._actor = actor; + } + + // TODO: Bug 1658980 + registerListener(aListener, aEvents) { + throw new Error("Cannot registerListener in child actor"); + } + unregisterListener(aListener, aEvents) { + throw new Error("Cannot registerListener in child actor"); + } + + /** + * Sends a request to Java. + * + * @param aMsg Message to send; must be an object with a "type" property + */ + sendRequest(aMsg) { + this._actor.sendAsyncMessage("DispatcherMessage", aMsg); + } + + /** + * Sends a request to Java, returning a Promise that resolves to the response. + * + * @param aMsg Message to send; must be an object with a "type" property + * @return A Promise resolving to the response + */ + sendRequestForResult(aMsg) { + return this._actor.sendQuery("DispatcherQuery", aMsg); + } +} + +function DispatcherDelegate(aDispatcher, aMessageManager) { + this._dispatcher = aDispatcher; + this._messageManager = aMessageManager; + + if (!aDispatcher) { + // Child process. + // TODO: this doesn't work with Fission, remove this code path once every + // consumer has been migrated. Bug 1569360. + this._replies = new Map(); + (aMessageManager || Services.cpmm).addMessageListener( + "GeckoView:MessagingReply", + this + ); + } +} + +DispatcherDelegate.prototype = { + /** + * Register a listener to be notified of event(s). + * + * @param aListener Target listener implementing nsIAndroidEventListener. + * @param aEvents String or array of strings of events to listen to. + */ + registerListener(aListener, aEvents) { + if (!this._dispatcher) { + throw new Error("Can only listen in parent process"); + } + this._dispatcher.registerListener(aListener, aEvents); + }, + + /** + * Unregister a previously-registered listener. + * + * @param aListener Registered listener implementing nsIAndroidEventListener. + * @param aEvents String or array of strings of events to stop listening to. + */ + unregisterListener(aListener, aEvents) { + if (!this._dispatcher) { + throw new Error("Can only listen in parent process"); + } + this._dispatcher.unregisterListener(aListener, aEvents); + }, + + /** + * Dispatch an event to registered listeners for that event, and pass an + * optional data object and/or a optional callback interface to the + * listeners. + * + * @param aEvent Name of event to dispatch. + * @param aData Optional object containing data for the event. + * @param aCallback Optional callback implementing nsIAndroidEventCallback. + * @param aFinalizer Optional finalizer implementing nsIAndroidEventFinalizer. + */ + dispatch(aEvent, aData, aCallback, aFinalizer) { + if (this._dispatcher) { + this._dispatcher.dispatch(aEvent, aData, aCallback, aFinalizer); + return; + } + + const mm = this._messageManager || Services.cpmm; + const forwardData = { + global: !this._messageManager, + event: aEvent, + data: aData, + }; + + if (aCallback) { + const uuid = Services.uuid.generateUUID().toString(); + this._replies.set(uuid, { + callback: aCallback, + finalizer: aFinalizer, + }); + forwardData.uuid = uuid; + } + + mm.sendAsyncMessage("GeckoView:Messaging", forwardData); + }, + + /** + * Sends a request to Java. + * + * @param aMsg Message to send; must be an object with a "type" property + * @param aCallback Optional callback implementing nsIAndroidEventCallback. + */ + sendRequest(aMsg, aCallback) { + const type = aMsg.type; + aMsg.type = undefined; + this.dispatch(type, aMsg, aCallback); + }, + + /** + * Sends a request to Java, returning a Promise that resolves to the response. + * + * @param aMsg Message to send; must be an object with a "type" property + * @return A Promise resolving to the response + */ + sendRequestForResult(aMsg) { + return new Promise((resolve, reject) => { + const type = aMsg.type; + aMsg.type = undefined; + + // Manually release the resolve/reject functions after one callback is + // received, so the JS GC is not tied up with the Java GC. + const onCallback = (callback, ...args) => { + if (callback) { + callback(...args); + } + resolve = undefined; + reject = undefined; + }; + const callback = { + onSuccess: result => onCallback(resolve, result), + onError: error => onCallback(reject, error), + onFinalize: _ => onCallback(reject), + }; + this.dispatch(type, aMsg, callback, callback); + }); + }, + + finalize() { + if (!this._replies) { + return; + } + this._replies.forEach(reply => { + if (typeof reply.finalizer === "function") { + reply.finalizer(); + } else if (reply.finalizer) { + reply.finalizer.onFinalize(); + } + }); + this._replies.clear(); + }, + + receiveMessage(aMsg) { + const { uuid, type } = aMsg.data; + const reply = this._replies.get(uuid); + if (!reply) { + return; + } + + if (type === "success") { + reply.callback.onSuccess(aMsg.data.response); + } else if (type === "error") { + reply.callback.onError(aMsg.data.response); + } else if (type === "finalize") { + if (typeof reply.finalizer === "function") { + reply.finalizer(); + } else if (reply.finalizer) { + reply.finalizer.onFinalize(); + } + this._replies.delete(uuid); + } else { + throw new Error("invalid reply type"); + } + }, +}; + +export var EventDispatcher = { + instance: new DispatcherDelegate( + IS_PARENT_PROCESS ? Services.androidBridge : undefined + ), + + /** + * Return an EventDispatcher instance for a chrome DOM window. In a content + * process, return a proxy through the message manager that automatically + * forwards events to the main process. + * + * To force using a message manager proxy (for example in a frame script + * environment), call forMessageManager. + * + * @param aWindow a chrome DOM window. + */ + for(aWindow) { + const view = + aWindow && + aWindow.arguments && + aWindow.arguments[0] && + aWindow.arguments[0].QueryInterface(Ci.nsIAndroidView); + + if (!view) { + const mm = !IS_PARENT_PROCESS && aWindow && aWindow.messageManager; + if (!mm) { + throw new Error( + "window is not a GeckoView-connected window and does" + + " not have a message manager" + ); + } + return this.forMessageManager(mm); + } + + return new DispatcherDelegate(view); + }, + + /** + * Returns a named EventDispatcher, which can communicate with the + * corresponding EventDispatcher on the java side. + */ + byName(aName) { + if (!IS_PARENT_PROCESS) { + return undefined; + } + const dispatcher = Services.androidBridge.getDispatcherByName(aName); + return new DispatcherDelegate(dispatcher); + }, + + /** + * Return an EventDispatcher instance for a message manager associated with a + * window. + * + * @param aWindow a message manager. + */ + forMessageManager(aMessageManager) { + return new DispatcherDelegate(null, aMessageManager); + }, + + /** + * Return the EventDispatcher instance associated with an actor. + * + * @param aActor an actor + */ + forActor(aActor) { + return new ChildActorDispatcher(aActor); + }, + + receiveMessage(aMsg) { + // aMsg.data includes keys: global, event, data, uuid + let callback; + if (aMsg.data.uuid) { + const reply = (type, response) => { + const mm = aMsg.data.global ? aMsg.target : aMsg.target.messageManager; + if (!mm) { + if (type === "finalize") { + // It's normal for the finalize call to come after the browser has + // been destroyed. We can gracefully handle that case despite + // having no message manager. + return; + } + throw Error( + `No message manager for ${aMsg.data.event}:${type} reply` + ); + } + mm.sendAsyncMessage("GeckoView:MessagingReply", { + type, + response, + uuid: aMsg.data.uuid, + }); + }; + callback = { + onSuccess: response => reply("success", response), + onError: error => reply("error", error), + onFinalize: () => reply("finalize"), + }; + } + + try { + if (aMsg.data.global) { + this.instance.dispatch( + aMsg.data.event, + aMsg.data.data, + callback, + callback + ); + return; + } + + const win = aMsg.target.ownerGlobal; + const dispatcher = win.WindowEventDispatcher || this.for(win); + dispatcher.dispatch(aMsg.data.event, aMsg.data.data, callback, callback); + } catch (e) { + callback?.onError(`Error getting dispatcher: ${e}`); + throw e; + } + }, +}; + +if (IS_PARENT_PROCESS) { + Services.mm.addMessageListener("GeckoView:Messaging", EventDispatcher); + Services.ppmm.addMessageListener("GeckoView:Messaging", EventDispatcher); +} diff --git a/mobile/android/modules/geckoview/metrics.yaml b/mobile/android/modules/geckoview/metrics.yaml new file mode 100644 index 0000000000..3b8cdd0dc9 --- /dev/null +++ b/mobile/android/modules/geckoview/metrics.yaml @@ -0,0 +1,153 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - "GeckoView :: General" + +geckoview: + page_load_progress_time: + type: timing_distribution + time_unit: millisecond + telemetry_mirror: GV_PAGE_LOAD_PROGRESS_MS + description: > + Time between page load progress starts (0) and completion (100). + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077#c10 + notification_emails: + - android-probes@mozilla.com + expires: never + + page_load_time: + type: timing_distribution + time_unit: millisecond + telemetry_mirror: GV_PAGE_LOAD_MS + description: > + The time taken to load a page. This includes all static contents, no + dynamic content. + Loading of about: pages is not counted. + Back back navigation (sometimes via BFCache) is included which is a + source of bimodality due to the <50ms load times. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109#c1 + notification_emails: + - android-probes@mozilla.com + expires: never + + page_reload_time: + type: timing_distribution + time_unit: millisecond + telemetry_mirror: GV_PAGE_RELOAD_MS + description: > + Time taken to reload a page. + This includes all static contents, no dynamic content. + Loading of about: pages is not counted. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1549519 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077#c10 + notification_emails: + - android-probes@mozilla.com + - sefeng@mozilla.com + - perf-telemetry-alerts@mozilla.com + expires: never + + document_site_origins: + type: custom_distribution + description: > + When a document is loaded, report the + number of [site origins](https://searchfox.org/ + mozilla-central/rev/ + 3300072e993ae05d50d5c63d815260367eaf9179/ + caps/nsIPrincipal.idl#264) of the entire browser + if it has been at least 5 minutes since last + time we collect this data. + (Migrated from the geckoview metric of the same name). + range_min: 0 + range_max: 100 + bucket_count: 50 + histogram_type: exponential + unit: number of site_origin + telemetry_mirror: FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1589700 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1589700#c5 + notification_emails: + - sefeng@mozilla.com + - perf-telemetry-alerts@mozilla.com + expires: never + + per_document_site_origins: + type: custom_distribution + description: > + When a document is unloaded, report the highest number of + [site origins](https://searchfox.org/ + mozilla-central/rev/ + 3300072e993ae05d50d5c63d815260367eaf9179/ + caps/nsIPrincipal.idl#264) loaded simultaneously in that + document. + (Migrated from the geckoview metric of the same name). + range_min: 0 + range_max: 100 + bucket_count: 50 + histogram_type: exponential + unit: number of site origins per document + telemetry_mirror: FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_DOCUMENT + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1603185 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1603185#c13 + notification_emails: + - barret@mozilla.com + - perf-telemetry-alerts@mozilla.com + expires: never + + startup_runtime: + type: timing_distribution + time_unit: millisecond + description: > + The time taken to initialize GeckoRuntime. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109#c1 + notification_emails: + - android-probes@mozilla.com + expires: never + + content_process_lifetime: + type: timing_distribution + time_unit: millisecond + description: > + The uptime of content processes. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1625325 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1625325#c2 + notification_emails: + - android-probes@mozilla.com + expires: never diff --git a/mobile/android/modules/geckoview/moz.build b/mobile/android/modules/geckoview/moz.build new file mode 100644 index 0000000000..9c2693c85e --- /dev/null +++ b/mobile/android/modules/geckoview/moz.build @@ -0,0 +1,45 @@ +# -*- 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/. + +EXTRA_JS_MODULES += [ + "AndroidLog.sys.mjs", + "BrowserUsageTelemetry.sys.mjs", + "ChildCrashHandler.sys.mjs", + "DelayedInit.sys.mjs", + "GeckoViewActorChild.sys.mjs", + "GeckoViewActorManager.sys.mjs", + "GeckoViewActorParent.sys.mjs", + "GeckoViewAutocomplete.sys.mjs", + "GeckoViewAutofill.sys.mjs", + "GeckoViewChildModule.sys.mjs", + "GeckoViewClipboardPermission.sys.mjs", + "GeckoViewConsole.sys.mjs", + "GeckoViewContent.sys.mjs", + "GeckoViewContentBlocking.sys.mjs", + "GeckoViewIdentityCredential.sys.mjs", + "GeckoViewMediaControl.sys.mjs", + "GeckoViewModule.sys.mjs", + "GeckoViewNavigation.sys.mjs", + "GeckoViewProcessHangMonitor.sys.mjs", + "GeckoViewProgress.sys.mjs", + "GeckoViewPushController.sys.mjs", + "GeckoViewRemoteDebugger.sys.mjs", + "GeckoViewSelectionAction.sys.mjs", + "GeckoViewSessionStore.sys.mjs", + "GeckoViewSettings.sys.mjs", + "GeckoViewStorageController.sys.mjs", + "GeckoViewTab.sys.mjs", + "GeckoViewTelemetry.sys.mjs", + "GeckoViewTestUtils.sys.mjs", + "GeckoViewTranslations.sys.mjs", + "GeckoViewUtils.sys.mjs", + "GeckoViewWebExtension.sys.mjs", + "LoadURIDelegate.sys.mjs", + "MediaUtils.sys.mjs", + "Messaging.sys.mjs", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] diff --git a/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js b/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js new file mode 100644 index 0000000000..0e07937ed3 --- /dev/null +++ b/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ChildCrashHandler: "resource://gre/modules/ChildCrashHandler.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { makeFakeAppDir } = ChromeUtils.importESModule( + "resource://testing-common/AppData.sys.mjs" +); + +add_setup(async function () { + await makeFakeAppDir(); + // The test harness sets MOZ_CRASHREPORTER_NO_REPORT, which disables crash + // reports. This test needs them enabled. + const noReport = Services.env.get("MOZ_CRASHREPORTER_NO_REPORT"); + Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", ""); + + registerCleanupFunction(function () { + Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", noReport); + }); +}); + +add_task(async function test_remoteType() { + const childID = 123; + const remoteType = "webIsolated=https://example.com"; + // Force-set a remote type for the process that we are going to "crash" next. + lazy.ChildCrashHandler.childMap.set(childID, remoteType); + + // Mock a process crash being notified. + const propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + propertyBag.setPropertyAsBool("abnormal", true); + propertyBag.setPropertyAsAString("dumpID", "a-dump-id"); + + // Set up a listener to receive the crash report event emitted by the handler. + let listener; + const crashReportPromise = new Promise(resolve => { + listener = { + onEvent(aEvent, aData, aCallback) { + resolve([aEvent, aData]); + }, + }; + }); + lazy.EventDispatcher.instance.registerListener(listener, [ + "GeckoView:ChildCrashReport", + ]); + + // Simulate a crash. + lazy.ChildCrashHandler.observe(propertyBag, "ipc:content-shutdown", childID); + + const [aEvent, aData] = await crashReportPromise; + Assert.equal( + "GeckoView:ChildCrashReport", + aEvent, + "expected a child crash report" + ); + Assert.equal("webIsolated", aData?.remoteType, "expected remote type prefix"); +}); + +add_task(async function test_extensions_process_crash() { + const childID = 123; + const remoteType = "extension"; + // Force-set a remote type for the process that we are going to "crash" next. + lazy.ChildCrashHandler.childMap.set(childID, remoteType); + + // Mock a process crash being notified. + const propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + propertyBag.setPropertyAsBool("abnormal", true); + propertyBag.setPropertyAsAString("dumpID", "a-dump-id"); + + // Set up a listener to receive the crash report event emitted by the handler. + let listener; + const crashReportPromise = new Promise(resolve => { + listener = { + onEvent(aEvent, aData, aCallback) { + resolve([aEvent, aData]); + }, + }; + }); + lazy.EventDispatcher.instance.registerListener(listener, [ + "GeckoView:ChildCrashReport", + ]); + + // Simulate a crash. + lazy.ChildCrashHandler.observe(propertyBag, "ipc:content-shutdown", childID); + + const [aEvent, aData] = await crashReportPromise; + Assert.equal( + "GeckoView:ChildCrashReport", + aEvent, + "expected a child crash report" + ); + Assert.equal("extension", aData?.remoteType, "expected remote type"); + Assert.equal("BACKGROUND_CHILD", aData?.processType, "expected process type"); +}); diff --git a/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml b/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..174a258d24 --- /dev/null +++ b/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] +firefox-appdir = "browser" +run-if = ["os == 'android'"] + +["test_ChildCrashHandler.js"] diff --git a/mobile/android/modules/moz.build b/mobile/android/modules/moz.build new file mode 100644 index 0000000000..52db19075d --- /dev/null +++ b/mobile/android/modules/moz.build @@ -0,0 +1,18 @@ +# -*- 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/. + +# Most files are General, a few exceptions +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +DIRS += [ + "geckoview", + "test", +] + +EXTRA_JS_MODULES += [ + "dbg-browser-actors.js", +] diff --git a/mobile/android/modules/test/AppUiTestDelegate.sys.mjs b/mobile/android/modules/test/AppUiTestDelegate.sys.mjs new file mode 100644 index 0000000000..8f880251ef --- /dev/null +++ b/mobile/android/modules/test/AppUiTestDelegate.sys.mjs @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +const TEST_SUPPORT_EXTENSION_ID = "test-runner-support@tests.mozilla.org"; + +/** + * The implementation of AppUiTestDelegate. All implementations need to be kept + * in sync. For details, see: + * testing/specialpowers/content/AppTestDelegateParent.sys.mjs + * + * This implementation mostly forwards calls to TestRunnerApiEngine in + * mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java + */ +class Delegate { + _sendMessageToApp(data) { + // "GeckoView:WebExtension:Message" with the "nativeApp" property set is a + // message usually emitted by the runtime.sendNativeMessage implementation. + // + // Although a dummy extension with ID TEST_SUPPORT_EXTENSION_ID is installed + // by TestRunnerActivity, the sendNativeMessage API is not used directly. + // Instead, we forge a message in the same (internal) format here. + // + // The message is ultimately received and handled by TestRunnerApiEngine at + // mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java + const message = { + type: "GeckoView:WebExtension:Message", + sender: { + envType: "addon_child", + url: "test-runner-support:///", + }, + data, + extensionId: TEST_SUPPORT_EXTENSION_ID, + nativeApp: "test-runner-support", + }; + + return lazy.EventDispatcher.instance.sendRequestForResult(message); + } + + clickPageAction(window, extensionId) { + return this._sendMessageToApp({ type: "clickPageAction", extensionId }); + } + + clickBrowserAction(window, extensionId) { + return this._sendMessageToApp({ type: "clickBrowserAction", extensionId }); + } + + closePageAction(window, extensionId) { + return this._sendMessageToApp({ type: "closePageAction", extensionId }); + } + + closeBrowserAction(window, extensionId) { + return this._sendMessageToApp({ type: "closeBrowserAction", extensionId }); + } + + awaitExtensionPanel(window, extensionId) { + return this._sendMessageToApp({ type: "awaitExtensionPanel", extensionId }); + } + + async removeTab(tab) { + const window = tab.browser.ownerGlobal; + await lazy.GeckoViewTabBridge.closeTab({ + window, + extensionId: TEST_SUPPORT_EXTENSION_ID, + }); + } + + async openNewForegroundTab(window, url, waitForLoad = true) { + const tab = await lazy.GeckoViewTabBridge.createNewTab({ + extensionId: TEST_SUPPORT_EXTENSION_ID, + createProperties: { + url, + active: true, + }, + }); + + const { browser } = tab; + const triggeringPrincipal = + Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(url), + {} + ); + + browser.fixupAndLoadURIString(url, { + flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE, + triggeringPrincipal, + }); + + const newWindow = browser.ownerGlobal; + lazy.mobileWindowTracker.setTabActive(newWindow, true); + + if (!waitForLoad) { + return tab; + } + + return new Promise(resolve => { + const listener = ev => { + const { browsingContext, internalURL } = ev.detail; + + // Sometimes we arrive here without an internalURL. If that's the + // case, just keep waiting until we get one. + if (!internalURL || internalURL == "about:blank") { + return; + } + + // Ignore subframes + if (browsingContext !== browsingContext.top) { + return; + } + + resolve(tab); + browser.removeEventListener("AppTestDelegate:load", listener, true); + }; + browser.addEventListener("AppTestDelegate:load", listener, true); + }); + } +} + +export var AppUiTestDelegate = new Delegate(); diff --git a/mobile/android/modules/test/moz.build b/mobile/android/modules/test/moz.build new file mode 100644 index 0000000000..b81665c00a --- /dev/null +++ b/mobile/android/modules/test/moz.build @@ -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/. + +TESTING_JS_MODULES += [ + "AppUiTestDelegate.sys.mjs", +] diff --git a/mobile/android/moz.build b/mobile/android/moz.build new file mode 100644 index 0000000000..689ec82933 --- /dev/null +++ b/mobile/android/moz.build @@ -0,0 +1,90 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + SCHEDULES.exclusive = ["android"] + +with Files("geckoview_example/**"): + BUG_COMPONENT = ("GeckoView", "GeckoViewExample") + +# The recursive make backend treats the first output specially: it's passed as +# an open FileAvoidWrite to the invoked script. That doesn't work well with +# the Gradle task that generates all of the outputs, so we add a dummy first +# output. +t = ("android_apks",) + +GENERATED_FILES += [t] +GENERATED_FILES[t].force = True +GENERATED_FILES[t].script = "/mobile/android/gradle.py:assemble_app" + +# The Android APKs are assembled in the `export` tier, which usually occurs +# before the following files are generated. However, mechanisms in `recurse.mk` +# are used to pull the generated files into the `pre-export` tier, so do not +# require an explicit dependency here. +config_keys = ( + "MOZ_ANDROID_CONTENT_SERVICE_COUNT", + "MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS", +) +flags = ["%s=%s" % (k, CONFIG[k] if CONFIG[k] else "") for k in config_keys] + +GeneratedFile( + ("geckoview/src/main/AndroidManifest_overlay.xml",), + script="gen_from_jinja.py", + inputs=["geckoview/src/main/AndroidManifest_overlay.jinja"], + flags=flags, +) + +GeneratedFile( + ( + "geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.java", + ), + script="gen_from_jinja.py", + inputs=[ + "geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja" + ], + flags=flags, +) + +GeneratedFile( + ("geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.java",), + script="/xpcom/base/ErrorList.py", + entry_point="gen_jinja", + inputs=["geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja"], +) + +CONFIGURE_SUBST_FILES += ["installer/Makefile"] + +DIRS += [ + "../locales", + "locales", +] + +DIRS += [ + "actors", + "chrome", + "components", + "modules", + "themes/geckoview", + "geckoview/src/androidTest/assets", + "app", + "fonts", +] + +TEST_HARNESS_FILES.testing.mochitest.tests.junit += [ + "geckoview/src/androidTest/assets/www/forms_iframe.html", + "geckoview/src/androidTest/assets/www/forms_xorigin.html", + "geckoview/src/androidTest/assets/www/hello.html", + "geckoview/src/androidTest/assets/www/hsts_header.sjs", + "geckoview/src/androidTest/assets/www/iframe_http_only.html", + "geckoview/src/androidTest/assets/www/simple_redirect.sjs", + "geckoview/src/androidTest/assets/www/update_manifest.json", +] + +SPHINX_TREES["/mobile/android"] = "docs" + +with Files("docs/**"): + SCHEDULES.exclusive = ["docs"] diff --git a/mobile/android/moz.configure b/mobile/android/moz.configure new file mode 100644 index 0000000000..a6ba923d93 --- /dev/null +++ b/mobile/android/moz.configure @@ -0,0 +1,95 @@ +# -*- 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/. + +project_flag( + "MOZ_ANDROID_EXCLUDE_FONTS", + help="Whether to exclude font files from the build", + default=True, +) + +project_flag( + "MOZ_ANDROID_HLS_SUPPORT", + help="Enable HLS (HTTP Live Streaming) support (currently using the ExoPlayer library)", + default=True, +) + +option( + "--num-content-services", + default="40", + help="The number of content process services to generate in the GeckoView manifest", +) + + +@depends("--num-content-services") +def num_content_services(value): + strValue = value[0] + intValue = int(strValue) + acceptableRange = range(1, 41) + if intValue not in acceptableRange: + die( + "Unacceptable value, must be within range [%d,%d)" + % (acceptableRange.start, acceptableRange.stop) + ) + return strValue + + +set_config("MOZ_ANDROID_CONTENT_SERVICE_COUNT", num_content_services) +set_define("MOZ_ANDROID_CONTENT_SERVICE_COUNT", num_content_services) + +option( + "--enable-isolated-process", + env="MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS", + help="Enable generating content process services with isolatedProcess=true", + default=False, +) +set_config( + "MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS", + depends_if("MOZ_ANDROID_CONTENT_SERVICE_ISOLATED_PROCESS")(lambda x: True), +) + +option( + "--enable-geckoview-lite", + help="Build GeckoView in Lite mode. Lite mode removes all unnecessary dependencies like Glean.", +) + +set_config("MOZ_ANDROID_GECKOVIEW_LITE", True, when="--enable-geckoview-lite") + +imply_option("MOZ_NORMANDY", False) +imply_option("MOZ_SERVICES_HEALTHREPORT", True) +imply_option("MOZ_ANDROID_HISTORY", True) + + +@depends(target) +def check_target(target): + if target.os != "Android": + log.error( + "You must specify --target=arm-linux-androideabi (or some " + "other valid Android target) when building mobile/android." + ) + die( + "See https://developer.mozilla.org/docs/Mozilla/Developer_guide/" + "Build_Instructions/Simple_Firefox_for_Android_build " + "for more information about the necessary options." + ) + + +include("../../toolkit/moz.configure") +include("../../build/moz.configure/android-sdk.configure") +include("../../build/moz.configure/java.configure") +include("gradle.configure") + +# Automation will set this via the TC environment. +option( + env="MOZ_ANDROID_FAT_AAR_ARCHITECTURES", + nargs="*", + choices=("armeabi-v7a", "arm64-v8a", "x86", "x86_64"), + help='Comma-separated list of Android CPU architectures like "armeabi-v7a,arm64-v8a,x86,x86_64"', +) + +set_config( + "MOZ_ANDROID_FAT_AAR_ARCHITECTURES", + depends("MOZ_ANDROID_FAT_AAR_ARCHITECTURES")(lambda x: x), +) diff --git a/mobile/android/test_runner/build.gradle b/mobile/android/test_runner/build.gradle new file mode 100644 index 0000000000..7f20b84c34 --- /dev/null +++ b/mobile/android/test_runner/build.gradle @@ -0,0 +1,61 @@ +buildDir "${topobjdir}/gradle/build/mobile/android/test_runner" + +apply plugin: 'com.android.application' + +apply from: "${topsrcdir}/mobile/android/gradle/product_flavors.gradle" + +android { + buildToolsVersion project.ext.buildToolsVersion + compileSdkVersion project.ext.compileSdkVersion + + defaultConfig { + targetSdkVersion project.ext.targetSdkVersion + minSdkVersion project.ext.minSdkVersion + manifestPlaceholders = project.ext.manifestPlaceholders + + applicationId "org.mozilla.geckoview.test_runner" + versionCode project.ext.versionCode + versionName project.ext.versionName + + multiDexEnabled true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + + // By default the android plugins ignores folders that start with `_`, but + // we need those in web extensions. + // See also: + // - https://issuetracker.google.com/issues/36911326 + // - https://stackoverflow.com/questions/9206117/how-to-workaround-autoomitting-fiiles-folders-starting-with-underscore-in + aaptOptions { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' + noCompress 'ja' + } + + project.configureProductFlavors.delegate = it + project.configureProductFlavors() + + namespace 'org.mozilla.geckoview.test_runner' +} + +dependencies { + implementation "androidx.annotation:annotation:1.6.0" + implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.preference:preference:1.1.1" + + implementation project(path: ':geckoview') + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.google.android.material:material:1.9.0' + + implementation 'androidx.multidex:multidex:2.0.1' +} diff --git a/mobile/android/test_runner/src/main/AndroidManifest.xml b/mobile/android/test_runner/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..6f55aa27ab --- /dev/null +++ b/mobile/android/test_runner/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> + <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> + <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> + <uses-permission android:name="android.permission.RECORD_AUDIO"/> + <uses-permission android:name="android.permission.CAMERA"/> + <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> + <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/> + + <application + android:allowBackup="true" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:usesCleartextTraffic="true" + android:theme="@style/AppTheme" + android:name="androidx.multidex.MultiDexApplication"> + <uses-library android:name="android.test.runner" android:required="false"/> + <activity android:name=".TestRunnerActivity" + android:configChanges="orientation|screenSize" + android:exported="true"/> + <activity-alias android:name=".App" android:targetActivity=".TestRunnerActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + <action android:name="org.mozilla.geckoview.test_runner.XPCSHELL_TEST"/> + <category android:name="android.intent.category.DEFAULT"/> + <category android:name="android.intent.category.LAUNCHER"/> + </intent-filter> + </activity-alias> + + <!-- This is used to run xpcshell tests --> + <service android:name=".XpcshellTestRunnerService$i0" android:enabled="true" android:exported="true" android:process=":xpcshell0"/> + <service android:name=".XpcshellTestRunnerService$i1" android:enabled="true" android:exported="true" android:process=":xpcshell1"/> + <service android:name=".XpcshellTestRunnerService$i2" android:enabled="true" android:exported="true" android:process=":xpcshell2"/> + <service android:name=".XpcshellTestRunnerService$i3" android:enabled="true" android:exported="true" android:process=":xpcshell3"/> + <service android:name=".XpcshellTestRunnerService$i4" android:enabled="true" android:exported="true" android:process=":xpcshell4"/> + <service android:name=".XpcshellTestRunnerService$i5" android:enabled="true" android:exported="true" android:process=":xpcshell5"/> + <service android:name=".XpcshellTestRunnerService$i6" android:enabled="true" android:exported="true" android:process=":xpcshell6"/> + <service android:name=".XpcshellTestRunnerService$i7" android:enabled="true" android:exported="true" android:process=":xpcshell7"/> + <service android:name=".XpcshellTestRunnerService$i8" android:enabled="true" android:exported="true" android:process=":xpcshell8"/> + <service android:name=".XpcshellTestRunnerService$i9" android:enabled="true" android:exported="true" android:process=":xpcshell9"/> + </application> +</manifest> diff --git a/mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json b/mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json new file mode 100644 index 0000000000..3d68643af9 --- /dev/null +++ b/mobile/android/test_runner/src/main/assets/test-runner-support/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Dummy test-runner-support", + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test-runner-support@tests.mozilla.org" + } + }, + "description": "This extension pretends to be the sender of native messages from tests. See mobile/android/modules/test/AppUiTestDelegate.sys.mjs for actual usage." +} diff --git a/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java new file mode 100644 index 0000000000..3930ab545c --- /dev/null +++ b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java @@ -0,0 +1,715 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test_runner; + +import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_EXPERIMENT_SLUG_NOT_FOUND; +import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_FEATURE_NOT_FOUND; +import static org.mozilla.geckoview.ExperimentDelegate.ExperimentException.ERROR_UNKNOWN; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.SurfaceTexture; +import android.net.Uri; +import android.os.Bundle; +import android.view.Surface; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.geckoview.AllowOrDeny; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.ExperimentDelegate; +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.GeckoSession.PermissionDelegate.ContentPermission; +import org.mozilla.geckoview.GeckoSessionSettings; +import org.mozilla.geckoview.GeckoView; +import org.mozilla.geckoview.OrientationController; +import org.mozilla.geckoview.WebExtension; +import org.mozilla.geckoview.WebExtensionController; +import org.mozilla.geckoview.WebRequestError; + +public class TestRunnerActivity extends Activity { + private static final String LOGTAG = "TestRunnerActivity"; + private static final String ERROR_PAGE = + "<!DOCTYPE html><head><title>Error</title></head><body>Error!</body></html>"; + + static GeckoRuntime sRuntime; + + private GeckoSession mPopupSession; + private GeckoSession mSession; + private GeckoView mView; + private boolean mKillProcessOnDestroy; + + private HashMap<GeckoSession, Display> mDisplays = new HashMap<>(); + private HashMap<String, ExtensionWrapper> mExtensions = new HashMap<>(); + + private static class ExtensionWrapper { + public WebExtension extension; + public HashMap<GeckoSession, WebExtension.Action> browserActions; + public HashMap<GeckoSession, WebExtension.Action> pageActions; + + public ExtensionWrapper(final WebExtension extension) { + this.extension = extension; + browserActions = new HashMap<>(); + pageActions = new HashMap<>(); + } + } + + private static class Display { + public final SurfaceTexture texture; + public final Surface surface; + + private final int width; + private final int height; + private GeckoDisplay sessionDisplay; + + public Display(final int width, final int height) { + this.width = width; + this.height = height; + texture = new SurfaceTexture(0); + texture.setDefaultBufferSize(width, height); + surface = new Surface(texture); + } + + public void attach(final GeckoSession session) { + sessionDisplay = session.acquireDisplay(); + sessionDisplay.surfaceChanged( + new GeckoDisplay.SurfaceInfo.Builder(surface).size(width, height).build()); + } + + public void release(final GeckoSession session) { + sessionDisplay.surfaceDestroyed(); + session.releaseDisplay(sessionDisplay); + } + } + + private static WebExtensionController webExtensionController() { + return sRuntime.getWebExtensionController(); + } + + private static OrientationController orientationController() { + return sRuntime.getOrientationController(); + } + + // Keeps track of all sessions for this test runner. The top session in the deque is the + // current active session for extension purposes. + private ArrayDeque<GeckoSession> mOwnedSessions = new ArrayDeque<>(); + + private GeckoSession.PermissionDelegate mPermissionDelegate = + new GeckoSession.PermissionDelegate() { + @Override + public GeckoResult<Integer> onContentPermissionRequest( + @NonNull final GeckoSession session, @NonNull ContentPermission perm) { + return GeckoResult.fromValue(ContentPermission.VALUE_ALLOW); + } + + @Override + public void onAndroidPermissionsRequest( + @NonNull final GeckoSession session, + @Nullable final String[] permissions, + @NonNull final Callback callback) { + callback.grant(); + } + }; + + private GeckoSession.PromptDelegate mPromptDelegate = + new GeckoSession.PromptDelegate() { + Map<BasePrompt, GeckoResult<PromptResponse>> mPromptResults = new HashMap<>(); + public GeckoSession.PromptDelegate.PromptInstanceDelegate mPromptInstanceDelegate = + new GeckoSession.PromptDelegate.PromptInstanceDelegate() { + @Override + public void onPromptDismiss( + final @NonNull GeckoSession.PromptDelegate.BasePrompt prompt) { + mPromptResults.get(prompt).complete(prompt.dismiss()); + } + }; + + @Override + public GeckoResult<PromptResponse> onAlertPrompt( + @NonNull final GeckoSession session, @NonNull final AlertPrompt prompt) { + mPromptResults.put(prompt, new GeckoResult<>()); + prompt.setDelegate(mPromptInstanceDelegate); + return mPromptResults.get(prompt); + } + + @Override + public GeckoResult<PromptResponse> onButtonPrompt( + @NonNull final GeckoSession session, @NonNull final ButtonPrompt prompt) { + mPromptResults.put(prompt, new GeckoResult<>()); + prompt.setDelegate(mPromptInstanceDelegate); + return mPromptResults.get(prompt); + } + + @Override + public GeckoResult<PromptResponse> onTextPrompt( + @NonNull final GeckoSession session, @NonNull final TextPrompt prompt) { + mPromptResults.put(prompt, new GeckoResult<>()); + prompt.setDelegate(mPromptInstanceDelegate); + return mPromptResults.get(prompt); + } + }; + + private GeckoSession.NavigationDelegate mNavigationDelegate = + new GeckoSession.NavigationDelegate() { + @Override + public void onLocationChange( + final GeckoSession session, final String url, final List<ContentPermission> perms) { + getActionBar().setSubtitle(url); + } + + @Override + public GeckoResult<AllowOrDeny> onLoadRequest( + final GeckoSession session, final LoadRequest request) { + // Allow Gecko to load all URIs + return GeckoResult.fromValue(AllowOrDeny.ALLOW); + } + + @Override + public GeckoResult<GeckoSession> onNewSession( + final GeckoSession session, final String uri) { + webExtensionController().setTabActive(mOwnedSessions.peek(), false); + final GeckoSession newSession = + createBackgroundSession(session.getSettings(), /* active */ true); + webExtensionController().setTabActive(newSession, true); + return GeckoResult.fromValue(newSession); + } + + @Override + public GeckoResult<String> onLoadError( + final GeckoSession session, final String uri, final WebRequestError error) { + + return GeckoResult.fromValue("data:text/html," + ERROR_PAGE); + } + }; + + private GeckoSession.ContentDelegate mContentDelegate = + new GeckoSession.ContentDelegate() { + private void onContentProcessGone() { + if (System.getenv("MOZ_CRASHREPORTER_SHUTDOWN") != null) { + sRuntime.shutdown(); + } + } + + @Override + public void onCloseRequest(final GeckoSession session) { + closeSession(session); + } + + @Override + public void onCrash(final GeckoSession session) { + onContentProcessGone(); + } + + @Override + public void onKill(final GeckoSession session) { + onContentProcessGone(); + } + }; + + private WebExtension.TabDelegate mTabDelegate = + new WebExtension.TabDelegate() { + @Override + public GeckoResult<GeckoSession> onNewTab( + final WebExtension source, final WebExtension.CreateTabDetails details) { + GeckoSessionSettings settings = null; + if (details.cookieStoreId != null) { + settings = new GeckoSessionSettings.Builder().contextId(details.cookieStoreId).build(); + } + + if (details.active == Boolean.TRUE) { + webExtensionController().setTabActive(mOwnedSessions.peek(), false); + } + final GeckoSession newSession = createSession(settings, details.active == Boolean.TRUE); + return GeckoResult.fromValue(newSession); + } + }; + + private WebExtension.ActionDelegate mActionDelegate = + new WebExtension.ActionDelegate() { + private GeckoResult<GeckoSession> togglePopup( + final WebExtension extension, final boolean forceOpen) { + if (mPopupSession != null) { + mPopupSession.close(); + if (!forceOpen) { + return null; + } + } + + mPopupSession = createBackgroundSession(null, /* active */ false); + mPopupSession.open(sRuntime); + + // Set the progress delegate in case there is an observer to the popup being loaded. + mTestApiImpl.setCurrentPopupExtension(extension); + mPopupSession.setProgressDelegate(mTestApiImpl); + + return GeckoResult.fromValue(mPopupSession); + } + + @Nullable + @Override + public GeckoResult<GeckoSession> onOpenPopup( + final WebExtension extension, final WebExtension.Action action) { + return togglePopup(extension, true); + } + + @Nullable + @Override + public GeckoResult<GeckoSession> onTogglePopup( + final WebExtension extension, final WebExtension.Action action) { + return togglePopup(extension, false); + } + + @Override + public void onBrowserAction( + final WebExtension extension, + final GeckoSession session, + final WebExtension.Action action) { + mExtensions.get(extension.id).browserActions.put(session, action); + } + + @Override + public void onPageAction( + final WebExtension extension, + final GeckoSession session, + final WebExtension.Action action) { + mExtensions.get(extension.id).pageActions.put(session, action); + } + }; + + private WebExtension.SessionTabDelegate mSessionTabDelegate = + new WebExtension.SessionTabDelegate() { + @NonNull + @Override + public GeckoResult<AllowOrDeny> onCloseTab( + @Nullable final WebExtension source, @NonNull final GeckoSession session) { + closeSession(session); + return GeckoResult.fromValue(AllowOrDeny.ALLOW); + } + + @Override + public GeckoResult<AllowOrDeny> onUpdateTab( + @NonNull final WebExtension source, + @NonNull final GeckoSession session, + @NonNull final WebExtension.UpdateTabDetails updateDetails) { + if (updateDetails.active == Boolean.TRUE) { + // Move session to the top since it's now the active tab + mOwnedSessions.remove(session); + mOwnedSessions.addFirst(session); + } + + return GeckoResult.fromValue(AllowOrDeny.ALLOW); + } + }; + + private class TestRunnerActivityDelegate implements GeckoView.ActivityContextDelegate { + public Context getActivityContext() { + return TestRunnerActivity.this; + } + } + + private TestRunnerActivityDelegate mActivityDelegate = new TestRunnerActivityDelegate(); + + /** + * Creates a session and adds it to the owned sessions deque. + * + * @param active Whether this session is the "active" session for extension purposes. The active + * session always sit at the top of the owned sessions deque. + * @return the newly created session. + */ + private GeckoSession createSession(final boolean active) { + return createSession(null, active); + } + + /** + * Creates a session and adds it to the owned sessions deque. + * + * @param settings settings for the newly created {@link GeckoSession}, could be null if no extra + * settings need to be added. + * @param active Whether this session is the "active" session for extension purposes. The active + * session always sit at the top of the owned sessions deque. + * @return the newly created session. + */ + private GeckoSession createSession(GeckoSessionSettings settings, final boolean active) { + if (settings == null) { + settings = new GeckoSessionSettings(); + } + + final GeckoSession session = new GeckoSession(settings); + session.setNavigationDelegate(mNavigationDelegate); + session.setContentDelegate(mContentDelegate); + session.setPermissionDelegate(mPermissionDelegate); + session.setPromptDelegate(mPromptDelegate); + + final WebExtension.SessionController sessionController = session.getWebExtensionController(); + for (final ExtensionWrapper wrapper : mExtensions.values()) { + sessionController.setActionDelegate(wrapper.extension, mActionDelegate); + sessionController.setTabDelegate(wrapper.extension, mSessionTabDelegate); + } + + if (active) { + mOwnedSessions.addFirst(session); + } else { + mOwnedSessions.addLast(session); + } + return session; + } + + /** + * Creates a session with a display attached. + * + * @param settings settings for the newly created {@link GeckoSession}, could be null if no extra + * settings need to be added. + * @param active Whether this session is the "active" session for extension purposes. The active + * session always sit at the top of the owned sessions deque. + * @return the newly created session. + */ + private GeckoSession createBackgroundSession( + final GeckoSessionSettings settings, final boolean active) { + final GeckoSession session = createSession(settings, active); + + final Display display = new Display(mView.getWidth(), mView.getHeight()); + display.attach(session); + + mDisplays.put(session, display); + + return session; + } + + private void closeSession(final GeckoSession session) { + if (session == mOwnedSessions.peek()) { + webExtensionController().setTabActive(session, false); + } + if (mDisplays.containsKey(session)) { + final Display display = mDisplays.remove(session); + display.release(session); + } + mOwnedSessions.remove(session); + session.close(); + if (!mOwnedSessions.isEmpty()) { + // Pick the top session as the current active + webExtensionController().setTabActive(mOwnedSessions.peek(), true); + } + } + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Intent intent = getIntent(); + + if ("org.mozilla.geckoview.test.XPCSHELL_TEST_MAIN".equals(intent.getAction())) { + // This activity is just a stub to run xpcshell tests in a service + return; + } + + if (sRuntime == null) { + final GeckoRuntimeSettings.Builder runtimeSettingsBuilder = + new GeckoRuntimeSettings.Builder(); + + // Mochitest and reftest encounter rounding errors if we have a + // a window.devicePixelRation like 3.625, so simplify that here. + runtimeSettingsBuilder + .arguments(new String[] {"-purgecaches"}) + .displayDpiOverride(160) + .displayDensityOverride(1.0f) + .remoteDebuggingEnabled(true) + .experimentDelegate(new TestRunnerExperimentDelegate()); + + final Bundle extras = intent.getExtras(); + if (extras != null) { + runtimeSettingsBuilder.extras(extras); + } + + final ContentBlocking.SafeBrowsingProvider googleLegacy = + ContentBlocking.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 ContentBlocking.SafeBrowsingProvider google = + ContentBlocking.SafeBrowsingProvider.from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER) + .getHashUrl("http://mochi.test:8888/safebrowsing4-dummy/gethash") + .updateUrl("http://mochi.test:8888/safebrowsing4-dummy/update") + .build(); + + runtimeSettingsBuilder + .consoleOutput(true) + .contentBlocking( + new ContentBlocking.Settings.Builder() + .safeBrowsingProviders(google, googleLegacy) + .build()); + + sRuntime = GeckoRuntime.create(this, runtimeSettingsBuilder.build()); + + webExtensionController() + .setDebuggerDelegate( + new WebExtensionController.DebuggerDelegate() { + @Override + public void onExtensionListUpdated() { + refreshExtensionList(); + } + }); + + webExtensionController() + .installBuiltIn("resource://android/assets/test-runner-support/") + .accept( + extension -> { + extension.setMessageDelegate(mApiEngine, "test-runner-support"); + extension.setTabDelegate(mTabDelegate); + }); + + webExtensionController() + .setAddonManagerDelegate(new WebExtensionController.AddonManagerDelegate() {}); + + sRuntime.setDelegate( + () -> { + mKillProcessOnDestroy = true; + finish(); + }); + } + + orientationController() + .setDelegate( + new OrientationController.OrientationDelegate() { + @Override + public GeckoResult<AllowOrDeny> onOrientationLock(int aOrientation) { + setRequestedOrientation(aOrientation); + return GeckoResult.allow(); + } + }); + + mSession = createSession(/* active */ true); + webExtensionController().setTabActive(mOwnedSessions.peek(), true); + mSession.open(sRuntime); + + // If we were passed a URI in the Intent, open it + final Uri uri = intent.getData(); + if (uri != null) { + mSession.loadUri(uri.toString()); + } + + mView = new GeckoView(this); + mView.setSession(mSession); + setContentView(mView); + mView.setActivityContextDelegate(mActivityDelegate); + + sRuntime.setServiceWorkerDelegate( + new GeckoRuntime.ServiceWorkerDelegate() { + @NonNull + @Override + public GeckoResult<GeckoSession> onOpenWindow(@NonNull String url) { + return mNavigationDelegate.onNewSession(mSession, url); + } + }); + } + + private final TestApiImpl mTestApiImpl = new TestApiImpl(); + private final TestRunnerApiEngine mApiEngine = new TestRunnerApiEngine(mTestApiImpl); + + private class TestApiImpl implements TestRunnerApiEngine.Api, GeckoSession.ProgressDelegate { + private GeckoResult<WebExtension> mOnPopupLoaded; + // Stores which extension opened the current popup + private WebExtension mCurrentPopupExtension; + + @Override + public void onPageStop(final GeckoSession session, final boolean success) { + if (mOnPopupLoaded != null) { + mOnPopupLoaded.complete(mCurrentPopupExtension); + mOnPopupLoaded = null; + mCurrentPopupExtension = null; + } + session.setProgressDelegate(null); + } + + public void setCurrentPopupExtension(final WebExtension extension) { + mCurrentPopupExtension = extension; + } + + private GeckoResult<Void> clickAction( + final String extensionId, final HashMap<GeckoSession, WebExtension.Action> actions) { + final GeckoSession active = mOwnedSessions.peek(); + + WebExtension.Action action = actions.get(active); + if (action == null) { + // Get default action if there's no specific one + action = actions.get(null); + } + + if (action == null) { + return GeckoResult.fromException( + new RuntimeException("No browser action for " + extensionId)); + } + + action.click(); + return GeckoResult.fromValue(null); + } + + @Override + public GeckoResult<Void> clickPageAction(final String extensionId) { + if (!mExtensions.containsKey(extensionId)) { + return GeckoResult.fromException( + new RuntimeException("Extension not found: " + extensionId)); + } + + return clickAction(extensionId, mExtensions.get(extensionId).pageActions); + } + + @Override + public GeckoResult<Void> clickBrowserAction(final String extensionId) { + if (!mExtensions.containsKey(extensionId)) { + return GeckoResult.fromException( + new RuntimeException("Extension not found: " + extensionId)); + } + + return clickAction(extensionId, mExtensions.get(extensionId).browserActions); + } + + @Override + public GeckoResult<Void> closePopup() { + if (mPopupSession != null) { + mPopupSession.close(); + mPopupSession = null; + } + return null; + } + + @Override + public GeckoResult<Void> awaitExtensionPopup(final String extensionId) { + mOnPopupLoaded = new GeckoResult<>(); + return mOnPopupLoaded.accept( + extension -> { + if (!extension.id.equals(extensionId)) { + throw new IllegalStateException( + "Expecting panel from extension: " + + extensionId + + " found " + + extension.id + + " instead."); + } + }); + } + } + + // Random start timestamp for the BrowsingDataDelegate API. + private static final int CLEAR_DATA_START_TIMESTAMP = 1234; + + private void refreshExtensionList() { + webExtensionController() + .list() + .accept( + extensions -> { + mExtensions.clear(); + for (final WebExtension extension : extensions) { + mExtensions.put(extension.id, new ExtensionWrapper(extension)); + extension.setActionDelegate(mActionDelegate); + extension.setTabDelegate(mTabDelegate); + + extension.setBrowsingDataDelegate( + new WebExtension.BrowsingDataDelegate() { + @Nullable + @Override + public GeckoResult<Settings> onGetSettings() { + final long types = + Type.CACHE + | Type.COOKIES + | Type.HISTORY + | Type.FORM_DATA + | Type.DOWNLOADS; + return GeckoResult.fromValue( + new Settings(CLEAR_DATA_START_TIMESTAMP, types, types)); + } + }); + + for (final GeckoSession session : mOwnedSessions) { + final WebExtension.SessionController controller = + session.getWebExtensionController(); + controller.setActionDelegate(extension, mActionDelegate); + controller.setTabDelegate(extension, mSessionTabDelegate); + } + } + }); + } + + @Override + protected void onDestroy() { + mSession.close(); + super.onDestroy(); + + if (mKillProcessOnDestroy) { + android.os.Process.killProcess(android.os.Process.myPid()); + } + } + + public GeckoView getGeckoView() { + return mView; + } + + public GeckoSession getGeckoSession() { + return mSession; + } + + class TestRunnerExperimentDelegate implements ExperimentDelegate { + @Override + public GeckoResult<JSONObject> onGetExperimentFeature(@NonNull String feature) { + GeckoResult<JSONObject> result = new GeckoResult<>(); + if (feature.equals("test")) { + try { + result.complete(new JSONObject().put("item-one", true).put("item-two", 5)); + } catch (JSONException e) { + result.completeExceptionally(new ExperimentException(ERROR_UNKNOWN)); + } + } else { + result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND)); + } + return result; + } + + @Override + public GeckoResult<Void> onRecordExposureEvent(@NonNull String feature) { + GeckoResult<Void> result = new GeckoResult<>(); + if (feature.equals("test")) { + result.complete(null); + } else { + result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND)); + } + return result; + } + + @Override + public GeckoResult<Void> onRecordExperimentExposureEvent( + @NonNull String feature, @NonNull String slug) { + GeckoResult<Void> result = new GeckoResult<>(); + if (feature.equals("test") && slug.equals("test")) { + result.complete(null); + } else if (!slug.equals("test") && feature.equals("test")) { + result.completeExceptionally(new ExperimentException(ERROR_EXPERIMENT_SLUG_NOT_FOUND)); + } else { + result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND)); + } + return result; + } + + @Override + public GeckoResult<Void> onRecordMalformedConfigurationEvent( + @NonNull String feature, @NonNull String part) { + GeckoResult<Void> result = new GeckoResult<>(); + if (feature.equals("test")) { + result.complete(null); + } else { + result.completeExceptionally(new ExperimentException(ERROR_FEATURE_NOT_FOUND)); + } + return result; + } + } +} diff --git a/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java new file mode 100644 index 0000000000..c6b8e797da --- /dev/null +++ b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerApiEngine.java @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test_runner; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.WebExtension; + +// Receives API calls via mobile/android/modules/test/AppUiTestDelegate.sys.mjs +// and forwards the calls to the Api impl. +// +// This interface allows JS/HTML-based mochitests to invoke test-only logic +// that is implemented by the embedder, e.g. by TestRunnerActivity. +// +// There is no implementation for xpcshell tests because the underlying concepts +// are not available to xpcshell on desktop Firefox. If there is ever a desire +// to create an instance, do so from XpcshellTestRunnerService.java. +// +// The supported messages are documented in AppTestDelegateParent.sys.mjs. +public class TestRunnerApiEngine implements WebExtension.MessageDelegate { + private static final String LOGTAG = "TestRunnerAPI"; + + public interface Api { + GeckoResult<Void> clickBrowserAction(String extensionId); + + GeckoResult<Void> clickPageAction(String extensionId); + + GeckoResult<Void> closePopup(); + + GeckoResult<Void> awaitExtensionPopup(String extensionId); + } + + private final Api mImpl; + + public TestRunnerApiEngine(final Api impl) { + mImpl = impl; + } + + @SuppressWarnings("unchecked") + private GeckoResult<Object> handleMessage(final JSONObject message) throws JSONException { + final String type = message.getString("type"); + + Log.i(LOGTAG, "Test API: " + type); + + if ("clickBrowserAction".equals(type)) { + return (GeckoResult) mImpl.clickBrowserAction(message.getString("extensionId")); + } else if ("clickPageAction".equals(type)) { + return (GeckoResult) mImpl.clickPageAction(message.getString("extensionId")); + } else if ("closeBrowserAction".equals(type)) { + return (GeckoResult) mImpl.closePopup(); + } else if ("closePageAction".equals(type)) { + return (GeckoResult) mImpl.closePopup(); + } else if ("awaitExtensionPanel".equals(type)) { + return (GeckoResult) mImpl.awaitExtensionPopup(message.getString("extensionId")); + } + + return GeckoResult.fromException(new RuntimeException("Unrecognized command " + type)); + } + + @Nullable + @Override + public GeckoResult<Object> onMessage( + @NonNull final String nativeApp, + @NonNull final Object message, + @NonNull final WebExtension.MessageSender sender) { + try { + return handleMessage((JSONObject) message); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + } +} diff --git a/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java new file mode 100644 index 0000000000..529b740c28 --- /dev/null +++ b/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/XpcshellTestRunnerService.java @@ -0,0 +1,144 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +package org.mozilla.geckoview.test_runner; + +import android.app.Service; +import android.content.Intent; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.Nullable; +import java.util.HashMap; +import org.mozilla.geckoview.ContentBlocking; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.GeckoRuntime; +import org.mozilla.geckoview.GeckoRuntimeSettings; +import org.mozilla.geckoview.WebExtension; +import org.mozilla.geckoview.WebExtensionController; + +public class XpcshellTestRunnerService extends Service { + public static final class i0 extends XpcshellTestRunnerService {} + + public static final class i1 extends XpcshellTestRunnerService {} + + public static final class i2 extends XpcshellTestRunnerService {} + + public static final class i3 extends XpcshellTestRunnerService {} + + public static final class i4 extends XpcshellTestRunnerService {} + + public static final class i5 extends XpcshellTestRunnerService {} + + public static final class i6 extends XpcshellTestRunnerService {} + + public static final class i7 extends XpcshellTestRunnerService {} + + public static final class i8 extends XpcshellTestRunnerService {} + + public static final class i9 extends XpcshellTestRunnerService {} + + private static final String LOGTAG = "XpcshellTestRunner"; + static GeckoRuntime sRuntime; + + private HashMap<String, WebExtension> mExtensions = new HashMap<>(); + + private static WebExtensionController webExtensionController() { + return sRuntime.getWebExtensionController(); + } + + @Override + public synchronized int onStartCommand(Intent intent, int flags, int startId) { + if (sRuntime != null) { + // We don't support restarting GeckoRuntime + throw new RuntimeException("Cannot start more than once"); + } + + final Bundle extras = intent.getExtras(); + for (final String key : extras.keySet()) { + Log.i(LOGTAG, "Got extras " + key + "=" + extras.get(key)); + } + + final ContentBlocking.SafeBrowsingProvider googleLegacy = + ContentBlocking.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 ContentBlocking.SafeBrowsingProvider google = + ContentBlocking.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() + .arguments(new String[] {"-xpcshell"}) + .extras(extras) + .consoleOutput(true) + .contentBlocking( + new ContentBlocking.Settings.Builder() + .safeBrowsingProviders(google, googleLegacy) + .build()) + .build(); + + sRuntime = GeckoRuntime.create(this, runtimeSettings); + + webExtensionController() + .setDebuggerDelegate( + new WebExtensionController.DebuggerDelegate() { + @Override + public void onExtensionListUpdated() { + refreshExtensionList(); + } + }); + + webExtensionController() + .setAddonManagerDelegate(new WebExtensionController.AddonManagerDelegate() {}); + + sRuntime.setDelegate( + () -> { + stopSelf(); + System.exit(0); + }); + + return Service.START_NOT_STICKY; + } + + // Random start timestamp for the BrowsingDataDelegate API. + private static final int CLEAR_DATA_START_TIMESTAMP = 1234; + + private void refreshExtensionList() { + webExtensionController() + .list() + .accept( + extensions -> { + mExtensions.clear(); + for (final WebExtension extension : extensions) { + mExtensions.put(extension.id, extension); + + extension.setBrowsingDataDelegate( + new WebExtension.BrowsingDataDelegate() { + @Nullable + @Override + public GeckoResult<Settings> onGetSettings() { + final long types = + Type.CACHE + | Type.COOKIES + | Type.HISTORY + | Type.FORM_DATA + | Type.DOWNLOADS; + return GeckoResult.fromValue( + new Settings(CLEAR_DATA_START_TIMESTAMP, types, types)); + } + }); + } + }); + } + + @Override + public synchronized IBinder onBind(Intent intent) { + return null; + } +} diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors.png Binary files differnew file mode 100644 index 0000000000..c9a2788e53 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors.png diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.png Binary files differnew file mode 100644 index 0000000000..da4eba73b3 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br.png diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.png Binary files differnew file mode 100644 index 0000000000..c402e73bb6 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_br_scaled.png diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.png Binary files differnew file mode 100644 index 0000000000..eda5c5ebf0 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl.png diff --git a/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.png b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.png Binary files differnew file mode 100644 index 0000000000..0ce5a631c4 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/drawable-nodpi/colors_tl_scaled.png diff --git a/mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml b/mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..cd75f1434a --- /dev/null +++ b/mobile/android/test_runner/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,77 @@ +<?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/. --> +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + android:height="108dp" + android:width="108dp" + android:viewportHeight="108" + android:viewportWidth="108"> + <path android:fillColor="#26A69A" + android:pathData="M0,0h108v108h-108z"/> + <path android:fillColor="#00000000" android:pathData="M9,0L9,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,0L19,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M29,0L29,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M39,0L39,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M49,0L49,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M59,0L59,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M69,0L69,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M79,0L79,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M89,0L89,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M99,0L99,108" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,9L108,9" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,19L108,19" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,29L108,29" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,39L108,39" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,49L108,49" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,59L108,59" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,69L108,69" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,79L108,79" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,89L108,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M0,99L108,99" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,29L89,29" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,39L89,39" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,49L89,49" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,59L89,59" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,69L89,69" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M19,79L89,79" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M29,19L29,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M39,19L39,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M49,19L49,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M59,19L59,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M69,19L69,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> + <path android:fillColor="#00000000" android:pathData="M79,19L79,89" + android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> +</vector> diff --git a/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..a2f5908281 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..1b52399808 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..ff10afd6e1 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..115a4c768a --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..dcd3cd8083 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..459ca609d3 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..8ca12fe024 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..8e19b410a1 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.png Binary files differnew file mode 100644 index 0000000000..b824ebdd48 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png Binary files differnew file mode 100644 index 0000000000..4c19a13c23 --- /dev/null +++ b/mobile/android/test_runner/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/mobile/android/test_runner/src/main/res/values/colors.xml b/mobile/android/test_runner/src/main/res/values/colors.xml new file mode 100644 index 0000000000..3a96673022 --- /dev/null +++ b/mobile/android/test_runner/src/main/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/test_runner/src/main/res/values/strings.xml b/mobile/android/test_runner/src/main/res/values/strings.xml new file mode 100644 index 0000000000..7831a536eb --- /dev/null +++ b/mobile/android/test_runner/src/main/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/test_runner/src/main/res/values/styles.xml b/mobile/android/test_runner/src/main/res/values/styles.xml new file mode 100644 index 0000000000..60abe4bf63 --- /dev/null +++ b/mobile/android/test_runner/src/main/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/themes/geckoview/config.css b/mobile/android/themes/geckoview/config.css new file mode 100644 index 0000000000..f1689d5865 --- /dev/null +++ b/mobile/android/themes/geckoview/config.css @@ -0,0 +1,379 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + --background-color: #fff; + --text-color: #0c0c0d; + --border-color: #e1e1e2; + + --toolbar-background-color: #f9f9fa; + --searchbar-background-color: #ededf0; + --searchbar-focused-background-color: #fff; + + --deemphasized-border-color: rgba(0,0,0,0.05); + --deemphasized-text-color: rgba(0,0,0,0.5); +} + +@media (prefers-color-scheme: dark) { + :root { + --background-color: #292833; + --text-color: #f9f9fa; + --border-color: rgba(255,255,255,0.15); + + --toolbar-background-color: #1c1b22; + --searchbar-background-color: #3f3e46; + --searchbar-focused-background-color: #4c4a54; + + --deemphasized-border-color: rgba(249,249,250,0.05); + --deemphasized-text-color: rgba(249,249,250,0.5); + } +} + +html, +body { + margin: 0; + padding: 0; + user-select: none; + font-family: sans-serif; + -moz-text-size-adjust: none; + background-color: var(--background-color); + color: var(--text-color); +} + +.toolbar { + width: 100%; + min-height: 3em; + display: flow-root; + position: sticky; + top: 0; + left: 0; + z-index: 10; + background-color: var(--toolbar-background-color); + font-weight: bold; + box-shadow: 0 2px 7px rgba(0,0,0,0.25); +} + +.toolbar-container { + max-width: 40em; + margin-inline: auto; +} + +#filter-container { + margin: 0.375em; + height: 2em; + background-color: var(--searchbar-background-color); + border-radius: 0.25em; + border: 1px solid transparent; + overflow: hidden; + display: flex; + float: inline-end; +} + +#filter-container:focus-within { + background-color: var(--searchbar-focused-background-color); + box-shadow: 0 1px 6px rgba(0,0,0,.1); +} + +#filter-input { + border: none; + background: none; + color: inherit; + flex-grow: 1; + height: 100%; + box-sizing: border-box; + opacity: 0.75; +} + +#new-pref-toggle-button { + background-image: url("chrome://geckoview/skin/images/add.svg"); + background-size: 1.25em; + background-position: center; + background-repeat: no-repeat; + height: 3em; + width: 3em; + outline: none; + float: inline-start; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; +} + +#filter-search-button, +#filter-input-clear-button { + background-size: 1em; + background-position: center; + background-repeat: no-repeat; + height: 2em; + width: 2em; + outline: none; + display: inline-block; + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; +} + +#filter-search-button { + background-image: url("chrome://geckoview/skin/images/search.svg"); +} + +#filter-search-button:dir(rtl) { + scale: -1 1; +} + +#filter-input-clear-button { + background-image: url("chrome://geckoview/skin/images/search-clear.svg"); +} + +#filter-input:placeholder-shown + #filter-input-clear-button { + visibility: hidden; /* display: none; causes the input size to change */ +} + +.toolbar-item { + display: inline-block; + height: 3em; + min-width: 3em; + float: right; +} + +#content { + position: relative; + margin: 0 auto; + padding-inline: 0; + min-height: 100%; + max-width: 40em; +} + +#prefs-container { + list-style: none; + min-height: 100%; + width: 100%; + margin: 0; + padding: 0; + box-sizing: border-box; + overflow-x: hidden; +} + +#prefs-container li { + border-bottom: 1px solid var(--border-color); + cursor: pointer; +} + +#new-pref-container { + width: 100%; + margin: 0; + background-color: var(--background-color); + box-shadow: 0 5px 18px rgba(0,0,0,.2); + box-sizing: border-box; + overflow-x: hidden; + max-width: 40em; + max-height: 100%; + position: fixed; + top: 3em; + left: auto; + display: none; + z-index: 5; +} + +#new-pref-container input, +#new-pref-container select { + border: none; + background: none; +} + +#new-pref-container.show { + display: block; +} + +#new-pref-line-boolean, +#new-pref-value-string, +#new-pref-value-int { + display: none; +} +#new-pref-item[typestyle="boolean"] #new-pref-line-boolean, +#new-pref-item[typestyle="string"] #new-pref-value-string, +#new-pref-item[typestyle="int"] #new-pref-value-int { + display: flex; +} +#new-pref-item[typestyle="boolean"] #new-pref-line-input { + border-top: none; +} + +.pref-name, +.pref-value { + padding: 15px 10px; + text-align: match-parent; + text-overflow: ellipsis; + overflow: hidden; + background: none; + color: inherit; + direction: ltr; +} + +.pref-value { + flex: 1 1 auto; + border: none; + unicode-bidi: plaintext; +} + +.pref-name[locked] { + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + fill-opacity: 0.8; + background-image: url("chrome://geckoview/skin/images/lock.svg"); + background-repeat: no-repeat; + background-position-y: center; + background-size: 1em; +} + +:dir(ltr) > .pref-name[locked] { + background-position-x: right 10px; + padding-right: 30px; +} + +:dir(rtl) > .pref-name[locked] { + background-position-x: 10px; + padding-left: 30px; +} + +#new-pref-name { + width: 30em; +} + +#new-pref-type { + appearance: none; + color: inherit; + border-inline-start: 1px solid var(--deemphasized-border-color) !important; + text-align: center; + width: 9em; + padding-inline: 8px; +} + +.pref-item-line { + border-top: 1px solid var(--deemphasized-border-color); + color: var(--deemphasized-text-color); + display: flex; +} + +#new-pref-value-boolean { + flex: 1 1 auto; +} + +#new-pref-container .pref-button.toggle { + display: flex; + opacity: 1; + flex: 0 1 auto; + float: right; +} + +#new-pref-container .pref-button.cancel, +#new-pref-container .pref-button.create { + display: inline-block; + opacity: 1; + flex: 1 1 auto; +} + +.pref-item-line { + pointer-events: none; +} + +#new-pref-container .pref-item-line, +.pref-item.selected .pref-item-line, +.pref-item:not(.selected) .pref-button.reset { + pointer-events: auto; +} + +#new-pref-container .pref-button.create[disabled] { + opacity: 0.5; +} + +.pref-item.selected { + background-color: hsla(0,0%,60%,.2); +} + +.pref-button { + display: inline-flex; + box-sizing: border-box; + align-items: center; + text-align: center; + padding: 10px 1em; + border-inline-start: 1px solid var(--deemphasized-border-color); + opacity: 0; + transition-property: opacity; + transition-duration: 500ms; +} + +.pref-item.selected .pref-item-line .pref-button { + opacity: 1; +} + +.pref-item:not(.selected) .pref-item-line .pref-button:not(.reset) { + display: none; +} + +.pref-item:not(.selected) .pref-button.reset { + opacity: 1; +} + +.pref-button:active, +#new-pref-type:active { + background-color: hsla(0,0%,60%,.4); +} + +.pref-button[disabled] { + display: none; +} + +.pref-button.up, +.pref-button.down { + -moz-context-properties: fill, fill-opacity; + fill: var(--text-color); + fill-opacity: 0.8; + background-size: 1em; + background-position: center; + background-repeat: no-repeat; +} + +.pref-button.up { + background-image: url("chrome://geckoview/skin/images/arrow-up.svg"); +} + +.pref-button.down { + background-image: url("chrome://geckoview/skin/images/arrow-down.svg"); +} + +#prefs-shield { + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.45); + position: fixed; + top: 0; + left: 0; + opacity: 0; + transition-property: opacity; + transition-duration: 500ms; + display: none; +} + +#prefs-shield[shown] { + display: block; + opacity: 1; +} + +#loading-container::before { + content: ""; + display: block; + width: 1.25em; + height: 1.25em; + border: 0.15em solid currentColor; + margin: 1em auto; + border-right-color: transparent; + border-radius: 100%; + opacity: 0.8; + animation: 1.1s linear infinite spin; +} + +@keyframes spin { + from { transform: none; } + to { transform: rotate(360deg); } +} diff --git a/mobile/android/themes/geckoview/images/add.svg b/mobile/android/themes/geckoview/images/add.svg new file mode 100644 index 0000000000..7796815d9b --- /dev/null +++ b/mobile/android/themes/geckoview/images/add.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M14 7H9V2a1 1 0 0 0-2 0v5H2a1 1 0 1 0 0 2h5v5a1 1 0 0 0 2 0V9h5a1 1 0 0 0 0-2z"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/arrow-down.svg b/mobile/android/themes/geckoview/images/arrow-down.svg new file mode 100644 index 0000000000..05279202d2 --- /dev/null +++ b/mobile/android/themes/geckoview/images/arrow-down.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M8 12a1 1 0 0 1-.707-.293l-5-5a1 1 0 0 1 1.414-1.414L8 9.586l4.293-4.293a1 1 0 0 1 1.414 1.414l-5 5A1 1 0 0 1 8 12z"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/arrow-up.svg b/mobile/android/themes/geckoview/images/arrow-up.svg new file mode 100644 index 0000000000..42eabd2494 --- /dev/null +++ b/mobile/android/themes/geckoview/images/arrow-up.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M8 4a1 1 0 00-.707.293l-5 5a1 1 0 001.414 1.414L8 6.414l4.293 4.293a1 1 0 001.414-1.414l-5-5A1 1 0 008 4z"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/dropmarker-right.svg b/mobile/android/themes/geckoview/images/dropmarker-right.svg new file mode 100644 index 0000000000..411417f88e --- /dev/null +++ b/mobile/android/themes/geckoview/images/dropmarker-right.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg width="7px" height="10px" xmlns="http://www.w3.org/2000/svg"> + <polyline points="1 1 6 5 1 9" stroke="#414141" stroke-width="2" stroke-linecap="round" fill="none" stroke-linejoin="round"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/dropmarker.svg b/mobile/android/themes/geckoview/images/dropmarker.svg new file mode 100644 index 0000000000..1f52a0bd50 --- /dev/null +++ b/mobile/android/themes/geckoview/images/dropmarker.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg width="10px" height="7px" xmlns="http://www.w3.org/2000/svg"> + <polyline points="1 1 5 6 9 1" stroke="#414141" stroke-width="2" stroke-linecap="round" fill="none" stroke-linejoin="round"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/lock.svg b/mobile/android/themes/geckoview/images/lock.svg new file mode 100644 index 0000000000..e0854cc15b --- /dev/null +++ b/mobile/android/themes/geckoview/images/lock.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M12,7 L13,7 C13.5522847,7 14,7.44771525 14,8 L14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 L2,8 C2,7.44771525 2.44771525,7 3,7 L4,7 L4,5.00032973 C4,2.79202307 5.79321704,1 8,1 C10.2075938,1 12,2.79481161 12,5.00032973 L12,7 Z M10,7 L10,5.00032973 C10,3.89878113 9.10242341,3 8,3 C6.89748845,3 6,3.89689088 6,5.00032973 L6,7 L10,7 Z"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/search-clear.svg b/mobile/android/themes/geckoview/images/search-clear.svg new file mode 100644 index 0000000000..17d2eb692c --- /dev/null +++ b/mobile/android/themes/geckoview/images/search-clear.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M6.586 8l-2.293 2.293a1 1 0 0 0 1.414 1.414L8 9.414l2.293 2.293a1 1 0 0 0 1.414-1.414L9.414 8l2.293-2.293a1 1 0 1 0-1.414-1.414L8 6.586 5.707 4.293a1 1 0 0 0-1.414 1.414L6.586 8zM8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0z"/> +</svg> diff --git a/mobile/android/themes/geckoview/images/search.svg b/mobile/android/themes/geckoview/images/search.svg new file mode 100644 index 0000000000..840ca07bd2 --- /dev/null +++ b/mobile/android/themes/geckoview/images/search.svg @@ -0,0 +1,6 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity"> + <path d="M15.707 14.293l-4.822-4.822a6.019 6.019 0 1 0-1.414 1.414l4.822 4.822a1 1 0 0 0 1.414-1.414zM6 10a4 4 0 1 1 4-4 4 4 0 0 1-4 4z"/> +</svg> diff --git a/mobile/android/themes/geckoview/jar.mn b/mobile/android/themes/geckoview/jar.mn new file mode 100644 index 0000000000..26e2b66cd4 --- /dev/null +++ b/mobile/android/themes/geckoview/jar.mn @@ -0,0 +1,17 @@ +#filter substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +geckoview.jar: + +% skin geckoview classic/1.0 %skin/ + skin/config.css (config.css) + skin/images/add.svg (images/add.svg) + skin/images/arrow-down.svg (images/arrow-down.svg) + skin/images/arrow-up.svg (images/arrow-up.svg) + skin/images/dropmarker-right.svg (images/dropmarker-right.svg) + skin/images/dropmarker.svg (images/dropmarker.svg) + skin/images/lock.svg (images/lock.svg) + skin/images/search-clear.svg (images/search-clear.svg) + skin/images/search.svg (images/search.svg) diff --git a/mobile/android/themes/geckoview/moz.build b/mobile/android/themes/geckoview/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/themes/geckoview/moz.build @@ -0,0 +1,7 @@ +# -*- 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/locales/Makefile.in b/mobile/locales/Makefile.in new file mode 100644 index 0000000000..9d5cf767d1 --- /dev/null +++ b/mobile/locales/Makefile.in @@ -0,0 +1,30 @@ +# -*- makefile -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +include $(topsrcdir)/config/config.mk + +USE_AUTOTARGETS_MK=1 +include $(topsrcdir)/config/makefiles/makeutils.mk + +$(call errorIfEmpty,MOZ_BRANDING_DIRECTORY) +SUBMAKEFILES += \ + $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/Makefile \ + $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales/Makefile \ + $(NULL) + +include $(topsrcdir)/config/rules.mk + +l10n-%: AB_CD=$* +l10n-%: + $(NSINSTALL) -D $(DIST)/install + @$(MAKE) -C $(DEPTH)/toolkit/locales l10n-$* + @$(MAKE) l10n AB_CD=$* XPI_NAME=locale-$* PREF_DIR=defaults/pref + @$(MAKE) -C $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales AB_CD=$* XPI_NAME=locale-$* + +# Tailored target to just add the chrome processing for multi-locale builds +chrome-%: AB_CD=$* +chrome-%: + @$(MAKE) chrome AB_CD=$* + @$(MAKE) -C $(DEPTH)/$(MOZ_BRANDING_DIRECTORY)/locales chrome AB_CD=$* diff --git a/mobile/locales/filter.py b/mobile/locales/filter.py new file mode 100644 index 0000000000..38b4bbb50d --- /dev/null +++ b/mobile/locales/filter.py @@ -0,0 +1,68 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.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 routine controls which localizable files and entries are +reported and l10n-merged. +This needs to stay in sync with the copy in mobile/android/locales. +""" + + +def test(mod, path, entity=None): + import re + + # ignore anything but mobile, which is our local repo checkout name + if mod not in ("dom", "toolkit", "mobile", "mobile/android"): + return "ignore" + + if mod == "toolkit": + # keep this file list in sync with jar.mn + if path in ( + "chrome/global/commonDialogs.properties", + "chrome/global/intl.properties", + "chrome/global/intl.css", + ): + return "error" + if re.match(r"crashreporter/[^/]*.ftl", path): + # error on crashreporter/*.ftl + return "error" + + if re.match(r"toolkit/about/[^/]*About.ftl", path): + # error on toolkit/about/*About.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Mozilla.ftl", path): + # error on toolkit/about/*Mozilla.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Rights.ftl", path): + # error on toolkit/about/*Rights.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Compat.ftl", path): + # error on toolkit/about/*Compat.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Support.ftl", path): + # error on toolkit/about/*Support.ftl + return "error" + if re.match(r"toolkit/about/[^/]*Webrtc.ftl", path): + # error on toolkit/about/*Webrtc.ftl + return "error" + return "ignore" + + if mod == "dom": + # keep this file list in sync with jar.mn + if path in ( + "chrome/accessibility/AccessFu.properties", + "chrome/dom/dom.properties", + ): + return "error" + return "ignore" + + if mod not in ("mobile", "mobile/android"): + # we only have exceptions for mobile* + return "error" + if mod == "mobile/android": + if entity is None: + if re.match(r"mobile-l10n.js", path): + return "ignore" + return "error" + + return "error" diff --git a/mobile/locales/l10n-changesets.json b/mobile/locales/l10n-changesets.json new file mode 100644 index 0000000000..b03f926abe --- /dev/null +++ b/mobile/locales/l10n-changesets.json @@ -0,0 +1,779 @@ +{ + "ach": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "an": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ar": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ast": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "az": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "be": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "bg": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "bn": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "br": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "bs": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ca": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "cak": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "cs": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "cy": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "da": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "de": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "dsb": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "el": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "en-CA": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "en-GB": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "eo": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "es-AR": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "es-CL": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "es-ES": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "es-MX": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "et": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "eu": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "fa": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ff": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "fi": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "fr": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "fy-NL": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ga-IE": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "gd": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "gl": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "gn": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "gu-IN": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "he": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "hi-IN": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "hr": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "hsb": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "hu": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "hy-AM": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ia": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "id": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "is": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "it": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ja": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ka": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "kab": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "kk": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "km": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "kn": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ko": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "lij": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "lo": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "lt": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ltg": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "lv": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "meh": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "mix": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "ml": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "mr": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ms": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "my": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "nb-NO": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ne-NP": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "nl": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "nn-NO": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "oc": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "pa-IN": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "pl": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "pt-BR": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "pt-PT": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "rm": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ro": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ru": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "sk": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "sl": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "son": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "sq": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "sr": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "sv-SE": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ta": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "te": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "th": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "tl": { + "platforms": [ + "android", + "android-arm" + ], + "revision": "default" + }, + "tr": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "trs": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "uk": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ur": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "uz": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "vi": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "wo": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "xh": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "zam": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "zh-CN": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "zh-TW": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + } +} diff --git a/mobile/locales/l10n-onchange-changesets.json b/mobile/locales/l10n-onchange-changesets.json new file mode 100644 index 0000000000..ad2dc0148f --- /dev/null +++ b/mobile/locales/l10n-onchange-changesets.json @@ -0,0 +1,34 @@ +{ + "en-CA": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "he": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "it": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + }, + "ja": { + "platforms": [ + "android", + "android-arm", + "android-multilocale" + ], + "revision": "default" + } +} diff --git a/mobile/locales/l10n.ini b/mobile/locales/l10n.ini new file mode 100644 index 0000000000..7d5d5056f1 --- /dev/null +++ b/mobile/locales/l10n.ini @@ -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/. + +# Control which directories and modules are part of mobile +# on the l10n dashboard. +# Changes here should be triggered by changes in +# mobile/android/locales/l10n.ini. + +[general] +depth = ../.. +all = mobile/android/locales/all-locales + +[compare] +dirs = mobile mobile/android + +[includes] +toolkit = toolkit/locales/l10n.ini diff --git a/mobile/locales/moz.build b/mobile/locales/moz.build new file mode 100644 index 0000000000..2d4f67a08b --- /dev/null +++ b/mobile/locales/moz.build @@ -0,0 +1,8 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") |