diff options
Diffstat (limited to 'browser/components/ion/content')
-rw-r--r-- | browser/components/ion/content/ion.css | 180 | ||||
-rw-r--r-- | browser/components/ion/content/ion.ftl | 83 | ||||
-rw-r--r-- | browser/components/ion/content/ion.html | 206 | ||||
-rw-r--r-- | browser/components/ion/content/ion.js | 791 |
4 files changed, 1260 insertions, 0 deletions
diff --git a/browser/components/ion/content/ion.css b/browser/components/ion/content/ion.css new file mode 100644 index 0000000000..e69828a2a6 --- /dev/null +++ b/browser/components/ion/content/ion.css @@ -0,0 +1,180 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +body { + margin: 40px auto; + max-width: 664px; + font-family: 'Segoe UI', 'Ubuntu', 'Helvetica Neue', sans-serif; +} + +@media (max-width: 830px) { + body { + margin-inline-start: 16px; + margin-inline-end: 16px; + } +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-end: 24px; +} + +@media (max-width: 600px) { + header { + display: block; + } +} + +#locale-notification { + display: none; + text-align: center; +} + +#summary, #details, #data { + margin-block-end: 24px; +} + +#summary, #summary, #details p, #data p { + font-size: 15px; + line-height: 22px; +} + +#data ul { + padding-inline-start: 15px; +} + +#data ul li { + margin-block-end: 8px; +} + +#data a, #data strong { + font-weight: 600; +} + +h2 { + font-size: 17px; + font-weight: 600; +} + +details > summary { + user-select: none; + padding: 2px 6px; + width: 18em; + cursor: pointer; + outline: none; + font-size: 17px; + font-weight: 600; +} + +#report-title, #title { + margin-block-end: 0; +} + +@media (max-width: 600px) { + #title { + margin-block-end: 8px; + } + #enrollment-button { + margin-inline-start: 0; + } +} + +#available-studies { + font-weight: 600; +} + +.card { + display: flex; + flex-wrap: wrap; +} + +.card-icon { + width: 32px; + height: 32px; + flex-shrink: 0; +} + +.card-body { + flex-grow: 1; + margin-inline-start: 16px; +} + +.card-name { + margin: 0; + font-size: 16px; + font-weight: 600; + line-height: 1; +} + +.card-author { + margin: 0; + font-size: 14px; + font-weight: 400; +} + +.card-actions { + align-self: center; + flex-shrink: 0; + min-width: 120px; +} + +.join-button { + max-width: 200px; + margin: 0; + margin-inline-end: 16px; +} + +.card-description { + font-size: 14px; + font-weight: normal; + width: 100%; +} + +.card-data-collected { + font-size: 14px; + font-weight: normal; +} + +*[hidden] { + display: none !important; +} + +#ion-icon { + -moz-context-properties: fill; + fill: currentColor; +} + +.modal { + max-height: 90%; + max-width: min(700px, 80%); + overflow: auto; + background: var(--in-content-page-background); + color: var(--in-content-page-color); + border: 1px solid transparent; + border-radius: 3.5px; + box-shadow: 0 2px 6px 0 rgba(0,0,0,0.3); + padding: 8px 16px 0; +} + +.modal > footer { + display: flex; + justify-content: flex-end; + padding-bottom: 16px; +} + +.modal::backdrop { + background-color: rgba(0,0,0,.5); +} + +h1 > p { margin-bottom: 0} + +.consent-list { + padding: 16px; + padding-inline-start: 32px; + margin-block-end: 16px; + border: 1px solid var(--in-content-box-border-color); + background: var(--in-content-box-background); +} diff --git a/browser/components/ion/content/ion.ftl b/browser/components/ion/content/ion.ftl new file mode 100644 index 0000000000..6762eca7b1 --- /dev/null +++ b/browser/components/ion/content/ion.ftl @@ -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/. + +### This file is not in a locales directory to prevent it from +### being translated as the feature is still in heavy development +### and strings are likely to change often. + +-ion-brand-short-name = Ion +ion = { -ion-brand-short-name } +ion-document-title = Put your data to work for a better internet +ion-summary = { -ion-brand-short-name } puts your data to work to address some of today’s most pressing technology concerns, like misinformation, data privacy, and ethical AI. The data you agree to share with Mozilla (the makers of Firefox) helps create tools for better internet transparency and design products that give control back to the people who use them. As an { -ion-brand-short-name } participant, you’ll also have the option to contribute your data to studies sponsored by research institutions and other organizations. +ion-study-prompt = Join to enroll +ion-join-study = Join Study +ion-leave-study = Leave Study +ion-enrollment-button = Join { -ion-brand-short-name } +ion-unenrollment-button = Leave { -ion-brand-short-name } +ion-current-studies = Current Studies +ion-no-current-studies = No current studies, please check back later. +ion-end-study = End Study +ion-ended-study = Study Ended +ion-accept-participate = Accept and Participate +ion-accept-leave = Accept and Leave +ion-cancel = Cancel +ion-consent-notice = { -ion-brand-short-name } Privacy Notice +ion-consent-study-notice = { -ion-brand-short-name } Study Privacy Consent Notice + +leave-ion-consent-title = You’re leaving? +leave-ion-consent-bullet-thanks = Thank you for participating. +leave-ion-consent-bullet-manage = We’re sorry to see you go. Once you leave { -ion-brand-short-name }, we will stop all data collection and unenroll you from any active studies. We will also delete the data you’ve contributed, where applicable. <a data-l10n-name="privacy-policy">Learn more about managing your { -ion-brand-short-name } data</a>. + +leave-study-consent-title = Withdraw your contribution to this study? +leave-study-consent-bullet-manage = If you unenroll while this study is still active, { -ion-brand-short-name } will rescind your contribution to this research effort and will delete any data you’ve already contributed. <a data-l10n-name="privacy-policy">Learn more about managing your { -ion-brand-short-name } data</a>. + +ion-consent-study-title = Leaving Study +ion-consent-study-join = Accept and Join Study +ion-consent-study-leave = Accept and Leave Study + +ion-program-consent-intro = When you enroll in { -ion-brand-short-name }, you are sharing personal information. Keeping that information safe is important to us and to the integrity of { -ion-brand-short-name }. Here is how we safeguard your data and protect your identity. + +ion-program-study-intro = When you enroll in this study, you are sharing personal information. Keeping that information safe is important to us and to the integrity of { -ion-brand-short-name }. Here is how we safeguard your data and protect your identity. + + +ion-works-title = How it works: +ion-works-bullet-get-started-title = Get started. +ion-works-bullet-get-started-content = Select the { ion-enrollment-button } button, review and agree to our Privacy Notice, and answer a few (optional) demographic questions. Note that { -ion-brand-short-name } is currently open to participants in the US who are 19 or older. +ion-works-bullet-enroll-title = Enroll in studies. +ion-works-bullet-enroll-content = Share your data with studies run by { -vendor-short-name } and our { -ion-brand-short-name } research partners. You’ll have the opportunity to learn about a study’s goals, the data it collects, and its research team before you enroll. +ion-works-bullet-control-title = Stay in control. +ion-works-bullet-control-content = The { -ion-brand-short-name } icon will appear on the { -brand-product-name } toolbar. Select the icon any time you want to return to this page to update your settings, enroll in a study, or leave a study or the { -ion-brand-short-name } program. + +ion-your-data-title = Your data: why it matters and how we protect it +ion-your-data-summary = { -ion-brand-short-name } puts your data to work for a better internet. Our goal is to better understand topics like internet usage, online privacy, algorithmic bias, discrimination, and misinformation. This in turn can lead to new products that fundamentally change the tech landscape and hand more power and control back to users. + +ion-your-data-bullet-know = You’ll know the information we plan to collect before we collect it. We publish our data collection documentation, so you can confirm this for yourself. Read each privacy notice for detailed information. +ion-your-data-bullet-lengths = We prioritize securing your data and protecting your privacy. +ion-your-data-bullet-leave = You can leave the { -ion-brand-short-name } program at any time, and we’ll stop collecting data when you do. +ion-your-data-learn-more = Learn more about <a data-l10n-name="privacy-policy">managing the data you share</a> with { -ion-brand-short-name }. + +ion-us-only = Sorry, { -ion-brand-short-name } is currently only open to participants in the US. + +ion-enroll-effective-date = Effective September 1, 2020 +ion-enroll-summary = { -ion-brand-short-name } is an experimental initiative led by Mozilla to better understand how our users use and navigate the internet. { -ion-brand-short-name } is available to Firefox users in the United States who are 19 or older. +ion-enroll-demographic = When you join { -ion-brand-short-name }, we’ll ask you to provide optional demographic data. We’ll also collect basic technical and interaction data as long as you’re participating in { -ion-brand-short-name }. Once you’ve enrolled, you’ll have the opportunity to join available studies—each study will have a specific research purpose and unique privacy notice for you to review before you join it. +ion-enroll-privacy-notice = In this Privacy Notice, we detail what data the { -ion-brand-short-name } program collects and discloses, and why. Read each study’s privacy notice for information about how data is collected and handled in that particular study. We also adhere to the <a data-l10n-name="privacy-notice">Mozilla Privacy Policy</a> for how we receive, handle, and share information. +ion-enroll-data-disclosure = To see a full list of the data we collect, click <a data-l10n-name="privacy-policy">here</a>. +ion-enroll-what-we-collect = What Information We Collect: +ion-enroll-collect-demographic = <strong>Demographic data:</strong> We collect optional, self-reported demographic data from { -ion-brand-short-name } participants, including their age, gender, race/ethnicity, education level, household income, and zip code. +ion-enroll-technical-data = <strong>Technical data:</strong> We collect basic information about your device’s operating system. When Firefox sends data to us, your IP address is temporarily collected as part of our server logs. +ion-enroll-interaction-data = <strong>Interaction data:</strong> We collect data about your interactions with Firefox, like number and type of installed Firefox Add-ons and your active browsing session duration. +ion-enroll-location-data = <strong>Location data:</strong> We will use your IP address to approximate your country location, in addition to collecting your self-reported zip code (if you provide it). +ion-enroll-how-we-use = How We Use Your Information: +ion-enroll-r-and-d = We use the information we collect for for <strong>research and development</strong>, including: +ion-enroll-bullet-criteria = To determine which participants meet the criteria to be available to participate in particular research studies +ion-enroll-bullet-representative = To ensure our data sets are representative of the many users of Firefox +ion-enroll-bullet-improve-existing = To improve our existing products and services +ion-enroll-bullet-create = To create and develop new products +ion-enroll-who-we-disclose-to = Who We May Disclose Information To: +ion-enroll-who-we-disclose-bullet-gcp = <strong>Google Cloud Platform (GCP):</strong> We use GCP as our cloud-storage service. Mozilla has contracted with GCP requiring them to handle the data in ways that are approved by us. +ion-enroll-who-we-disclose-bullet-third-party = <strong>Third-party researchers:</strong> As part of being part of the { -ion-brand-short-name } program, we will offer you the ability to join studies. If necessary for the study, we may ask you to share all or some of the data collected under this Privacy Notice with the third party researcher(s) administering a study. Mozilla will contractually obligate the third party researchers to ensure that your data is handled in ways that are approved by us. +ion-enroll-who-we-disclose-bullet-public = <strong>General public:</strong> To advance our <a data-l10n-name="mozilla-manifesto">mission of being open</a>, we may release data sets to the general public. When we do so, we will aggregate the data and remove identifying information, so the data won’t reveal the behaviors or characteristics of individual users. +ion-enroll-data-management = Data Management: +ion-enroll-data-management-learn-more = You can learn more about managing your { -ion-brand-short-name } and individual study data <a data-l10n-name="privacy-policy">here</a>. If you have any other questions regarding our privacy practices, please contact us at <a data-l10n-name="compliance-email">compliance@mozilla.com</a>. diff --git a/browser/components/ion/content/ion.html b/browser/components/ion/content/ion.html new file mode 100644 index 0000000000..5cf05e8a36 --- /dev/null +++ b/browser/components/ion/content/ion.html @@ -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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8" /> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome: blob:; img-src https:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <link rel="localization" href="toolkit/branding/brandings.ftl" /> + <link rel="localization" href="branding/brand.ftl" /> + <!-- Temporary "en-US"-only l10n strings --> + <link rel="localization" href="preview/ion.ftl" /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="stylesheet" href="chrome://browser/content/ion.css" /> + <script src="chrome://browser/content/ion.js"></script> + <link rel="icon" href="chrome://browser/skin/ion.svg" /> + <title data-l10n-id="ion"></title> + </head> + + <body> + <div id="locale-notification" data-l10n-id="ion-us-only"></div> + <div id="report-content"> + <header> + <h1 id="title" data-l10n-id="ion-document-title"></h1> + <button id="enrollment-button" class="primary"></button> + </header> + <div id="summary" data-l10n-id="ion-summary"></div> + <details id="details" open> + <summary data-l10n-id="ion-works-title"></summary> + <p> + <strong data-l10n-id="ion-works-bullet-get-started-title"></strong> + <span data-l10n-id="ion-works-bullet-get-started-content"></span> + </p> + <p> + <strong data-l10n-id="ion-works-bullet-enroll-title"></strong> + <span data-l10n-id="ion-works-bullet-enroll-content"></span> + </p> + <p> + <strong data-l10n-id="ion-works-bullet-control-title"></strong> + <span data-l10n-id="ion-works-bullet-control-content"></span> + </p> + </details> + <details id="data" open> + <summary data-l10n-id="ion-your-data-title"></summary> + <p data-l10n-id="ion-your-data-summary"></p> + <ul> + <li data-l10n-id="ion-your-data-bullet-know"></li> + <li data-l10n-id="ion-your-data-bullet-lengths"></li> + <li data-l10n-id="ion-your-data-bullet-leave"></li> + </ul> + </details> + <p data-l10n-id="ion-your-data-learn-more"> + <a + data-l10n-name="privacy-policy" + class="privacy-policy" + href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" + target="_blank" + ></a> + </p> + <h2 id="header-available-studies"></h2> + <div id="available-studies"></div> + <dialog id="join-ion-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="ion-consent-notice"></h3> + <p data-l10n-id="ion-program-consent-intro"></p> + <ul id="join-ion-consent" class="consent-list"> + <p data-l10n-id="ion-enroll-effective-date"></p> + <p data-l10n-id="ion-enroll-summary"></p> + <p data-l10n-id="ion-enroll-demographic"></p> + <p data-l10n-id="ion-enroll-privacy-notice"> + <a + data-l10n-name="privacy-notice" + class="privacy-notice" + href="https://www.mozilla.org/%LOCALE%/privacy/" + target="_blank" + ></a> + </p> + <h2 data-l10n-id="ion-enroll-what-we-collect"></h2> + <p data-l10n-id="ion-enroll-collect-demographic"></p> + <p data-l10n-id="ion-enroll-technical-data"></p> + <p data-l10n-id="ion-enroll-interaction-data"></p> + <p data-l10n-id="ion-enroll-location-data"></p> + <p data-l10n-id="ion-enroll-data-disclosure"> + <a + data-l10n-name="privacy-policy" + class="privacy-policy" + href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" + target="_blank" + ></a> + </p> + <h2 data-l10n-id="ion-enroll-how-we-use"></h2> + <p data-l10n-id="ion-enroll-r-and-d"></p> + <ul> + <li data-l10n-id="ion-enroll-bullet-criteria"></li> + <li data-l10n-id="ion-enroll-bullet-representative"></li> + <li data-l10n-id="ion-enroll-bullet-improve-existing"></li> + <li data-l10n-id="ion-enroll-bullet-create"></li> + </ul> + <h2 data-l10n-id="ion-enroll-who-we-disclose-to"></h2> + <p data-l10n-id="ion-enroll-who-we-disclose-bullet-gcp"></p> + <p data-l10n-id="ion-enroll-who-we-disclose-bullet-third-party"></p> + <p data-l10n-id="ion-enroll-who-we-disclose-bullet-public"> + <a + data-l10n-name="mozilla-manifesto" + href="https://www.mozilla.org/about/manifesto/" + target="_blank" + ></a> + </p> + <h2 data-l10n-id="ion-enroll-data-management"></h2> + <p data-l10n-id="ion-enroll-data-management-learn-more"> + <a + data-l10n-name="privacy-policy" + class="privacy-policy" + href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" + target="_blank" + ></a> + <a + data-l10n-name="compliance-email" + href="mailto:compliance@mozilla.com" + ></a> + </p> + </ul> + <footer> + <button + id="join-ion-accept-dialog-button" + class="primary" + data-l10n-id="ion-accept-participate" + ></button> + <button + id="join-ion-cancel-dialog-button" + data-l10n-id="ion-cancel" + ></button> + </footer> + </dialog> + <dialog id="leave-ion-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="leave-ion-consent-title"></h3> + <ul id="leave-ion-consent" class="consent-list"> + <p data-l10n-id="leave-ion-consent-bullet-thanks"></p> + <p data-l10n-id="leave-ion-consent-bullet-manage"> + <a + data-l10n-name="privacy-policy" + class="privacy-policy" + href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" + target="_blank" + ></a> + </p> + </ul> + <footer> + <button + id="leave-ion-cancel-dialog-button" + class="primary" + data-l10n-id="ion-cancel" + ></button> + <button + id="leave-ion-accept-dialog-button" + data-l10n-id="ion-accept-leave" + ></button> + </footer> + </dialog> + <dialog id="join-study-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="ion-consent-study-notice"></h3> + <p data-l10n-id="ion-program-study-intro"></p> + <ul id="join-study-consent" class="consent-list"></ul> + <footer> + <button + id="join-study-accept-dialog-button" + class="primary" + data-l10n-id="ion-consent-study-join" + ></button> + <button + id="join-study-cancel-dialog-button" + data-l10n-id="ion-cancel" + ></button> + </footer> + </dialog> + <dialog id="leave-study-consent-dialog" class="modal" is="trapped-dialog"> + <h3 data-l10n-id="leave-study-consent-title"></h3> + <ul id="leave-study-consent" class="consent-list"> + <p data-l10n-id="leave-study-consent-bullet-manage"> + <a + data-l10n-name="privacy-policy" + class="privacy-policy" + href="https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/pioneer-managing-account-data" + target="_blank" + ></a> + </p> + </ul> + <footer> + <button + id="leave-study-cancel-dialog-button" + class="primary" + data-l10n-id="ion-cancel" + ></button> + <button + id="leave-study-accept-dialog-button" + data-l10n-id="ion-consent-study-leave" + ></button> + </footer> + </dialog> + </div> + </body> +</html> diff --git a/browser/components/ion/content/ion.js b/browser/components/ion/content/ion.js new file mode 100644 index 0000000000..3c34328d58 --- /dev/null +++ b/browser/components/ion/content/ion.js @@ -0,0 +1,791 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 panel for the Ion project, formerly known as Pioneer. + * This lives in `about:ion` and provides a UI for users to un/enroll in the + * overall program, and to un/enroll from individual studies. + * + * NOTE - prefs and Telemetry both still mention Pioneer for backwards-compatibility, + * this may change in the future. + */ + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); + +let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils +); + +const PREF_ION_ID = "toolkit.telemetry.pioneerId"; +const PREF_ION_NEW_STUDIES_AVAILABLE = + "toolkit.telemetry.pioneer-new-studies-available"; +const PREF_ION_COMPLETED_STUDIES = + "toolkit.telemetry.pioneer-completed-studies"; + +/** + * Remote Settings keys for general content, and available studies. + */ +const CONTENT_COLLECTION_KEY = "pioneer-content-v2"; +const STUDY_ADDON_COLLECTION_KEY = "pioneer-study-addons-v2"; + +const STUDY_LEAVE_REASONS = { + USER_ABANDONED: "user-abandoned", + STUDY_ENDED: "study-ended", +}; + +const PREF_TEST_CACHED_CONTENT = "toolkit.pioneer.testCachedContent"; +const PREF_TEST_CACHED_ADDONS = "toolkit.pioneer.testCachedAddons"; +const PREF_TEST_ADDONS = "toolkit.pioneer.testAddons"; + +/** + * Use the in-tree HTML Sanitizer to ensure that HTML from remote-settings is safe to use. + * Note that RS does use content-signing, we're doing this extra step as an in-depth security measure. + * + * @param {string} htmlString - unsanitized HTML (content-signed by remote-settings) + * @returns {DocumentFragment} - sanitized DocumentFragment + */ +function sanitizeHtml(htmlString) { + const content = document.createElement("div"); + const contentFragment = parserUtils.parseFragment( + htmlString, + Ci.nsIParserUtils.SanitizerDropForms | + Ci.nsIParserUtils.SanitizerAllowStyle | + Ci.nsIParserUtils.SanitizerLogRemovals, + false, + Services.io.newURI("about:ion"), + content + ); + + return contentFragment; +} + +function showEnrollmentStatus() { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + const enrollmentButton = document.getElementById("enrollment-button"); + + document.l10n.setAttributes( + enrollmentButton, + `ion-${ionId ? "un" : ""}enrollment-button` + ); + enrollmentButton.classList.toggle("primary", !ionId); + + // collapse content above the fold if enrolled, otherwise open it. + for (const section of ["details", "data"]) { + const details = document.getElementById(section); + if (ionId) { + details.removeAttribute("open"); + } else { + details.setAttribute("open", true); + } + } +} + +function toggleContentBasedOnLocale() { + const requestedLocale = Services.locale.requestedLocale; + if (requestedLocale !== "en-US") { + const localeNotificationBar = document.getElementById( + "locale-notification" + ); + localeNotificationBar.style.display = "block"; + + const reportContent = document.getElementById("report-content"); + reportContent.style.display = "none"; + } +} + +async function toggleEnrolled(studyAddonId, cachedAddons) { + let addon; + let install; + + const cachedAddon = cachedAddons.find(a => a.addon_id == studyAddonId); + + if (Cu.isInAutomation) { + install = { + install: async () => { + let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + testAddons = JSON.parse(testAddons); + + testAddons.push(studyAddonId); + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify(testAddons) + ); + }, + }; + + let testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + testAddons = JSON.parse(testAddons); + + for (const testAddon of testAddons) { + if (testAddon == studyAddonId) { + addon = {}; + addon.uninstall = () => { + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify(testAddons.filter(a => a.id != testAddon.id)) + ); + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + install = await AddonManager.getInstallForURL(cachedAddon.sourceURI.spec); + } + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const study = document.querySelector(`.card[id="${cachedAddon.addon_id}"`); + const joinBtn = study.querySelector(".join-button"); + + if (addon) { + joinBtn.disabled = true; + await addon.uninstall(); + await sendDeletionPing(studyAddonId); + + document.l10n.setAttributes(joinBtn, "ion-join-study"); + joinBtn.disabled = false; + + // Record that the user abandoned this study, since it may not be re-join-able. + if (completedStudies) { + const studies = JSON.parse(completedStudies); + studies[studyAddonId] = STUDY_LEAVE_REASONS.USER_ABANDONED; + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + } else { + // Check if this study is re-join-able before enrollment. + const studies = JSON.parse(completedStudies); + if (studyAddonId in studies) { + if ( + "canRejoin" in cachedAddons[studyAddonId] && + cachedAddons[studyAddonId].canRejoin === false + ) { + console.error( + `Cannot rejoin ended study ${studyAddonId}, reason: ${studies[studyAddonId]}` + ); + return; + } + } + joinBtn.disabled = true; + await install.install(); + document.l10n.setAttributes(joinBtn, "ion-leave-study"); + joinBtn.disabled = false; + + // Send an enrollment ping for this study. Note that this could be sent again + // if we are re-joining. + await sendEnrollmentPing(studyAddonId); + } + + await updateStudy(cachedAddon.addon_id); +} + +async function showAvailableStudies(cachedAddons) { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + const defaultAddons = cachedAddons.filter(a => a.isDefault); + if (ionId) { + for (const defaultAddon of defaultAddons) { + let addon; + let install; + if (Cu.isInAutomation) { + install = { + install: async () => { + if ( + defaultAddon.addon_id == "ion-v2-bad-default-example@mozilla.org" + ) { + throw new Error("Bad test default add-on"); + } + }, + }; + } else { + addon = await AddonManager.getAddonByID(defaultAddon.addon_id); + install = await AddonManager.getInstallForURL( + defaultAddon.sourceURI.spec + ); + } + + if (!addon) { + // Any default add-ons are required, try to reinstall. + await install.install(); + } + } + } + + const studyAddons = cachedAddons.filter(a => !a.isDefault); + for (const cachedAddon of studyAddons) { + if (!cachedAddon) { + console.error( + `about:ion - Study addon ID not found in cache: ${studyAddonId}` + ); + return; + } + + const studyAddonId = cachedAddon.addon_id; + + const study = document.createElement("div"); + study.setAttribute("id", studyAddonId); + study.setAttribute("class", "card card-no-hover"); + + if (cachedAddon.icons && 32 in cachedAddon.icons) { + const iconName = document.createElement("img"); + iconName.setAttribute("class", "card-icon"); + iconName.setAttribute("src", cachedAddon.icons[32]); + study.appendChild(iconName); + } + + const studyBody = document.createElement("div"); + studyBody.classList.add("card-body"); + study.appendChild(studyBody); + + const studyName = document.createElement("h3"); + studyName.setAttribute("class", "card-name"); + studyName.textContent = cachedAddon.name; + studyBody.appendChild(studyName); + + const studyAuthor = document.createElement("span"); + studyAuthor.setAttribute("class", "card-author"); + studyAuthor.textContent = cachedAddon.authors.name; + studyBody.appendChild(studyAuthor); + + const actions = document.createElement("div"); + actions.classList.add("card-actions"); + study.appendChild(actions); + + const joinBtn = document.createElement("button"); + joinBtn.setAttribute("id", `${studyAddonId}-join-button`); + joinBtn.classList.add("primary"); + joinBtn.classList.add("join-button"); + document.l10n.setAttributes(joinBtn, "ion-join-study"); + + joinBtn.addEventListener("click", async () => { + let addon; + if (Cu.isInAutomation) { + const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + for (const testAddon of JSON.parse(testAddons)) { + if (testAddon == studyAddonId) { + addon = {}; + addon.uninstall = () => { + Services.prefs.setStringPref(PREF_TEST_ADDONS, "[]"); + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + } + let joinOrLeave = addon ? "leave" : "join"; + let dialog = document.getElementById( + `${joinOrLeave}-study-consent-dialog` + ); + dialog.setAttribute("addon-id", cachedAddon.addon_id); + const consentText = dialog.querySelector( + `[id=${joinOrLeave}-study-consent]` + ); + + // Clears out any existing children with a single #text node + consentText.textContent = ""; + + const contentFragment = sanitizeHtml( + cachedAddon[`${joinOrLeave}StudyConsent`] + ); + consentText.appendChild(contentFragment); + + dialog.showModal(); + dialog.scrollTop = 0; + + const openEvent = new Event("open"); + dialog.dispatchEvent(openEvent); + }); + actions.appendChild(joinBtn); + + const studyDesc = document.createElement("div"); + studyDesc.setAttribute("class", "card-description"); + + const contentFragment = sanitizeHtml(cachedAddon.description); + studyDesc.appendChild(contentFragment); + + study.appendChild(studyDesc); + + const studyDataCollected = document.createElement("div"); + studyDataCollected.setAttribute("class", "card-data-collected"); + study.appendChild(studyDataCollected); + + const dataCollectionDetailsHeader = document.createElement("p"); + dataCollectionDetailsHeader.textContent = "This study will collect:"; + studyDataCollected.appendChild(dataCollectionDetailsHeader); + + const dataCollectionDetails = document.createElement("ul"); + for (const dataCollectionDetail of cachedAddon.dataCollectionDetails) { + const detailsBullet = document.createElement("li"); + detailsBullet.textContent = dataCollectionDetail; + dataCollectionDetails.append(detailsBullet); + } + studyDataCollected.appendChild(dataCollectionDetails); + + const availableStudies = document.getElementById("available-studies"); + availableStudies.appendChild(study); + + await updateStudy(studyAddonId); + } + + const availableStudies = document.getElementById("header-available-studies"); + document.l10n.setAttributes(availableStudies, "ion-current-studies"); +} + +async function updateStudy(studyAddonId) { + let addon; + if (Cu.isInAutomation) { + const testAddons = Services.prefs.getStringPref(PREF_TEST_ADDONS, "[]"); + for (const testAddon of JSON.parse(testAddons)) { + if (testAddon == studyAddonId) { + addon = { + uninstall() {}, + }; + } + } + } else { + addon = await AddonManager.getAddonByID(studyAddonId); + } + + const study = document.querySelector(`.card[id="${studyAddonId}"`); + + const joinBtn = study.querySelector(".join-button"); + + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + + const studies = JSON.parse(completedStudies); + if (studyAddonId in studies) { + study.style.opacity = 0.5; + joinBtn.disabled = true; + document.l10n.setAttributes(joinBtn, "ion-ended-study"); + return; + } + + if (ionId) { + study.style.opacity = 1; + joinBtn.disabled = false; + + if (addon) { + document.l10n.setAttributes(joinBtn, "ion-leave-study"); + } else { + document.l10n.setAttributes(joinBtn, "ion-join-study"); + } + } else { + document.l10n.setAttributes(joinBtn, "ion-study-prompt"); + study.style.opacity = 0.5; + joinBtn.disabled = true; + } +} + +// equivalent to what we use for Telemetry IDs +// https://searchfox.org/mozilla-central/rev/9193635dca8cfdcb68f114306194ffc860456044/toolkit/components/telemetry/app/TelemetryUtils.jsm#222 +function generateUUID() { + let str = Services.uuid.generateUUID().toString(); + return str.substring(1, str.length - 1); +} + +async function setup(cachedAddons) { + document + .getElementById("enrollment-button") + .addEventListener("click", async () => { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + if (ionId) { + let dialog = document.getElementById("leave-ion-consent-dialog"); + dialog.showModal(); + dialog.scrollTop = 0; + } else { + let dialog = document.getElementById("join-ion-consent-dialog"); + dialog.showModal(); + dialog.scrollTop = 0; + } + }); + + document + .getElementById("join-ion-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("join-ion-consent-dialog").close() + ); + document + .getElementById("leave-ion-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("leave-ion-consent-dialog").close() + ); + document + .getElementById("join-study-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("join-study-consent-dialog").close() + ); + document + .getElementById("leave-study-cancel-dialog-button") + .addEventListener("click", () => + document.getElementById("leave-study-consent-dialog").close() + ); + + document + .getElementById("join-ion-accept-dialog-button") + .addEventListener("click", async event => { + const ionId = Services.prefs.getStringPref(PREF_ION_ID, null); + + if (!ionId) { + let uuid = generateUUID(); + Services.prefs.setStringPref(PREF_ION_ID, uuid); + for (const cachedAddon of cachedAddons) { + if (cachedAddon.isDefault) { + let install; + if (Cu.isInAutomation) { + install = { + install: async () => { + if ( + cachedAddon.addon_id == + "ion-v2-bad-default-example@mozilla.org" + ) { + throw new Error("Bad test default add-on"); + } + }, + }; + } else { + install = await AddonManager.getInstallForURL( + cachedAddon.sourceURI.spec + ); + } + + try { + await install.install(); + } catch (ex) { + // No need to throw here, we'll try again before letting users enroll in any studies. + console.error( + `Could not install default add-on ${cachedAddon.addon_id}` + ); + const availableStudies = + document.getElementById("available-studies"); + document.l10n.setAttributes( + availableStudies, + "ion-no-current-studies" + ); + } + } + const study = document.getElementById(cachedAddon.addon_id); + if (study) { + await updateStudy(cachedAddon.addon_id); + } + } + document.querySelector("dialog").close(); + } + // A this point we should have a valid ion id, so we should be able to send + // the enrollment ping. + await sendEnrollmentPing(); + + showEnrollmentStatus(); + }); + + document + .getElementById("leave-ion-accept-dialog-button") + .addEventListener("click", async event => { + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + const studies = JSON.parse(completedStudies); + + // Send a deletion ping for all completed studies the user has been a part of. + for (const studyAddonId in studies) { + await sendDeletionPing(studyAddonId); + } + + Services.prefs.clearUserPref(PREF_ION_COMPLETED_STUDIES); + + for (const cachedAddon of cachedAddons) { + // Record any studies that have been marked as concluded on the server, in case they re-enroll. + if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) { + studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED; + + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + + let addon; + if (Cu.isInAutomation) { + addon = {}; + addon.id = cachedAddon.addon_id; + addon.uninstall = () => { + let testAddons = Services.prefs.getStringPref( + PREF_TEST_ADDONS, + "[]" + ); + testAddons = JSON.parse(testAddons); + + Services.prefs.setStringPref( + PREF_TEST_ADDONS, + JSON.stringify( + testAddons.filter(a => a.id != cachedAddon.addon_id) + ) + ); + }; + } else { + addon = await AddonManager.getAddonByID(cachedAddon.addon_id); + } + if (addon) { + await sendDeletionPing(addon.id); + await addon.uninstall(); + } + } + + Services.prefs.clearUserPref(PREF_ION_ID); + for (const cachedAddon of cachedAddons) { + const study = document.getElementById(cachedAddon.addon_id); + if (study) { + await updateStudy(cachedAddon.addon_id); + } + } + + document.getElementById("leave-ion-consent-dialog").close(); + showEnrollmentStatus(); + }); + + document + .getElementById("join-study-accept-dialog-button") + .addEventListener("click", async event => { + const dialog = document.getElementById("join-study-consent-dialog"); + const studyAddonId = dialog.getAttribute("addon-id"); + toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); + }); + + document + .getElementById("leave-study-accept-dialog-button") + .addEventListener("click", async event => { + const dialog = document.getElementById("leave-study-consent-dialog"); + const studyAddonId = dialog.getAttribute("addon-id"); + await toggleEnrolled(studyAddonId, cachedAddons).then(dialog.close()); + }); + + const onAddonEvent = async addon => { + for (const cachedAddon of cachedAddons) { + if (cachedAddon.addon_id == addon.id) { + await updateStudy(addon.id); + } + } + }; + + const addonsListener = { + onEnabled: onAddonEvent, + onDisabled: onAddonEvent, + onInstalled: onAddonEvent, + onUninstalled: onAddonEvent, + }; + AddonManager.addAddonListener(addonsListener); + + window.addEventListener("unload", event => { + AddonManager.removeAddonListener(addonsListener); + }); +} + +function removeBadge() { + Services.prefs.setBoolPref(PREF_ION_NEW_STUDIES_AVAILABLE, false); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + const badge = win.document + .getElementById("ion-button") + .querySelector(".toolbarbutton-badge"); + badge.classList.remove("feature-callout"); + } +} + +// Updates Ion HTML page contents from RemoteSettings. +function updateContents(contents) { + for (const section of [ + "title", + "summary", + "details", + "data", + "joinIonConsent", + "leaveIonConsent", + ]) { + if (contents && section in contents) { + // Generate a corresponding dom-id style ID for a camel-case domId style JS attribute. + // Dynamically set the tag type based on which section is getting updated. + const domId = section + .split(/(?=[A-Z])/) + .join("-") + .toLowerCase(); + // Clears out any existing children with a single #text node. + document.getElementById(domId).textContent = ""; + + const contentFragment = sanitizeHtml(contents[section]); + document.getElementById(domId).appendChild(contentFragment); + } + } +} + +document.addEventListener("DOMContentLoaded", async domEvent => { + toggleContentBasedOnLocale(); + + showEnrollmentStatus(); + + document.addEventListener("focus", removeBadge); + removeBadge(); + + const privacyPolicyLinks = document.querySelectorAll( + ".privacy-policy,.privacy-notice" + ); + for (const privacyPolicyLink of privacyPolicyLinks) { + const privacyPolicyFormattedLink = Services.urlFormatter.formatURL( + privacyPolicyLink.href + ); + privacyPolicyLink.href = privacyPolicyFormattedLink; + } + + let cachedContent; + let cachedAddons; + if (Cu.isInAutomation) { + let testCachedAddons = Services.prefs.getStringPref( + PREF_TEST_CACHED_ADDONS, + null + ); + if (testCachedAddons) { + cachedAddons = JSON.parse(testCachedAddons); + } + + let testCachedContent = Services.prefs.getStringPref( + PREF_TEST_CACHED_CONTENT, + null + ); + if (testCachedContent) { + cachedContent = JSON.parse(testCachedContent); + } + } else { + cachedContent = await RemoteSettings(CONTENT_COLLECTION_KEY).get(); + cachedAddons = await RemoteSettings(STUDY_ADDON_COLLECTION_KEY).get(); + } + + // Replace existing contents immediately on page load. + for (const contents of cachedContent) { + updateContents(contents); + } + + for (const cachedAddon of cachedAddons) { + // Record any studies that have been marked as concluded on the server. + if ("studyEnded" in cachedAddon && cachedAddon.studyEnded === true) { + const completedStudies = Services.prefs.getStringPref( + PREF_ION_COMPLETED_STUDIES, + "{}" + ); + const studies = JSON.parse(completedStudies); + studies[cachedAddon.addon_id] = STUDY_LEAVE_REASONS.STUDY_ENDED; + + Services.prefs.setStringPref( + PREF_ION_COMPLETED_STUDIES, + JSON.stringify(studies) + ); + } + } + + await setup(cachedAddons); + + try { + await showAvailableStudies(cachedAddons); + } catch (ex) { + // No need to throw here, we'll try again before letting users enroll in any studies. + console.error(`Could not show available studies`, ex); + } +}); + +async function sendDeletionPing(studyAddonId) { + const type = "pioneer-study"; + + const options = { + studyName: studyAddonId, + addPioneerId: true, + useEncryption: true, + // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry + // pipeline requires that pings are shaped this way so they are routed to the correct environment. + // + // At the moment, the public key used here isn't important but we do need to use *something*. + encryptionKeyId: "discarded", + publicKey: { + crv: "P-256", + kty: "EC", + x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk", + y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE", + }, + schemaName: "deletion-request", + schemaVersion: 1, + // The schema namespace needs to be the study addon id, as we + // want to route the ping to the specific study table. + schemaNamespace: studyAddonId, + }; + + const payload = { + encryptedData: "", + }; + + await TelemetryController.submitExternalPing(type, payload, options); +} + +/** + * Sends a Pioneer enrollment ping. + * + * The `creationDate` provided by the telemetry APIs will be used as the timestamp for + * considering the user enrolled in pioneer and/or the study. + * + * @param {string} [studyAddonId] - optional study id. It's sent in the ping, if present, + * to signal that user enroled in the study. + */ +async function sendEnrollmentPing(studyAddonId) { + let options = { + studyName: "pioneer-meta", + addPioneerId: true, + useEncryption: true, + // NOTE - while we're not actually sending useful data in this payload, the current Pioneer v2 Telemetry + // pipeline requires that pings are shaped this way so they are routed to the correct environment. + // + // At the moment, the public key used here isn't important but we do need to use *something*. + encryptionKeyId: "discarded", + publicKey: { + crv: "P-256", + kty: "EC", + x: "XLkI3NaY3-AF2nRMspC63BT1u0Y3moXYSfss7VuQ0mk", + y: "SB0KnIW-pqk85OIEYZenoNkEyOOp5GeWQhS1KeRtEUE", + }, + schemaName: "pioneer-enrollment", + schemaVersion: 1, + // Note that the schema namespace directly informs how data is segregated after ingestion. + // If this is an enrollment ping for the pioneer program (in contrast to the enrollment to + // a specific study), use a meta namespace. + schemaNamespace: "pioneer-meta", + }; + + // If we were provided with a study id, then this is an enrollment to a study. + // Send the id alongside with the data and change the schema namespace to simplify + // the work on the ingestion pipeline. + if (typeof studyAddonId != "undefined") { + options.studyName = studyAddonId; + // The schema namespace needs to be the study addon id, as we + // want to route the ping to the specific study table. + options.schemaNamespace = studyAddonId; + } + + await TelemetryController.submitExternalPing("pioneer-study", {}, options); +} |