/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// Cannot use Services.appinfo here, or else xpcshell-tests will blow up, as
// most tests later register different nsIAppInfo implementations, which
// wouldn't be reflected in Services.appinfo anymore, as the lazy getter
// underlying it would have been initialized if we used it here.
if ("@mozilla.org/xre/app-info;1" in Cc) {
  // eslint-disable-next-line mozilla/use-services
  let runtime = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULRuntime);
  if (runtime.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
    // Refuse to run in child processes.
    throw new Error("You cannot use the AddonManager in child processes!");
  }
}

import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";

const INTL_LOCALES_CHANGED = "intl:app-locales-changed";
const XPIPROVIDER_BLOCKLIST_ATTENTION_UPDATED =
  "xpi-provider:blocklist-attention-updated";

const PREF_BLOCKLIST_PINGCOUNTVERSION = "extensions.blocklist.pingCountVersion";
const PREF_EM_UPDATE_ENABLED = "extensions.update.enabled";
const PREF_EM_LAST_APP_VERSION = "extensions.lastAppVersion";
const PREF_EM_LAST_PLATFORM_VERSION = "extensions.lastPlatformVersion";
const PREF_EM_AUTOUPDATE_DEFAULT = "extensions.update.autoUpdateDefault";
const PREF_EM_STRICT_COMPATIBILITY = "extensions.strictCompatibility";
const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity";
const PREF_SYS_ADDON_UPDATE_ENABLED = "extensions.systemAddon.update.enabled";
const PREF_REMOTESETTINGS_DISABLED = "extensions.remoteSettings.disabled";
const PREF_USE_REMOTE = "extensions.webextensions.remote";
const PREF_AMTELEMETRY_ADDONS_BUILDER =
  "extensions.telemetry.EnvironmentAddonBuilder";
const PREF_GLEAN_PING_ADDONS_UPDATED_DELAY_MS =
  "extensions.gleanPingAddons.updated.delay";
const PREF_GLEAN_PING_ADDONS_UPDATED_IDLE_TIMEOUT_MS =
  "extensions.gleanPingAddons.updated.idleTimeout";
const PREF_GLEAN_PING_ADDONS_UPDATED_TESTING =
  "extensions.gleanPingAddons.updated.testing";
const PREF_EM_LAST_FORK_VERSION = "extensions.lastTorBrowserVersion";

const PREF_MIN_WEBEXT_PLATFORM_VERSION =
  "extensions.webExtensionsMinPlatformVersion";
const PREF_WEBAPI_TESTING = "extensions.webapi.testing";
const PREF_EM_POSTDOWNLOAD_THIRD_PARTY =
  "extensions.postDownloadThirdPartyPrompt";

const PREF_ALLOW_EXECUTESCRIPT_IN_MOZEXTENSION =
  "extensions.webextensions.allow_executeScript_in_moz_extension";

const UPDATE_REQUEST_VERSION = 2;

const BRANCH_REGEXP = /^([^\.]+\.[0-9]+[a-z]*).*/gi;
const PREF_EM_CHECK_COMPATIBILITY_BASE = "extensions.checkCompatibility";
var PREF_EM_CHECK_COMPATIBILITY = AppConstants.NIGHTLY_BUILD
  ? PREF_EM_CHECK_COMPATIBILITY_BASE + ".nightly"
  : undefined;

const WEBAPI_INSTALL_HOSTS =
  AppConstants.MOZ_APP_NAME !== "thunderbird"
    ? ["addons.mozilla.org"]
    : ["addons.thunderbird.net"];
const WEBAPI_TEST_INSTALL_HOSTS =
  AppConstants.MOZ_APP_NAME !== "thunderbird"
    ? ["addons.allizom.org", "addons-dev.allizom.org", "example.com"]
    : ["addons-stage.thunderbird.net", "example.com"];

const AMO_ATTRIBUTION_ALLOWED_SOURCES = ["amo", "disco", "rtamo"];
const AMO_ATTRIBUTION_DATA_KEYS = [
  "utm_campaign",
  "utm_content",
  "utm_medium",
  "utm_source",
];
const AMO_ATTRIBUTION_DATA_MAX_LENGTH = 40;

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

// This global is overridden by xpcshell tests, and therefore cannot be
// a const.
import { AsyncShutdown as realAsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs";

var AsyncShutdown = realAsyncShutdown;

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs",
  AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
  GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.sys.mjs",
  EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
  Extension: "resource://gre/modules/Extension.sys.mjs",
  ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
  TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
  isGatedPermissionType:
    "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
  isKnownPublicSuffix:
    "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
  isPrincipalInSitePermissionsBlocklist:
    "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "WEBEXT_POSTDOWNLOAD_THIRD_PARTY",
  PREF_EM_POSTDOWNLOAD_THIRD_PARTY,
  false
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "AMTELEMETRY_ADDONS_BUILDER_ENABLED",
  PREF_AMTELEMETRY_ADDONS_BUILDER,
  false
);

// By default coalesce `addons` updated ping submission happening in a 5min interval.
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "GLEAN_PING_ADDONS_UPDATED_DELAY_MS",
  PREF_GLEAN_PING_ADDONS_UPDATED_DELAY_MS,
  1000 * 60 * 5
);

// By default wait for 1min for an idle slot after the delay time have already elapsed.
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "GLEAN_PING_ADDONS_UPDATED_IDLE_TIMEOUT_MS",
  PREF_GLEAN_PING_ADDONS_UPDATED_IDLE_TIMEOUT_MS,
  1000 * 60
);

// Whether EnvironmentAddonBuilder._scheduleGleanPingAddonsUpdated should
// send the `test-glean-ping-addons-updated` observer service notification.
XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "GLEAN_PING_ADDONS_UPDATED_TESTING",
  PREF_GLEAN_PING_ADDONS_UPDATED_TESTING,
  false
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "ALLOW_EXECUTESCRIPT_IN_MOZEXTENSION",
  PREF_ALLOW_EXECUTESCRIPT_IN_MOZEXTENSION,
  false,
  // onUpdate callback.
  (_pref, _oldValue, newValue) => {
    Glean.extensions.allowExecuteScriptInMozExtension.set(newValue);
  }
);

// Initialize the WebExtension process script service as early as possible,
// since it needs to be able to track things like new frameLoader globals that
// are created before other framework code has been initialized.
Services.ppmm.loadProcessScript(
  "resource://gre/modules/extensionProcessScriptLoader.js",
  true
);

const INTEGER = /^[1-9]\d*$/;

const CATEGORY_PROVIDER_MODULE = "addon-provider-module";

import { Log } from "resource://gre/modules/Log.sys.mjs";
// Configure a logger at the parent 'addons' level to format
// messages for all the modules under addons.*
const PARENT_LOGGER_ID = "addons";
var parentLogger = Log.repository.getLogger(PARENT_LOGGER_ID);
parentLogger.level = Log.Level.Warn;
var formatter = new Log.BasicFormatter();
// Set parent logger (and its children) to append to
// the Javascript section of the Browser Console
parentLogger.addAppender(new Log.ConsoleAppender(formatter));

// Create a new logger (child of 'addons' logger)
// for use by the Addons Manager
const LOGGER_ID = "addons.manager";
var logger = Log.repository.getLogger(LOGGER_ID);

// Provide the ability to enable/disable logging
// messages at runtime.
// If the "extensions.logging.enabled" preference is
// missing or 'false', messages at the WARNING and higher
// severity should be logged to the JS console and standard error.
// If "extensions.logging.enabled" is set to 'true', messages
// at DEBUG and higher should go to JS console and standard error.
const PREF_LOGGING_ENABLED = "extensions.logging.enabled";
const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";

const GMP_PROVIDER_REGISTERED_TOPIC = "gmp-provider-registered";
// The maximum length of strings collected in the Glean addons.activeAddons/activeTheme/activeGMPlugins
// metrics properties (e.g. description).
const MAX_ADDON_STRING_LENGTH = 100;

const UNNAMED_PROVIDER = "<unnamed-provider>";
function providerName(aProvider) {
  return aProvider.name || UNNAMED_PROVIDER;
}

// A reference to XPIProvider. This should only be used to access properties or
// methods that are independent of XPIProvider startup.
var gXPIProvider;

/**
 * Preference listener which listens for a change in the
 * "extensions.logging.enabled" preference and changes the logging level of the
 * parent 'addons' level logger accordingly.
 */
var PrefObserver = {
  init() {
    Services.prefs.addObserver(PREF_LOGGING_ENABLED, this);
    Services.obs.addObserver(this, "xpcom-shutdown");
    this.observe(null, NS_PREFBRANCH_PREFCHANGE_TOPIC_ID, PREF_LOGGING_ENABLED);
  },

  observe(aSubject, aTopic) {
    if (aTopic == "xpcom-shutdown") {
      Services.prefs.removeObserver(PREF_LOGGING_ENABLED, this);
      Services.obs.removeObserver(this, "xpcom-shutdown");
    } else if (aTopic == NS_PREFBRANCH_PREFCHANGE_TOPIC_ID) {
      let debugLogEnabled = Services.prefs.getBoolPref(
        PREF_LOGGING_ENABLED,
        false
      );
      if (debugLogEnabled) {
        parentLogger.level = Log.Level.Debug;
      } else {
        parentLogger.level = Log.Level.Warn;
      }
    }
  },
};

PrefObserver.init();

/**
 * Calls a callback method consuming any thrown exception. Any parameters after
 * the callback parameter will be passed to the callback.
 *
 * @param  aCallback
 *         The callback method to call
 */
function safeCall(aCallback, ...aArgs) {
  try {
    aCallback.apply(null, aArgs);
  } catch (e) {
    logger.warn("Exception calling callback", e);
  }
}

/**
 * Report an exception thrown by a provider API method.
 */
function reportProviderError(aProvider, aMethod, aError) {
  let method = `provider ${providerName(aProvider)}.${aMethod}`;
  AddonManagerPrivate.recordException("AMI", method, aError);
  logger.error("Exception calling " + method, aError);
}

/**
 * Calls a method on a provider if it exists and consumes any thrown exception.
 * Any parameters after the aDefault parameter are passed to the provider's method.
 *
 * @param  aProvider
 *         The provider to call
 * @param  aMethod
 *         The method name to call
 * @param  aDefault
 *         A default return value if the provider does not implement the named
 *         method or throws an error.
 * @return the return value from the provider, or aDefault if the provider does not
 *         implement method or throws an error
 */
function callProvider(aProvider, aMethod, aDefault, ...aArgs) {
  if (!(aMethod in aProvider)) {
    return aDefault;
  }

  try {
    return aProvider[aMethod].apply(aProvider, aArgs);
  } catch (e) {
    reportProviderError(aProvider, aMethod, e);
    return aDefault;
  }
}

/**
 * Calls a method on a provider if it exists and consumes any thrown exception.
 * Parameters after aMethod are passed to aProvider.aMethod().
 * If the provider does not implement the method, or the method throws, calls
 * the callback with 'undefined'.
 *
 * @param  aProvider
 *         The provider to call
 * @param  aMethod
 *         The method name to call
 */
async function promiseCallProvider(aProvider, aMethod, ...aArgs) {
  if (!(aMethod in aProvider)) {
    return undefined;
  }
  try {
    return aProvider[aMethod].apply(aProvider, aArgs);
  } catch (e) {
    reportProviderError(aProvider, aMethod, e);
    return undefined;
  }
}

/**
 * Gets the currently selected locale for display.
 *
 * @return  the selected locale or "en-US" if none is selected
 */
function getLocale() {
  return Services.locale.requestedLocale || "en-US";
}

const WEB_EXPOSED_ADDON_PROPERTIES = [
  "id",
  "version",
  "type",
  "name",
  "description",
  "isActive",
];

function webAPIForAddon(addon) {
  if (!addon) {
    return null;
  }

  // These web-exposed Addon properties (see AddonManager.webidl)
  // just come directly from an Addon object.
  let result = {};
  for (let prop of WEB_EXPOSED_ADDON_PROPERTIES) {
    result[prop] = addon[prop];
  }

  // These properties are computed.
  result.isEnabled = !addon.userDisabled;
  result.canUninstall = Boolean(
    addon.permissions & AddonManager.PERM_CAN_UNINSTALL
  );

  return result;
}

/**
 * Listens for a browser changing origin and cancels the installs that were
 * started by it.
 */
function BrowserListener(aBrowser, aInstallingPrincipal, aInstall) {
  this.browser = aBrowser;
  this.messageManager = this.browser.messageManager;
  this.principal = aInstallingPrincipal;
  this.install = aInstall;

  aBrowser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
  Services.obs.addObserver(this, "message-manager-close", true);

  aInstall.addListener(this);

  this.registered = true;
}

BrowserListener.prototype = {
  browser: null,
  install: null,
  registered: false,

  unregister() {
    if (!this.registered) {
      return;
    }
    this.registered = false;

    Services.obs.removeObserver(this, "message-manager-close");
    // The browser may have already been detached
    if (this.browser.removeProgressListener) {
      this.browser.removeProgressListener(this);
    }

    this.install.removeListener(this);
    this.install = null;
  },

  cancelInstall() {
    try {
      this.install.cancel();
    } catch (e) {
      // install may have already failed or been cancelled, ignore these
    }
  },

  observe(subject) {
    if (subject != this.messageManager) {
      return;
    }

    // The browser's message manager has closed and so the browser is
    // going away, cancel the install
    this.cancelInstall();
  },

  onLocationChange() {
    if (
      this.browser.contentPrincipal &&
      this.principal.subsumes(this.browser.contentPrincipal)
    ) {
      return;
    }

    // The browser has navigated to a new origin so cancel the install
    this.cancelInstall();
  },

  onDownloadCancelled() {
    this.unregister();
  },

  onDownloadFailed() {
    this.unregister();
  },

  onInstallFailed() {
    this.unregister();
  },

  onInstallEnded() {
    this.unregister();
  },

  QueryInterface: ChromeUtils.generateQI([
    "nsISupportsWeakReference",
    "nsIWebProgressListener",
    "nsIObserver",
  ]),
};

/**
 * This represents an author of an add-on (e.g. creator or developer)
 *
 * @param  aName
 *         The name of the author
 * @param  aURL
 *         The URL of the author's profile page
 */
function AddonAuthor(aName, aURL) {
  this.name = aName;
  this.url = aURL;
}

AddonAuthor.prototype = {
  name: null,
  url: null,

  // Returns the author's name, defaulting to the empty string
  toString() {
    return this.name || "";
  },
};

/**
 * This represents an screenshot for an add-on
 *
 * @param  aURL
 *         The URL to the full version of the screenshot
 * @param  aWidth
 *         The width in pixels of the screenshot
 * @param  aHeight
 *         The height in pixels of the screenshot
 * @param  aThumbnailURL
 *         The URL to the thumbnail version of the screenshot
 * @param  aThumbnailWidth
 *         The width in pixels of the thumbnail version of the screenshot
 * @param  aThumbnailHeight
 *         The height in pixels of the thumbnail version of the screenshot
 * @param  aCaption
 *         The caption of the screenshot
 */
function AddonScreenshot(
  aURL,
  aWidth,
  aHeight,
  aThumbnailURL,
  aThumbnailWidth,
  aThumbnailHeight,
  aCaption
) {
  this.url = aURL;
  if (aWidth) {
    this.width = aWidth;
  }
  if (aHeight) {
    this.height = aHeight;
  }
  if (aThumbnailURL) {
    this.thumbnailURL = aThumbnailURL;
  }
  if (aThumbnailWidth) {
    this.thumbnailWidth = aThumbnailWidth;
  }
  if (aThumbnailHeight) {
    this.thumbnailHeight = aThumbnailHeight;
  }
  if (aCaption) {
    this.caption = aCaption;
  }
}

AddonScreenshot.prototype = {
  url: null,
  width: null,
  height: null,
  thumbnailURL: null,
  thumbnailWidth: null,
  thumbnailHeight: null,
  caption: null,

  // Returns the screenshot URL, defaulting to the empty string
  toString() {
    return this.url || "";
  },
};

var gStarted = false;
var gStartedPromise = Promise.withResolvers();
var gStartupComplete = false;
var gCheckCompatibility = true;
var gStrictCompatibility = true;
var gCheckUpdateSecurityDefault = true;
var gCheckUpdateSecurity = gCheckUpdateSecurityDefault;
var gUpdateEnabled = true;
var gAutoUpdateDefault = true;
var gWebExtensionsMinPlatformVersion = "";
var gFinalShutdownBarrier = null;
var gBeforeShutdownBarrier = null;
var gRepoShutdownState = "";
var gShutdownInProgress = false;
var gBrowserUpdated = null;

export var AMTelemetry;
export var AMRemoteSettings;
export var AMBrowserExtensionsImport;

/**
 * This is the real manager, kept here rather than in AddonManager to keep its
 * contents hidden from API users.
 *
 * @class
 * @lends AddonManager
 */
var AddonManagerInternal = {
  managerListeners: new Set(),
  installListeners: new Set(),
  addonListeners: new Set(),
  pendingProviders: new Set(),
  providers: new Set(),
  providerShutdowns: new Map(),
  typesByProvider: new Map(),
  startupChanges: {},
  // Store telemetry details per addon provider
  telemetryDetails: {},
  upgradeListeners: new Map(),
  externalExtensionLoaders: new Map(),

  /**
   * Start up a provider, and register its shutdown hook if it has one
   *
   * @param {string} aProvider - An add-on provider.
   * @param {boolean} aAppChanged - Whether or not the app version has changed since last session.
   * @param {string} aOldAppVersion - Previous application version, if changed.
   * @param {string} aOldPlatformVersion - Previous platform version, if changed.
   *
   * @private
   */
  _startProvider(aProvider, aAppChanged, aOldAppVersion, aOldPlatformVersion) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    logger.debug(`Starting provider: ${providerName(aProvider)}`);
    callProvider(
      aProvider,
      "startup",
      null,
      aAppChanged,
      aOldAppVersion,
      aOldPlatformVersion
    );
    if ("shutdown" in aProvider) {
      let name = providerName(aProvider);
      let AMProviderShutdown = () => {
        // If the provider has been unregistered, it will have been removed from
        // this.providers. If it hasn't been unregistered, then this is a normal
        // shutdown - and we move it to this.pendingProviders in case we're
        // running in a test that will start AddonManager again.
        if (this.providers.has(aProvider)) {
          this.providers.delete(aProvider);
          this.pendingProviders.add(aProvider);
        }

        return new Promise(resolve => {
          logger.debug("Calling shutdown blocker for " + name);
          resolve(aProvider.shutdown());
        }).catch(err => {
          logger.warn("Failure during shutdown of " + name, err);
          AddonManagerPrivate.recordException(
            "AMI",
            "Async shutdown of " + name,
            err
          );
        });
      };
      logger.debug("Registering shutdown blocker for " + name);
      this.providerShutdowns.set(aProvider, AMProviderShutdown);
      AddonManagerPrivate.finalShutdown.addBlocker(name, AMProviderShutdown);
    }

    this.pendingProviders.delete(aProvider);
    this.providers.add(aProvider);
    logger.debug(`Provider finished startup: ${providerName(aProvider)}`);
  },

  _getProviderByName(aName) {
    for (let provider of this.providers) {
      if (providerName(provider) == aName) {
        return provider;
      }
    }
    return undefined;
  },

  /**
   * Initializes the AddonManager, loading any known providers and initializing
   * them.
   */
  startup() {
    try {
      if (gStarted) {
        return;
      }

      Glean.addonsManager.startupTimeline.AMI_startup_begin.set(
        Services.telemetry.msSinceProcessStart()
      );

      // Enable the AMRemoteSettings client.
      AMRemoteSettings.init();

      // clear this for xpcshell test restarts
      for (let provider in this.telemetryDetails) {
        delete this.telemetryDetails[provider];
      }

      let appChanged = undefined;

      let oldAppVersion = null;
      try {
        oldAppVersion = Services.prefs.getCharPref(PREF_EM_LAST_APP_VERSION);
        appChanged = Services.appinfo.version != oldAppVersion;
      } catch (e) {}

      gBrowserUpdated = appChanged;

      let oldPlatformVersion = Services.prefs.getCharPref(
        PREF_EM_LAST_PLATFORM_VERSION,
        ""
      );

      if (appChanged !== false) {
        logger.debug("Application has been upgraded");
        Services.prefs.setCharPref(
          PREF_EM_LAST_APP_VERSION,
          Services.appinfo.version
        );
        Services.prefs.setCharPref(
          PREF_EM_LAST_PLATFORM_VERSION,
          Services.appinfo.platformVersion
        );
        Services.prefs.setIntPref(
          PREF_BLOCKLIST_PINGCOUNTVERSION,
          appChanged === undefined ? 0 : -1
        );
      }

      // To ensure that extension and plugin code gets a chance to run after
      // each browser update, set appChanged = true when BASE_BROWSER_VERSION
      // has changed even if the Mozilla app version has not changed.
      const forkChanged =
        AppConstants.BASE_BROWSER_VERSION !==
        Services.prefs.getCharPref(PREF_EM_LAST_FORK_VERSION, "");
      if (forkChanged) {
        // appChanged could be undefined (in case of a new profile)
        if (appChanged === false) {
          appChanged = true;
        }

        Services.prefs.setCharPref(
          PREF_EM_LAST_FORK_VERSION,
          AppConstants.BASE_BROWSER_VERSION
        );
      }

      if (!AppConstants.NIGHTLY_BUILD) {
        PREF_EM_CHECK_COMPATIBILITY =
          PREF_EM_CHECK_COMPATIBILITY_BASE +
          "." +
          Services.appinfo.version.replace(BRANCH_REGEXP, "$1");
      }

      gCheckCompatibility = Services.prefs.getBoolPref(
        PREF_EM_CHECK_COMPATIBILITY,
        gCheckCompatibility
      );
      Services.prefs.addObserver(PREF_EM_CHECK_COMPATIBILITY, this);

      gStrictCompatibility = Services.prefs.getBoolPref(
        PREF_EM_STRICT_COMPATIBILITY,
        gStrictCompatibility
      );
      Services.prefs.addObserver(PREF_EM_STRICT_COMPATIBILITY, this);

      let defaultBranch = Services.prefs.getDefaultBranch("");
      gCheckUpdateSecurityDefault = defaultBranch.getBoolPref(
        PREF_EM_CHECK_UPDATE_SECURITY,
        gCheckUpdateSecurityDefault
      );

      gCheckUpdateSecurity = Services.prefs.getBoolPref(
        PREF_EM_CHECK_UPDATE_SECURITY,
        gCheckUpdateSecurity
      );
      Services.prefs.addObserver(PREF_EM_CHECK_UPDATE_SECURITY, this);

      gUpdateEnabled = Services.prefs.getBoolPref(
        PREF_EM_UPDATE_ENABLED,
        gUpdateEnabled
      );
      Services.prefs.addObserver(PREF_EM_UPDATE_ENABLED, this);

      gAutoUpdateDefault = Services.prefs.getBoolPref(
        PREF_EM_AUTOUPDATE_DEFAULT,
        gAutoUpdateDefault
      );
      Services.prefs.addObserver(PREF_EM_AUTOUPDATE_DEFAULT, this);

      gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref(
        PREF_MIN_WEBEXT_PLATFORM_VERSION,
        gWebExtensionsMinPlatformVersion
      );
      Services.prefs.addObserver(PREF_MIN_WEBEXT_PLATFORM_VERSION, this);

      // Watch for changes to PREF_REMOTESETTINGS_DISABLED.
      Services.prefs.addObserver(PREF_REMOTESETTINGS_DISABLED, this);

      // Watch for language changes, refresh the addon cache when it changes.
      Services.obs.addObserver(this, INTL_LOCALES_CHANGED);

      // Watch for changes in the `AMBrowserExtensionsImport` singleton.
      Services.obs.addObserver(this, AMBrowserExtensionsImport.TOPIC_CANCELLED);
      Services.obs.addObserver(this, AMBrowserExtensionsImport.TOPIC_COMPLETE);
      Services.obs.addObserver(this, AMBrowserExtensionsImport.TOPIC_PENDING);

      // Watch for blocklist attention updates.
      Services.obs.addObserver(this, XPIPROVIDER_BLOCKLIST_ATTENTION_UPDATED);

      // Ensure all default providers have had a chance to register themselves.
      const { XPIExports } = ChromeUtils.importESModule(
        "resource://gre/modules/addons/XPIExports.sys.mjs"
      );
      gXPIProvider = XPIExports.XPIProvider;
      gXPIProvider.registerProvider();

      // Load any providers registered in the category manager
      for (let { entry, value: url } of Services.catMan.enumerateCategory(
        CATEGORY_PROVIDER_MODULE
      )) {
        try {
          ChromeUtils.importESModule(url);
          logger.debug(`Loaded provider scope for ${url}`);
        } catch (e) {
          AddonManagerPrivate.recordException(
            "AMI",
            "provider " + url + " load failed",
            e
          );
          logger.error(
            "Exception loading provider " +
              entry +
              ' from category "' +
              url +
              '"',
            e
          );
        }
      }

      // Register our shutdown handler with the AsyncShutdown manager
      gBeforeShutdownBarrier = new AsyncShutdown.Barrier(
        "AddonManager: Waiting to start provider shutdown."
      );
      gFinalShutdownBarrier = new AsyncShutdown.Barrier(
        "AddonManager: Waiting for providers to shut down."
      );
      AsyncShutdown.profileBeforeChange.addBlocker(
        "AddonManager: shutting down.",
        this.shutdownManager.bind(this),
        { fetchState: this.shutdownState.bind(this) }
      );

      // Once we start calling providers we must allow all normal methods to work.
      gStarted = true;

      for (let provider of this.pendingProviders) {
        this._startProvider(
          provider,
          appChanged,
          oldAppVersion,
          oldPlatformVersion
        );
      }

      // If this is a new profile just pretend that there were no changes
      if (appChanged === undefined) {
        for (let type in this.startupChanges) {
          delete this.startupChanges[type];
        }
      }

      gStartupComplete = true;
      gStartedPromise.resolve();
      Glean.addonsManager.startupTimeline.AMI_startup_end.set(
        Services.telemetry.msSinceProcessStart()
      );
    } catch (e) {
      logger.error("startup failed", e);
      AddonManagerPrivate.recordException("AMI", "startup failed", e);
      gStartedPromise.reject("startup failed");
    }

    // Disable the quarantined domains feature if the system add-on has been
    // disabled in a previous version.
    if (
      Services.prefs.getBoolPref(
        "extensions.webextensions.addons-restricted-domains@mozilla.com.disabled",
        false
      )
    ) {
      Services.prefs.setBoolPref(
        "extensions.quarantinedDomains.enabled",
        false
      );
      logger.debug(
        "Disabled quarantined domains because the system add-on was disabled"
      );
    }

    Glean.extensions.useRemotePolicy.set(
      WebExtensionPolicy.useRemoteWebExtensions
    );
    Glean.extensions.useRemotePref.set(
      Services.prefs.getBoolPref(PREF_USE_REMOTE)
    );
    Services.prefs.addObserver(PREF_USE_REMOTE, this);

    Glean.extensions.allowExecuteScriptInMozExtension.set(
      lazy.ALLOW_EXECUTESCRIPT_IN_MOZEXTENSION
    );

    logger.debug("Completed startup sequence");
    this.callManagerListeners("onStartup");
  },

  /**
   * Registers a new AddonProvider.
   *
   * @param {string} aProvider -The provider to register
   * @param {string[]} [aTypes] - An optional array of add-on types
   */
  registerProvider(aProvider, aTypes) {
    if (!aProvider || typeof aProvider != "object") {
      throw Components.Exception(
        "aProvider must be specified",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aTypes && !Array.isArray(aTypes)) {
      throw Components.Exception(
        "aTypes must be an array or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.pendingProviders.add(aProvider);

    if (aTypes) {
      this.typesByProvider.set(aProvider, new Set(aTypes));
    }

    // If we're registering after startup call this provider's startup.
    if (gStarted) {
      this._startProvider(aProvider);
    }
  },

  /**
   * Unregisters an AddonProvider.
   *
   * @param  aProvider
   *         The provider to unregister
   * @return Whatever the provider's 'shutdown' method returns (if anything).
   *         For providers that have async shutdown methods returning Promises,
   *         the caller should wait for that Promise to resolve.
   */
  unregisterProvider(aProvider) {
    if (!aProvider || typeof aProvider != "object") {
      throw Components.Exception(
        "aProvider must be specified",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.providers.delete(aProvider);
    // The test harness will unregister XPIProvider *after* shutdown, which is
    // after the provider will have been moved from providers to
    // pendingProviders.
    this.pendingProviders.delete(aProvider);

    this.typesByProvider.delete(aProvider);

    // If we're unregistering after startup but before shutting down,
    // remove the blocker for this provider's shutdown and call it.
    // If we're already shutting down, just let gFinalShutdownBarrier
    // call it to avoid races.
    if (gStarted && !gShutdownInProgress) {
      logger.debug(
        "Unregistering shutdown blocker for " + providerName(aProvider)
      );
      let shutter = this.providerShutdowns.get(aProvider);
      if (shutter) {
        this.providerShutdowns.delete(aProvider);
        gFinalShutdownBarrier.client.removeBlocker(shutter);
        return shutter();
      }
    }
    return undefined;
  },

  /**
   * Mark a provider as safe to access via AddonManager APIs, before its
   * startup has completed.
   *
   * Normally a provider isn't marked as safe until after its (synchronous)
   * startup() method has returned. Until a provider has been marked safe,
   * it won't be used by any of the AddonManager APIs. markProviderSafe()
   * allows a provider to mark itself as safe during its startup; this can be
   * useful if the provider wants to perform tasks that block startup, which
   * happen after its required initialization tasks and therefore when the
   * provider is in a safe state.
   *
   * @param aProvider Provider object to mark safe
   */
  markProviderSafe(aProvider) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aProvider || typeof aProvider != "object") {
      throw Components.Exception(
        "aProvider must be specified",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!this.pendingProviders.has(aProvider)) {
      return;
    }

    this.pendingProviders.delete(aProvider);
    this.providers.add(aProvider);
  },

  /**
   * Calls a method on all registered providers if it exists and consumes any
   * thrown exception. Return values are ignored. Any parameters after the
   * method parameter are passed to the provider's method.
   * WARNING: Do not use for asynchronous calls; callProviders() does not
   * invoke callbacks if provider methods throw synchronous exceptions.
   *
   * @param  aMethod
   *         The method name to call
   */
  callProviders(aMethod, ...aArgs) {
    if (!aMethod || typeof aMethod != "string") {
      throw Components.Exception(
        "aMethod must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let providers = [...this.providers];
    for (let provider of providers) {
      try {
        if (aMethod in provider) {
          provider[aMethod].apply(provider, aArgs);
        }
      } catch (e) {
        reportProviderError(provider, aMethod, e);
      }
    }
  },

  /**
   * Report the current state of asynchronous shutdown
   */
  shutdownState() {
    let state = [];
    for (let barrier of [gBeforeShutdownBarrier, gFinalShutdownBarrier]) {
      if (barrier) {
        state.push({ name: barrier.client.name, state: barrier.state });
      }
    }
    state.push({
      name: "AddonRepository: async shutdown",
      state: gRepoShutdownState,
    });
    return state;
  },

  /**
   * Shuts down the addon manager and all registered providers, this must clean
   * up everything in order for automated tests to fake restarts.
   *
   * @return Promise{null} that resolves when all providers and dependent modules
   *                       have finished shutting down
   */
  async shutdownManager() {
    logger.debug("before shutdown");
    try {
      await gBeforeShutdownBarrier.wait();
    } catch (e) {
      Cu.reportError(e);
    }

    logger.debug("shutdown");
    this.callManagerListeners("onShutdown");

    if (!gStartupComplete) {
      gStartedPromise.reject("shutting down");
    }

    gRepoShutdownState = "pending";
    gShutdownInProgress = true;

    // Clean up listeners
    Services.prefs.removeObserver(PREF_EM_CHECK_COMPATIBILITY, this);
    Services.prefs.removeObserver(PREF_EM_STRICT_COMPATIBILITY, this);
    Services.prefs.removeObserver(PREF_EM_CHECK_UPDATE_SECURITY, this);
    Services.prefs.removeObserver(PREF_EM_UPDATE_ENABLED, this);
    Services.prefs.removeObserver(PREF_EM_AUTOUPDATE_DEFAULT, this);
    Services.prefs.removeObserver(PREF_REMOTESETTINGS_DISABLED, this);

    Services.obs.removeObserver(this, INTL_LOCALES_CHANGED);

    Services.obs.removeObserver(
      this,
      AMBrowserExtensionsImport.TOPIC_CANCELLED
    );
    Services.obs.removeObserver(this, AMBrowserExtensionsImport.TOPIC_COMPLETE);
    Services.obs.removeObserver(this, AMBrowserExtensionsImport.TOPIC_PENDING);
    Services.obs.removeObserver(this, XPIPROVIDER_BLOCKLIST_ATTENTION_UPDATED);

    AMRemoteSettings.shutdown();

    let savedError = null;
    // Only shut down providers if they've been started.
    if (gStarted) {
      try {
        await gFinalShutdownBarrier.wait();
      } catch (err) {
        savedError = err;
        logger.error("Failure during wait for shutdown barrier", err);
        AddonManagerPrivate.recordException(
          "AMI",
          "Async shutdown of AddonManager providers",
          err
        );
      }
    }
    gXPIProvider = null;

    // Shut down AddonRepository after providers (if any).
    try {
      gRepoShutdownState = "in progress";
      await lazy.AddonRepository.shutdown();
      gRepoShutdownState = "done";
    } catch (err) {
      savedError = err;
      logger.error("Failure during AddonRepository shutdown", err);
      AddonManagerPrivate.recordException(
        "AMI",
        "Async shutdown of AddonRepository",
        err
      );
    }

    logger.debug("Async provider shutdown done");
    this.managerListeners.clear();
    this.installListeners.clear();
    this.addonListeners.clear();
    this.providerShutdowns.clear();
    for (let type in this.startupChanges) {
      delete this.startupChanges[type];
    }
    gStarted = false;
    gStartedPromise = Promise.withResolvers();
    gStartupComplete = false;
    gFinalShutdownBarrier = null;
    gBeforeShutdownBarrier = null;
    gShutdownInProgress = false;
    if (savedError) {
      throw savedError;
    }
  },

  /**
   * Notified when a preference we're interested in has changed.
   */
  observe(aSubject, aTopic, aData) {
    switch (aTopic) {
      case INTL_LOCALES_CHANGED: {
        // Asynchronously fetch and update the addons cache.
        lazy.AddonRepository.backgroundUpdateCheck();
        return;
      }

      case AMBrowserExtensionsImport.TOPIC_CANCELLED:
      case AMBrowserExtensionsImport.TOPIC_COMPLETE:
      case AMBrowserExtensionsImport.TOPIC_PENDING:
        this.callManagerListeners("onBrowserExtensionsImportChanged");
        return;

      case XPIPROVIDER_BLOCKLIST_ATTENTION_UPDATED:
        this.callManagerListeners("onBlocklistAttentionUpdated");
        return;
    }

    switch (aData) {
      case PREF_EM_CHECK_COMPATIBILITY: {
        let oldValue = gCheckCompatibility;
        gCheckCompatibility = Services.prefs.getBoolPref(
          PREF_EM_CHECK_COMPATIBILITY,
          true
        );

        this.callManagerListeners("onCompatibilityModeChanged");

        if (gCheckCompatibility != oldValue) {
          this.updateAddonAppDisabledStates();
        }

        break;
      }
      case PREF_EM_STRICT_COMPATIBILITY: {
        let oldValue = gStrictCompatibility;
        gStrictCompatibility = Services.prefs.getBoolPref(
          PREF_EM_STRICT_COMPATIBILITY,
          true
        );

        this.callManagerListeners("onCompatibilityModeChanged");

        if (gStrictCompatibility != oldValue) {
          this.updateAddonAppDisabledStates();
        }

        break;
      }
      case PREF_EM_CHECK_UPDATE_SECURITY: {
        let oldValue = gCheckUpdateSecurity;
        gCheckUpdateSecurity = Services.prefs.getBoolPref(
          PREF_EM_CHECK_UPDATE_SECURITY,
          true
        );

        this.callManagerListeners("onCheckUpdateSecurityChanged");

        if (gCheckUpdateSecurity != oldValue) {
          this.updateAddonAppDisabledStates();
        }

        break;
      }
      case PREF_EM_UPDATE_ENABLED: {
        gUpdateEnabled = Services.prefs.getBoolPref(
          PREF_EM_UPDATE_ENABLED,
          true
        );

        this.callManagerListeners("onUpdateModeChanged");
        break;
      }
      case PREF_EM_AUTOUPDATE_DEFAULT: {
        gAutoUpdateDefault = Services.prefs.getBoolPref(
          PREF_EM_AUTOUPDATE_DEFAULT,
          true
        );

        this.callManagerListeners("onUpdateModeChanged");
        break;
      }
      case PREF_MIN_WEBEXT_PLATFORM_VERSION: {
        gWebExtensionsMinPlatformVersion = Services.prefs.getCharPref(
          PREF_MIN_WEBEXT_PLATFORM_VERSION
        );
        break;
      }
      case PREF_REMOTESETTINGS_DISABLED: {
        if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) {
          AMRemoteSettings.shutdown();
        } else {
          AMRemoteSettings.init();
        }
        break;
      }
      case PREF_USE_REMOTE: {
        Glean.extensions.useRemotePref.set(
          Services.prefs.getBoolPref(PREF_USE_REMOTE)
        );
        break;
      }
    }
  },

  /**
   * Replaces %...% strings in an addon url (update and updateInfo) with
   * appropriate values.
   *
   * @param  aAddon
   *         The Addon representing the add-on
   * @param  aUri
   *         The string representation of the URI to escape
   * @param  aAppVersion
   *         The optional application version to use for %APP_VERSION%
   * @return The appropriately escaped URI.
   */
  escapeAddonURI(aAddon, aUri, aAppVersion) {
    if (!aAddon || typeof aAddon != "object") {
      throw Components.Exception(
        "aAddon must be an Addon object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!aUri || typeof aUri != "string") {
      throw Components.Exception(
        "aUri must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aAppVersion && typeof aAppVersion != "string") {
      throw Components.Exception(
        "aAppVersion must be a string or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    var addonStatus =
      aAddon.userDisabled || aAddon.softDisabled
        ? "userDisabled"
        : "userEnabled";

    if (!aAddon.isCompatible) {
      addonStatus += ",incompatible";
    }

    let { blocklistState } = aAddon;
    if (blocklistState == Ci.nsIBlocklistService.STATE_BLOCKED) {
      addonStatus += ",blocklisted";
    }
    if (blocklistState == Ci.nsIBlocklistService.STATE_SOFTBLOCKED) {
      addonStatus += ",softblocked";
    }

    let params = new Map(
      Object.entries({
        ITEM_ID: aAddon.id,
        ITEM_VERSION: aAddon.version,
        ITEM_STATUS: addonStatus,
        APP_ID: Services.appinfo.ID,
        APP_VERSION: aAppVersion ? aAppVersion : Services.appinfo.version,
        REQ_VERSION: UPDATE_REQUEST_VERSION,
        APP_OS: Services.appinfo.OS,
        APP_ABI: Services.appinfo.XPCOMABI,
        APP_LOCALE: getLocale(),
        CURRENT_APP_VERSION: Services.appinfo.version,
      })
    );

    let uri = aUri.replace(/%([A-Z_]+)%/g, (m0, m1) => params.get(m1) || m0);

    // escape() does not properly encode + symbols in any embedded FVF strings.
    return uri.replace(/\+/g, "%2B");
  },

  updatePromptHandler(info) {
    let oldPerms = info.existingAddon.userPermissions;
    if (!oldPerms) {
      // Updating from a legacy add-on, just let it proceed
      return Promise.resolve();
    }

    if (info.existingAddon.isInstalledByEnterprisePolicy) {
      return Promise.resolve();
    }

    let newPerms = info.addon.userPermissions;
    let difference = lazy.Extension.comparePermissions(oldPerms, newPerms);

    // When an update for an existing add-on includes data collection
    // permissions, which the add-ons didn't have so far, and the manifest
    // contains a flag to indicate that there was a previous consent, then we
    // allow the update to just proceed, unless there are other new required
    // permissions.
    const updateIsMigratingToDataCollectionPerms =
      !info.existingAddon.hasDataCollectionPermissions &&
      info.install.addonHasPreviousConsent;

    // If there are no new permissions, just go ahead with the update
    if (
      !difference.origins.length &&
      !difference.permissions.length &&
      (updateIsMigratingToDataCollectionPerms ||
        !difference.data_collection.length)
    ) {
      return Promise.resolve();
    }

    return new Promise((resolve, reject) => {
      let subject = {
        wrappedJSObject: {
          addon: info.addon,
          existingAddon: info.existingAddon,
          permissions: difference,
          resolve,
          reject,
          // Reference to the related AddonInstall object (used in AMTelemetry to
          // link the recorded event to the other events from the same install flow).
          install: info.install,
        },
      };
      Services.obs.notifyObservers(
        subject,
        "webextension-update-permission-prompt"
      );
    });
  },

  // Returns true if System Addons should be updated
  systemUpdateEnabled() {
    if (!Services.prefs.getBoolPref(PREF_SYS_ADDON_UPDATE_ENABLED)) {
      return false;
    }
    if (Services.policies && !Services.policies.isAllowed("SysAddonUpdate")) {
      return false;
    }
    return true;
  },

  /**
   * Performs a background update check by starting an update for all add-ons
   * that can be updated.
   *
   * @return Promise{null} Resolves when the background update check is complete
   *                       (the resulting addon installations may still be in progress).
   */
  backgroundUpdateCheck() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    let buPromise = (async () => {
      logger.debug("Background update check beginning");

      Services.obs.notifyObservers(null, "addons-background-update-start");

      if (this.updateEnabled) {
        // Keep track of all the async add-on updates happening in parallel
        let updates = [];

        let allAddons = await this.getAllAddons();

        // Repopulate repository cache first, to ensure compatibility overrides
        // are up to date before checking for addon updates.
        await lazy.AddonRepository.backgroundUpdateCheck();

        for (let addon of allAddons) {
          // Check all add-ons for updates so that any compatibility updates will
          // be applied

          if (!(addon.permissions & AddonManager.PERM_CAN_UPGRADE)) {
            continue;
          }

          updates.push(
            new Promise(resolve => {
              addon.findUpdates(
                {
                  onUpdateAvailable(aAddon, aInstall) {
                    // Start installing updates when the add-on can be updated and
                    // background updates should be applied.
                    logger.debug("Found update for add-on ${id}", aAddon);
                    if (AddonManager.shouldAutoUpdate(aAddon)) {
                      // XXX we really should resolve when this install is done,
                      // not when update-available check completes, no?
                      logger.debug(`Starting upgrade install of ${aAddon.id}`);
                      aInstall.promptHandler = (...args) =>
                        AddonManagerInternal.updatePromptHandler(...args);
                      aInstall.install();
                    }
                  },

                  onUpdateFinished: aAddon => {
                    logger.debug("onUpdateFinished for ${id}", aAddon);
                    resolve();
                  },
                },
                AddonManager.UPDATE_WHEN_PERIODIC_UPDATE
              );
            })
          );
        }
        Services.obs.notifyObservers(
          null,
          "addons-background-updates-found",
          updates.length
        );
        await Promise.all(updates);
      }

      if (AddonManagerInternal.systemUpdateEnabled()) {
        try {
          await AddonManagerInternal._getProviderByName(
            "XPIProvider"
          ).updateSystemAddons();
        } catch (e) {
          logger.warn("Failed to update system addons", e);
        }
      }

      logger.debug("Background update check complete");
      Services.obs.notifyObservers(null, "addons-background-update-complete");
    })();
    // Fork the promise chain so we can log the error and let our caller see it too.
    buPromise.catch(e => logger.warn("Error in background update", e));
    return buPromise;
  },

  /**
   * Adds a add-on to the list of detected changes for this startup. If
   * addStartupChange is called multiple times for the same add-on in the same
   * startup then only the most recent change will be remembered.
   *
   * @param  aType
   *         The type of change as a string. Providers can define their own
   *         types of changes or use the existing defined STARTUP_CHANGE_*
   *         constants
   * @param  aID
   *         The ID of the add-on
   */
  addStartupChange(aType, aID) {
    if (!aType || typeof aType != "string") {
      throw Components.Exception(
        "aType must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!aID || typeof aID != "string") {
      throw Components.Exception(
        "aID must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (gStartupComplete) {
      return;
    }
    logger.debug("Registering startup change '" + aType + "' for " + aID);

    // Ensure that an ID is only listed in one type of change
    for (let type in this.startupChanges) {
      this.removeStartupChange(type, aID);
    }

    if (!(aType in this.startupChanges)) {
      this.startupChanges[aType] = [];
    }
    this.startupChanges[aType].push(aID);
  },

  /**
   * Removes a startup change for an add-on.
   *
   * @param  aType
   *         The type of change
   * @param  aID
   *         The ID of the add-on
   */
  removeStartupChange(aType, aID) {
    if (!aType || typeof aType != "string") {
      throw Components.Exception(
        "aType must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!aID || typeof aID != "string") {
      throw Components.Exception(
        "aID must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (gStartupComplete) {
      return;
    }

    if (!(aType in this.startupChanges)) {
      return;
    }

    this.startupChanges[aType] = this.startupChanges[aType].filter(
      aItem => aItem != aID
    );
  },

  /**
   * Calls all registered AddonManagerListeners with an event. Any parameters
   * after the method parameter are passed to the listener.
   *
   * @param  aMethod
   *         The method on the listeners to call
   */
  callManagerListeners(aMethod, ...aArgs) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aMethod || typeof aMethod != "string") {
      throw Components.Exception(
        "aMethod must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let managerListeners = new Set(this.managerListeners);
    for (let listener of managerListeners) {
      try {
        if (aMethod in listener) {
          listener[aMethod].apply(listener, aArgs);
        }
      } catch (e) {
        logger.warn(
          "AddonManagerListener threw exception when calling " + aMethod,
          e
        );
      }
    }
  },

  /**
   * Calls all registered InstallListeners with an event. Any parameters after
   * the extraListeners parameter are passed to the listener.
   *
   * @param  aMethod
   *         The method on the listeners to call
   * @param  aExtraListeners
   *         An optional array of extra InstallListeners to also call
   * @return false if any of the listeners returned false, true otherwise
   */
  callInstallListeners(aMethod, aExtraListeners, ...aArgs) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aMethod || typeof aMethod != "string") {
      throw Components.Exception(
        "aMethod must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aExtraListeners && !Array.isArray(aExtraListeners)) {
      throw Components.Exception(
        "aExtraListeners must be an array or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let result = true;
    let listeners;
    if (aExtraListeners) {
      listeners = new Set(
        aExtraListeners.concat(Array.from(this.installListeners))
      );
    } else {
      listeners = new Set(this.installListeners);
    }

    for (let listener of listeners) {
      try {
        if (aMethod in listener) {
          if (listener[aMethod].apply(listener, aArgs) === false) {
            result = false;
          }
        }
      } catch (e) {
        logger.warn(
          "InstallListener threw exception when calling " + aMethod,
          e
        );
      }
    }
    return result;
  },

  /**
   * Calls all registered AddonListeners with an event. Any parameters after
   * the method parameter are passed to the listener.
   *
   * @param  aMethod
   *         The method on the listeners to call
   */
  callAddonListeners(aMethod, ...aArgs) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aMethod || typeof aMethod != "string") {
      throw Components.Exception(
        "aMethod must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let addonListeners = new Set(this.addonListeners);
    for (let listener of addonListeners) {
      try {
        if (aMethod in listener) {
          listener[aMethod].apply(listener, aArgs);
        }
      } catch (e) {
        logger.warn("AddonListener threw exception when calling " + aMethod, e);
      }
    }
  },

  /**
   * Notifies all providers that an add-on has been enabled when that type of
   * add-on only supports a single add-on being enabled at a time. This allows
   * the providers to disable theirs if necessary.
   *
   * @param  aID
   *         The ID of the enabled add-on
   * @param  aType
   *         The type of the enabled add-on
   * @param  aPendingRestart
   *         A boolean indicating if the change will only take place the next
   *         time the application is restarted
   */
  async notifyAddonChanged(aID, aType, aPendingRestart) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (aID && typeof aID != "string") {
      throw Components.Exception(
        "aID must be a string or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!aType || typeof aType != "string") {
      throw Components.Exception(
        "aType must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    // Temporary hack until bug 520124 lands.
    // We can get here during synchronous startup, at which point it's
    // considered unsafe (and therefore disallowed by AddonManager.sys.mjs) to
    // access providers that haven't been initialized yet. Since this is when
    // XPIProvider is starting up, XPIProvider can't access itself via APIs
    // going through AddonManager.sys.mjs. Thankfully, this is the only use
    // of this API, and we know it's safe to use this API with both
    // providers; so we have this hack to allow bypassing the normal
    // safetey guard.
    // The notifyAddonChanged/addonChanged API will be unneeded and therefore
    // removed by bug 520124, so this is a temporary quick'n'dirty hack.
    let providers = [...this.providers, ...this.pendingProviders];
    for (let provider of providers) {
      let result = callProvider(
        provider,
        "addonChanged",
        null,
        aID,
        aType,
        aPendingRestart
      );
      if (result) {
        await result;
      }
    }
  },

  /**
   * Notifies all providers they need to update the appDisabled property for
   * their add-ons in response to an application change such as a blocklist
   * update.
   */
  updateAddonAppDisabledStates() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    this.callProviders("updateAddonAppDisabledStates");
  },

  /**
   * Notifies all providers that the repository has updated its data for
   * installed add-ons.
   */
  updateAddonRepositoryData() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return (async () => {
      for (let provider of this.providers) {
        await promiseCallProvider(provider, "updateAddonRepositoryData");
      }

      // only tests should care about this
      Services.obs.notifyObservers(null, "TEST:addon-repository-data-updated");
    })();
  },

  /**
   * Asynchronously gets an AddonInstall for a URL.
   *
   * @param  aUrl
   *         The string represenation of the URL where the add-on is located
   * @param  {object} [aOptions = {}]
   *         Additional options for this install
   * @param  {string} [aOptions.hash]
   *         An optional hash of the add-on
   * @param  {string} [aOptions.name]
   *         An optional placeholder name while the add-on is being downloaded
   * @param  {string | object} [aOptions.icons]
   *         Optional placeholder icons while the add-on is being downloaded
   * @param  {string} [aOptions.version]
   *         An optional placeholder version while the add-on is being downloaded
   * @param  {XULElement} [aOptions.browser]
   *         An optional <browser> element for download permissions prompts.
   * @param  {nsIPrincipal} [aOptions.triggeringPrincipal]
   *         The principal which is attempting to install the add-on.
   * @param  {object} [aOptions.telemetryInfo]
   *         An optional object which provides details about the installation source
   *         included in the addon manager telemetry events.
   * @throws if aUrl is not specified or if an optional argument of
   *         an improper type is passed.
   */
  async getInstallForURL(aUrl, aOptions = {}) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aUrl || typeof aUrl != "string") {
      throw Components.Exception(
        "aURL must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aOptions.hash && typeof aOptions.hash != "string") {
      throw Components.Exception(
        "hash must be a string or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aOptions.name && typeof aOptions.name != "string") {
      throw Components.Exception(
        "name must be a string or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aOptions.icons) {
      if (typeof aOptions.icons == "string") {
        aOptions.icons = { 32: aOptions.icons };
      } else if (typeof aOptions.icons != "object") {
        throw Components.Exception(
          "icons must be a string, an object or null",
          Cr.NS_ERROR_INVALID_ARG
        );
      }
    } else {
      aOptions.icons = {};
    }

    if (aOptions.version && typeof aOptions.version != "string") {
      throw Components.Exception(
        "version must be a string or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aOptions.browser && !Element.isInstance(aOptions.browser)) {
      throw Components.Exception(
        "aOptions.browser must be an Element or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    for (let provider of this.providers) {
      let install = await promiseCallProvider(
        provider,
        "getInstallForURL",
        aUrl,
        aOptions
      );
      if (install) {
        return install;
      }
    }

    return null;
  },

  /**
   * Asynchronously gets an AddonInstall for an nsIFile.
   *
   * @param  aFile
   *         The nsIFile where the add-on is located
   * @param  aMimetype
   *         An optional mimetype hint for the add-on
   * @param  aTelemetryInfo
   *         An optional object which provides details about the installation source
   *         included in the addon manager telemetry events.
   * @param  aUseSystemLocation
   *         If true the addon is installed into the system profile location.
   * @throws if the aFile or aCallback arguments are not specified
   */
  getInstallForFile(aFile, aMimetype, aTelemetryInfo, aUseSystemLocation) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!(aFile instanceof Ci.nsIFile)) {
      throw Components.Exception(
        "aFile must be a nsIFile",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aMimetype && typeof aMimetype != "string") {
      throw Components.Exception(
        "aMimetype must be a string or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return (async () => {
      for (let provider of this.providers) {
        let install = await promiseCallProvider(
          provider,
          "getInstallForFile",
          aFile,
          aTelemetryInfo,
          aUseSystemLocation
        );

        if (install) {
          return install;
        }
      }

      return null;
    })();
  },

  /**
   * Get a SitePermsAddonInstall instance.
   *
   * @param  {Element} aBrowser: The optional browser element that started the install
   * @param {nsIPrincipal} aInstallingPrincipal
   * @param {string} aSitePerm
   * @returns {Promise<SitePermsAddonInstall|null>} The promise will resolve with null if there
   *         are no provider with a getSitePermsAddonInstallForWebpage method. In practice,
   *         this should only be the case when SitePermsAddonProvider is not enabled,
   *         i.e. when dom.sitepermsaddon-provider.enabled is false.
   * @throws {Components.Exception} Will throw an error if:
   *         - the AddonManager is not initialized
   *         - `aInstallingPrincipal` is not a nsIPrincipal
   *         - `aInstallingPrincipal` scheme is not https
   *         - `aInstallingPrincipal` is a public etld
   *         - `aInstallingPrincipal` is a plain ip address
   *         - `aInstallingPrincipal` is in the blocklist
   *         - `aSitePerm` is not a gated permission
   *         - `aBrowser` is not null and not an element
   */
  async getSitePermsAddonInstallForWebpage(
    aBrowser,
    aInstallingPrincipal,
    aSitePerm
  ) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (
      !aInstallingPrincipal ||
      !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
    ) {
      throw Components.Exception(
        "aInstallingPrincipal must be a nsIPrincipal",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aBrowser && !Element.isInstance(aBrowser)) {
      throw Components.Exception(
        "aBrowser must be an Element, or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (lazy.isPrincipalInSitePermissionsBlocklist(aInstallingPrincipal)) {
      throw Components.Exception(
        `SitePermsAddons can't be installed`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    // Block install from null principal.
    // /!\ We need to do this check before checking if this is a remote origin iframe,
    // otherwise isThirdPartyPrincipal might throw.
    if (aInstallingPrincipal.isNullPrincipal) {
      throw Components.Exception(
        `SitePermsAddons can't be installed from sandboxed subframes`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    // Block install from remote origin iframe
    if (
      aBrowser &&
      aBrowser.contentPrincipal.isThirdPartyPrincipal(aInstallingPrincipal)
    ) {
      throw Components.Exception(
        `SitePermsAddons can't be installed from cross origin subframes`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aInstallingPrincipal.isIpAddress) {
      throw Components.Exception(
        `SitePermsAddons install disallowed when the host is an IP address`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    // Gated APIs should probably not be available on non-secure origins,
    // but let's double check here.
    if (aInstallingPrincipal.scheme !== "https") {
      throw Components.Exception(
        `SitePermsAddons can only be installed from secure origins`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    // Install origin cannot be on a known etld (e.g. github.io).
    if (lazy.isKnownPublicSuffix(aInstallingPrincipal.siteOriginNoSuffix)) {
      throw Components.Exception(
        `SitePermsAddon can't be installed from public eTLDs ${aInstallingPrincipal.siteOriginNoSuffix}`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!lazy.isGatedPermissionType(aSitePerm)) {
      throw Components.Exception(
        `"${aSitePerm}" is not a gated permission`,
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    for (let provider of this.providers) {
      let install = await promiseCallProvider(
        provider,
        "getSitePermsAddonInstallForWebpage",
        aInstallingPrincipal,
        aSitePerm
      );
      if (install) {
        return install;
      }
    }

    return null;
  },

  /**
   * Uninstall an addon from the system profile location.
   *
   * @param {string} aID
   *         The ID of the addon to remove.
   * @returns A promise that resolves when the addon is uninstalled.
   */
  uninstallSystemProfileAddon(aID) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }
    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).uninstallSystemProfileAddon(aID);
  },

  /**
   * Asynchronously gets all current AddonInstalls optionally limiting to a list
   * of types.
   *
   * @param  aTypes
   *         An optional array of types to retrieve. Each type is a string name
   * @throws If the aCallback argument is not specified
   */
  getInstallsByTypes(aTypes) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (aTypes && !Array.isArray(aTypes)) {
      throw Components.Exception(
        "aTypes must be an array or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return (async () => {
      let installs = [];

      for (let provider of this.providers) {
        let providerInstalls = await promiseCallProvider(
          provider,
          "getInstallsByTypes",
          aTypes
        );

        if (providerInstalls) {
          installs.push(...providerInstalls);
        }
      }

      return installs;
    })();
  },

  /**
   * Asynchronously gets all current AddonInstalls.
   */
  getAllInstalls() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return this.getInstallsByTypes(null);
  },

  /**
   * Checks whether installation is enabled for a particular mimetype.
   *
   * @param  aMimetype
   *         The mimetype to check
   * @return true if installation is enabled for the mimetype
   */
  isInstallEnabled(aMimetype) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aMimetype || typeof aMimetype != "string") {
      throw Components.Exception(
        "aMimetype must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let providers = [...this.providers];
    for (let provider of providers) {
      if (
        callProvider(provider, "supportsMimetype", false, aMimetype) &&
        callProvider(provider, "isInstallEnabled")
      ) {
        return true;
      }
    }
    return false;
  },

  /**
   * Checks whether a particular source is allowed to install add-ons of a
   * given mimetype.
   *
   * @param  aMimetype
   *         The mimetype of the add-on
   * @param  aInstallingPrincipal
   *         The nsIPrincipal that initiated the install
   * @return true if the source is allowed to install this mimetype
   */
  isInstallAllowed(aMimetype, aInstallingPrincipal) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aMimetype || typeof aMimetype != "string") {
      throw Components.Exception(
        "aMimetype must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (
      !aInstallingPrincipal ||
      !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
    ) {
      throw Components.Exception(
        "aInstallingPrincipal must be a nsIPrincipal",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (
      this.isInstallAllowedByPolicy(
        aInstallingPrincipal,
        null,
        true /* explicit */
      )
    ) {
      return true;
    }

    let providers = [...this.providers];
    for (let provider of providers) {
      if (
        callProvider(provider, "supportsMimetype", false, aMimetype) &&
        callProvider(provider, "isInstallAllowed", null, aInstallingPrincipal)
      ) {
        return true;
      }
    }
    return false;
  },

  /**
   * Checks whether a particular source is allowed to install add-ons based
   * on policy.
   *
   * @param  aInstallingPrincipal
   *         The nsIPrincipal that initiated the install
   * @param  aInstall
   *         The AddonInstall to be installed
   * @param  explicit
   *         If this is set, we only return true if the source is explicitly
   *         blocked via policy.
   *
   * @return boolean
   *         By default, returns true if the source is blocked by policy
   *         or there is no policy.
   *         If explicit is set, only returns true of the source is
   *         blocked by policy, false otherwise. This is needed for
   *         handling inverse cases.
   */
  isInstallAllowedByPolicy(aInstallingPrincipal, aInstall, explicit) {
    if (Services.policies) {
      let extensionSettings = Services.policies.getExtensionSettings("*");
      if (extensionSettings && extensionSettings.install_sources) {
        if (
          (!aInstall ||
            Services.policies.allowedInstallSource(aInstall.sourceURI)) &&
          (!aInstallingPrincipal ||
            !aInstallingPrincipal.URI ||
            Services.policies.allowedInstallSource(aInstallingPrincipal.URI))
        ) {
          return true;
        }
        return false;
      }
    }
    return !explicit;
  },

  installNotifyObservers(
    aTopic,
    aBrowser,
    aUri,
    aInstall,
    aInstallFn,
    aCancelFn
  ) {
    let info = {
      wrappedJSObject: {
        browser: aBrowser,
        originatingURI: aUri,
        installs: [aInstall],
        install: aInstallFn,
        cancel: aCancelFn,
      },
    };
    Services.obs.notifyObservers(info, aTopic);
  },

  startInstall(browser, url, install) {
    this.installNotifyObservers("addon-install-started", browser, url, install);

    // Local installs may already be in a failed state in which case
    // we won't get any further events, detect those cases now.
    if (
      install.state == AddonManager.STATE_DOWNLOADED &&
      (install.addon.appDisabled ||
        install.addon.blocklistState !=
          Ci.nsIBlocklistService.STATE_NOT_BLOCKED)
    ) {
      install.cancel();
      this.installNotifyObservers(
        "addon-install-failed",
        browser,
        url,
        install
      );
      return;
    }

    let self = this;
    let listener = {
      onDownloadCancelled() {
        install.removeListener(listener);
      },

      onDownloadFailed() {
        install.removeListener(listener);
        self.installNotifyObservers(
          "addon-install-failed",
          browser,
          url,
          install
        );
      },

      onDownloadEnded() {
        if (
          install.addon.appDisabled ||
          install.addon.blocklistState !=
            Ci.nsIBlocklistService.STATE_NOT_BLOCKED
        ) {
          install.removeListener(listener);
          install.cancel();
          self.installNotifyObservers(
            "addon-install-failed",
            browser,
            url,
            install
          );
        }
      },

      onInstallCancelled() {
        install.removeListener(listener);
      },

      onInstallFailed() {
        install.removeListener(listener);
        self.installNotifyObservers(
          "addon-install-failed",
          browser,
          url,
          install
        );
      },

      onInstallEnded() {
        install.removeListener(listener);

        // If installing a theme that is disabled and can be enabled
        // then enable it
        if (
          install.addon.type == "theme" &&
          !!install.addon.userDisabled &&
          !install.addon.appDisabled
        ) {
          install.addon.enable();
        }

        let subject = {
          wrappedJSObject: { target: browser, addon: install.addon },
        };
        Services.obs.notifyObservers(subject, "webextension-install-notify");
      },
    };

    install.addListener(listener);

    // Start downloading if it hasn't already begun
    install.install();
  },

  /**
   * Starts installation of a SitePermsAddonInstall notifying the registered
   * web install listener of a blocked or started install.
   *
   * @param  aBrowser
   *         The optional browser element that started the install
   * @param  aInstallingPrincipal
   *         The nsIPrincipal that initiated the install
   * @param  aPermission
   *         The permission to install
   * @returns {Promise} A promise that will resolve when the user installs the addon.
   *         The promise will reject if the user blocked the install, or if the addon
   *         can't be installed (e.g. the principal isn't supported).
   * @throws {Components.Exception} Will throw an error if the AddonManager is not initialized
   *         or if `aInstallingPrincipal` is not a nsIPrincipal.
   */
  async installSitePermsAddonFromWebpage(
    aBrowser,
    aInstallingPrincipal,
    aPermission
  ) {
    const synthAddonInstall =
      await AddonManagerInternal.getSitePermsAddonInstallForWebpage(
        aBrowser,
        aInstallingPrincipal,
        aPermission
      );
    const promiseInstall = new Promise((resolve, reject) => {
      const installListener = {
        onInstallFailed() {
          synthAddonInstall.removeListener(installListener);
          reject(new Error("Install Failed"));
        },

        onInstallCancelled() {
          synthAddonInstall.removeListener(installListener);
          reject(new Error("Install Cancelled"));
        },

        onInstallEnded() {
          synthAddonInstall.removeListener(installListener);
          resolve();
        },
      };
      synthAddonInstall.addListener(installListener);
    });

    let startInstall = () => {
      AddonManagerInternal.setupPromptHandler(
        aBrowser,
        aInstallingPrincipal.URI,
        synthAddonInstall,
        true,
        "SitePermissionAddonPrompt"
      );

      AddonManagerInternal.startInstall(
        aBrowser,
        aInstallingPrincipal.URI,
        synthAddonInstall
      );
    };

    startInstall();

    return promiseInstall;
  },

  async installGeckoViewWebExtension(extensionUri) {
    const installId = Services.uuid.generateUUID().toString();
    let { extension } = await lazy.GeckoViewWebExtension.installWebExtension(
      installId,
      extensionUri
    );
    if (lazy.PrivateBrowsingUtils.permanentPrivateBrowsing) {
      extension = await lazy.GeckoViewWebExtension.setPrivateBrowsingAllowed(
        extension.webExtensionId,
        true
      );
    }
    await lazy.EventDispatcher.instance.sendRequest({
      type: "GeckoView:WebExtension:OnInstalled",
      extension,
    });
  },

  /**
   * Starts installation of an AddonInstall notifying the registered
   * web install listener of a blocked or started install.
   *
   * @param  aMimetype
   *         The mimetype of the add-on being installed
   * @param  aBrowser
   *         The optional browser element that started the install
   * @param  aInstallingPrincipal
   *         The nsIPrincipal that initiated the install
   * @param  aInstall
   *         The AddonInstall to be installed
   * @param  [aDetails]
   *         Additional optional details
   * @param  [aDetails.hasCrossOriginAncestor]
   *         Boolean value set to true if any of cross-origin ancestors of the triggering frame
   *         (if set to true the installation will be denied).
   */
  installAddonFromWebpage(
    aMimetype,
    aBrowser,
    aInstallingPrincipal,
    aInstall,
    aDetails
  ) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aMimetype || typeof aMimetype != "string") {
      throw Components.Exception(
        "aMimetype must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (aBrowser && !Element.isInstance(aBrowser)) {
      throw Components.Exception(
        "aSource must be an Element, or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (
      !aInstallingPrincipal ||
      !(aInstallingPrincipal instanceof Ci.nsIPrincipal)
    ) {
      throw Components.Exception(
        "aInstallingPrincipal must be a nsIPrincipal",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    // When a chrome in-content UI has loaded a <browser> inside to host a
    // website we want to do our security checks on the inner-browser but
    // notify front-end that install events came from the top browser (the
    // main tab's browser).
    // aBrowser is null in GeckoView.
    let topBrowser = aBrowser?.browsingContext.top.embedderElement;
    try {
      // Use fullscreenElement to check for DOM fullscreen, while still allowing
      // macOS fullscreen, which still has a browser chrome.
      if (topBrowser && topBrowser.ownerDocument.fullscreenElement) {
        // Addon installation and the resulting notifications should be
        // blocked in DOM fullscreen for security and usability reasons.
        // Installation prompts in fullscreen can trick the user into
        // installing unwanted addons.
        // In fullscreen the notification box does not have a clear
        // visual association with its parent anymore.
        aInstall.cancel();

        this.installNotifyObservers(
          "addon-install-fullscreen-blocked",
          topBrowser,
          aInstallingPrincipal.URI,
          aInstall
        );
        return;
      } else if (!this.isInstallEnabled(aMimetype)) {
        aInstall.cancel();

        this.installNotifyObservers(
          "addon-install-disabled",
          topBrowser,
          aInstallingPrincipal.URI,
          aInstall
        );
        return;
      } else if (
        !this.isInstallAllowedByPolicy(
          aInstallingPrincipal,
          aInstall,
          false /* explicit */
        )
      ) {
        aInstall.cancel();

        this.installNotifyObservers(
          "addon-install-policy-blocked",
          topBrowser,
          aInstallingPrincipal.URI,
          aInstall
        );
        return;
      } else if (
        // Block the install request if the triggering frame does have any cross-origin
        // ancestor.
        aDetails?.hasCrossOriginAncestor ||
        // Block the install if triggered by a null principal.
        aInstallingPrincipal.isNullPrincipal ||
        (aBrowser &&
          (!aBrowser.contentPrincipal ||
            // When we attempt to handle an XPI load immediately after a
            // process switch, the DocShell it's being loaded into will have
            // a null principal, since it won't have been initialized yet.
            // Allowing installs in this case is relatively safe, since
            // there isn't much to gain by spoofing an install request from
            // a null principal in any case. This exception can be removed
            // once content handlers are triggered by DocumentChannel in the
            // parent process.
            !(
              aBrowser.contentPrincipal.isNullPrincipal ||
              aInstallingPrincipal.subsumes(aBrowser.contentPrincipal)
            )))
      ) {
        aInstall.cancel();

        this.installNotifyObservers(
          "addon-install-origin-blocked",
          topBrowser,
          aInstallingPrincipal.URI,
          aInstall
        );
        return;
      }

      if (aBrowser) {
        // The install may start now depending on the web install listener,
        // listen for the browser navigating to a new origin and cancel the
        // install in that case.
        new BrowserListener(aBrowser, aInstallingPrincipal, aInstall);
      }

      let startInstall = source => {
        AddonManagerInternal.setupPromptHandler(
          aBrowser,
          aInstallingPrincipal.URI,
          aInstall,
          true,
          source
        );

        AddonManagerInternal.startInstall(
          aBrowser,
          aInstallingPrincipal.URI,
          aInstall
        );
      };

      let installAllowed = this.isInstallAllowed(
        aMimetype,
        aInstallingPrincipal
      );
      let installPerm = Services.perms.testPermissionFromPrincipal(
        aInstallingPrincipal,
        "install"
      );

      if (installAllowed) {
        if (AppConstants.platform == "android") {
          aInstall.cancel();
          this.installGeckoViewWebExtension(aInstall.sourceURI);
          return;
        }
        startInstall("AMO");
      } else if (installPerm === Ci.nsIPermissionManager.DENY_ACTION) {
        // Block without prompt
        aInstall.cancel();
        this.installNotifyObservers(
          "addon-install-blocked-silent",
          topBrowser,
          aInstallingPrincipal.URI,
          aInstall
        );
      } else if (!lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY) {
        // Block with prompt
        this.installNotifyObservers(
          "addon-install-blocked",
          topBrowser,
          aInstallingPrincipal.URI,
          aInstall,
          () => startInstall("other"),
          () => aInstall.cancel()
        );
      } else {
        // We download the addon and validate whether a 3rd party
        // install prompt should be shown using e.g. recommended
        // state and install_origins.
        logger.info(`Addon download before validation.`);
        startInstall("other");
      }
    } catch (e) {
      // In the event that the weblistener throws during instantiation or when
      // calling onWebInstallBlocked or onWebInstallRequested the
      // install should get cancelled.
      logger.warn("Failure calling web installer", e);
      aInstall.cancel();
    }
  },

  /**
   * Starts installation of an AddonInstall created from add-ons manager
   * front-end code (e.g., drag-and-drop of xpis or "Install Add-on from File"
   *
   * @param  browser
   *         The browser element where the installation was initiated
   * @param  uri
   *         The URI of the page where the installation was initiated
   * @param  install
   *         The AddonInstall to be installed
   * @param  options
   *         Optional - An object containing the following options:
   *
   *         "options.preferUpdateOverInstall" - Prefer update over install
   *         when there is an existing add-on for the `install` object.
   */
  installAddonFromAOM(browser, uri, install, options = {}) {
    if (!this.isInstallAllowedByPolicy(null, install)) {
      install.cancel();

      this.installNotifyObservers(
        "addon-install-policy-blocked",
        browser,
        install.sourceURI,
        install
      );
      return;
    }
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!!options.preferUpdateOverInstall && install.existingAddon) {
      install.promptHandler = (...args) =>
        AddonManagerInternal.updatePromptHandler(...args);
    } else {
      AddonManagerInternal.setupPromptHandler(
        browser,
        uri,
        install,
        true,
        "local"
      );
    }

    AddonManagerInternal.startInstall(browser, uri, install);
  },

  /**
   * Adds a new InstallListener if the listener is not already registered.
   *
   * @param  aListener
   *         The InstallListener to add
   */
  addInstallListener(aListener) {
    if (!aListener || typeof aListener != "object") {
      throw Components.Exception(
        "aListener must be a InstallListener object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.installListeners.add(aListener);
  },

  /**
   * Removes an InstallListener if the listener is registered.
   *
   * @param  aListener
   *         The InstallListener to remove
   */
  removeInstallListener(aListener) {
    if (!aListener || typeof aListener != "object") {
      throw Components.Exception(
        "aListener must be a InstallListener object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.installListeners.delete(aListener);
  },
  /**
   * Adds new or overrides existing UpgradeListener.
   *
   * @param  aInstanceID
   *         The instance ID of an addon to register a listener for.
   * @param  aCallback
   *         The callback to invoke when updates are available for this addon.
   * @throws if there is no addon matching the instanceID
   */
  addUpgradeListener(aInstanceID, aCallback) {
    if (!aInstanceID || typeof aInstanceID != "symbol") {
      throw Components.Exception(
        "aInstanceID must be a symbol",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!aCallback || typeof aCallback != "function") {
      throw Components.Exception(
        "aCallback must be a function",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let addonId = this.syncGetAddonIDByInstanceID(aInstanceID);
    if (!addonId) {
      throw Error(`No addon matching instanceID: ${String(aInstanceID)}`);
    }
    logger.debug(`Registering upgrade listener for ${addonId}`);
    this.upgradeListeners.set(addonId, aCallback);
  },

  /**
   * Removes an UpgradeListener if the listener is registered.
   *
   * @param  aInstanceID
   *         The instance ID of the addon to remove
   */
  removeUpgradeListener(aInstanceID) {
    if (!aInstanceID || typeof aInstanceID != "symbol") {
      throw Components.Exception(
        "aInstanceID must be a symbol",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let addonId = this.syncGetAddonIDByInstanceID(aInstanceID);
    if (!addonId) {
      throw Error(`No addon for instanceID: ${aInstanceID}`);
    }
    if (this.upgradeListeners.has(addonId)) {
      this.upgradeListeners.delete(addonId);
    } else {
      throw Error(`No upgrade listener registered for addon ID: ${addonId}`);
    }
  },

  addExternalExtensionLoader(loader) {
    this.externalExtensionLoaders.set(loader.name, loader);
  },

  /**
   * Installs a temporary add-on from a local file or directory.
   *
   * @param  aFile
   *         An nsIFile for the file or directory of the add-on to be
   *         temporarily installed.
   * @returns a Promise that rejects if the add-on is not a valid restartless
   *          add-on or if the same ID is already temporarily installed.
   */
  installTemporaryAddon(aFile) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!(aFile instanceof Ci.nsIFile)) {
      throw Components.Exception(
        "aFile must be a nsIFile",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).installTemporaryAddon(aFile);
  },

  /**
   * Installs an add-on from a built-in location
   *  (ie a resource: url referencing assets shipped with the application)
   *
   * @param  aBase
   *         A string containing the base URL.  Must be a resource: URL.
   * @returns a Promise that resolves when the addon is installed.
   */
  installBuiltinAddon(aBase) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).installBuiltinAddon(aBase);
  },

  /**
   * Like `installBuiltinAddon`, but only installs the addon at `aBase`
   * if an existing built-in addon with the ID `aID` and version doesn't
   * already exist.
   *
   * @param {string} aID
   *        The ID of the add-on being registered.
   * @param {string} aVersion
   *        The version of the add-on being registered.
   * @param {string} aBase
   *        A string containing the base URL.  Must be a resource: URL.
   * @returns a Promise that resolves when the addon is installed.
   */
  maybeInstallBuiltinAddon(aID, aVersion, aBase) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).maybeInstallBuiltinAddon(aID, aVersion, aBase);
  },

  syncGetAddonIDByInstanceID(aInstanceID) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aInstanceID || typeof aInstanceID != "symbol") {
      throw Components.Exception(
        "aInstanceID must be a Symbol()",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).getAddonIDByInstanceID(aInstanceID);
  },

  /**
   * Gets an icon from the icon set provided by the add-on
   * that is closest to the specified size.
   *
   * The optional window parameter will be used to determine
   * the screen resolution and select a more appropriate icon.
   * Calling this method with 48px on retina screens will try to
   * match an icon of size 96px.
   *
   * @param  aAddon
   *         An addon object, meaning:
   *         An object with either an icons property that is a key-value list
   *         of icon size and icon URL, or an object having an iconURL property.
   * @param  aSize
   *         Ideal icon size in pixels
   * @param  aWindow
   *         Optional window object for determining the correct scale.
   * @return {string} The absolute URL of the icon or null if the addon doesn't have icons
   */
  getPreferredIconURL(aAddon, aSize, aWindow = undefined) {
    if (aWindow && aWindow.devicePixelRatio) {
      aSize *= aWindow.devicePixelRatio;
    }

    let icons = aAddon.icons;

    // certain addon-types only have iconURLs
    if (!icons) {
      icons = {};
      if (aAddon.iconURL) {
        icons[32] = aAddon.iconURL;
        icons[48] = aAddon.iconURL;
      }
    }

    // quick return if the exact size was found
    if (icons[aSize]) {
      return icons[aSize];
    }

    let bestSize = null;

    for (let size of Object.keys(icons)) {
      if (!INTEGER.test(size)) {
        throw Components.Exception(
          "Invalid icon size, must be an integer",
          Cr.NS_ERROR_ILLEGAL_VALUE
        );
      }

      size = parseInt(size, 10);

      if (!bestSize) {
        bestSize = size;
        continue;
      }

      if (size > aSize && bestSize > aSize) {
        // If both best size and current size are larger than the wanted size then choose
        // the one closest to the wanted size
        bestSize = Math.min(bestSize, size);
      } else {
        // Otherwise choose the largest of the two so we'll prefer sizes as close to below aSize
        // or above aSize
        bestSize = Math.max(bestSize, size);
      }
    }

    return icons[bestSize] || null;
  },

  /**
   * Asynchronously gets an add-on with a specific ID.
   *
   * @type {function}
   * @param  {string} aID
   *         The ID of the add-on to retrieve
   * @returns {Promise} resolves with the found Addon or null if no such add-on exists. Never rejects.
   * @throws if the aID argument is not specified
   */
  getAddonByID(aID) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aID || typeof aID != "string") {
      throw Components.Exception(
        "aID must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let promises = Array.from(this.providers, p =>
      promiseCallProvider(p, "getAddonByID", aID)
    );
    return Promise.all(promises).then(aAddons => {
      return aAddons.find(a => !!a) || null;
    });
  },

  /**
   * Asynchronously get an add-on with a specific Sync GUID.
   *
   * @param  aGUID
   *         String GUID of add-on to retrieve
   * @throws if the aGUID argument is not specified
   */
  getAddonBySyncGUID(aGUID) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!aGUID || typeof aGUID != "string") {
      throw Components.Exception(
        "aGUID must be a non-empty string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return (async () => {
      for (let provider of this.providers) {
        let addon = await promiseCallProvider(
          provider,
          "getAddonBySyncGUID",
          aGUID
        );

        if (addon) {
          return addon;
        }
      }

      return null;
    })();
  },

  /**
   * Asynchronously gets an array of add-ons.
   *
   * @param  aIDs
   *         The array of IDs to retrieve
   * @returns {Promise}
   *   Resolves to the array of found add-ons.
   * @rejects Never
   * @throws if the aIDs argument is not specified
   */
  getAddonsByIDs(aIDs) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!Array.isArray(aIDs)) {
      throw Components.Exception(
        "aIDs must be an array",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let promises = aIDs.map(a => AddonManagerInternal.getAddonByID(a));
    return Promise.all(promises);
  },

  /**
   * Asynchronously gets add-ons of specific types.
   *
   * @param  aTypes
   *         An optional array of types to retrieve. Each type is a string name
   */
  getAddonsByTypes(aTypes) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (aTypes && !Array.isArray(aTypes)) {
      throw Components.Exception(
        "aTypes must be an array or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return (async () => {
      let addons = [];

      for (let provider of this.providers) {
        let providerAddons = await promiseCallProvider(
          provider,
          "getAddonsByTypes",
          aTypes
        );

        if (providerAddons) {
          addons.push(...providerAddons);
        }
      }

      return addons;
    })();
  },

  /**
   * Gets active add-ons of specific types.
   *
   * This is similar to getAddonsByTypes() but it may return a limited
   * amount of information about only active addons.  Consequently, it
   * can be implemented by providers using only immediately available
   * data as opposed to getAddonsByTypes which may require I/O).
   *
   * @param  aTypes
   *         An optional array of types to retrieve. Each type is a string name
   *
   * @returns {Promise<{addons: Array, fullData: boolean}>}
   *   fullData is true if addons contains all the data we have on those addons.
   *   It is false if addons only contains partial data.
   */
  async getActiveAddons(aTypes) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (aTypes && !Array.isArray(aTypes)) {
      throw Components.Exception(
        "aTypes must be an array or null",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    let addons = [],
      fullData = true;

    for (let provider of this.providers) {
      let providerAddons, providerFullData;
      if ("getActiveAddons" in provider) {
        ({ addons: providerAddons, fullData: providerFullData } =
          await callProvider(provider, "getActiveAddons", null, aTypes));
      } else {
        providerAddons = await promiseCallProvider(
          provider,
          "getAddonsByTypes",
          aTypes
        );
        providerAddons = providerAddons.filter(a => a.isActive);
        providerFullData = true;
      }

      if (providerAddons) {
        addons.push(...providerAddons);
        fullData = fullData && providerFullData;
      }
    }

    return { addons, fullData };
  },

  /**
   * Asynchronously gets all installed add-ons.
   */
  getAllAddons() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return this.getAddonsByTypes(null);
  },

  getBuiltinAddonVersion(addonId) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return this._getProviderByName("XPIProvider").getBuiltinAddonVersion(
      addonId
    );
  },

  shouldShowBlocklistAttention() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return this._getProviderByName(
      "XPIProvider"
    ).shouldShowBlocklistAttention();
  },

  getBlocklistAttentionInfo() {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    return this._getProviderByName("XPIProvider").getBlocklistAttentionInfo();
  },

  /**
   * Adds a new AddonManagerListener if the listener is not already registered.
   *
   * @param {AddonManagerListener} aListener
   *         The listener to add
   */
  addManagerListener(aListener) {
    if (!aListener || typeof aListener != "object") {
      throw Components.Exception(
        "aListener must be an AddonManagerListener object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.managerListeners.add(aListener);
  },

  /**
   * Removes an AddonManagerListener if the listener is registered.
   *
   * @param {AddonManagerListener} aListener
   *         The listener to remove
   */
  removeManagerListener(aListener) {
    if (!aListener || typeof aListener != "object") {
      throw Components.Exception(
        "aListener must be an AddonManagerListener object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.managerListeners.delete(aListener);
  },

  /**
   * Adds a new AddonListener if the listener is not already registered.
   *
   * @param {AddonManagerListener} aListener
   *        The AddonListener to add.
   */
  addAddonListener(aListener) {
    if (!aListener || typeof aListener != "object") {
      throw Components.Exception(
        "aListener must be an AddonListener object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.addonListeners.add(aListener);
  },

  /**
   * Removes an AddonListener if the listener is registered.
   *
   * @param {object}  aListener
   *         The AddonListener to remove
   */
  removeAddonListener(aListener) {
    if (!aListener || typeof aListener != "object") {
      throw Components.Exception(
        "aListener must be an AddonListener object",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    this.addonListeners.delete(aListener);
  },

  /**
   * @param {string} addonType
   * @returns {boolean}
   *          Whether there is a provider that provides the given addon type.
   */
  hasAddonType(addonType) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    for (let addonTypes of this.typesByProvider.values()) {
      if (addonTypes.has(addonType)) {
        return true;
      }
    }
    return false;
  },

  get autoUpdateDefault() {
    return gAutoUpdateDefault;
  },

  set autoUpdateDefault(aValue) {
    aValue = !!aValue;
    if (aValue != gAutoUpdateDefault) {
      Services.prefs.setBoolPref(PREF_EM_AUTOUPDATE_DEFAULT, aValue);
    }
  },

  get checkCompatibility() {
    return gCheckCompatibility;
  },

  set checkCompatibility(aValue) {
    aValue = !!aValue;
    if (aValue != gCheckCompatibility) {
      if (!aValue) {
        Services.prefs.setBoolPref(PREF_EM_CHECK_COMPATIBILITY, false);
      } else {
        Services.prefs.clearUserPref(PREF_EM_CHECK_COMPATIBILITY);
      }
    }
  },

  get strictCompatibility() {
    return gStrictCompatibility;
  },

  set strictCompatibility(aValue) {
    aValue = !!aValue;
    if (aValue != gStrictCompatibility) {
      Services.prefs.setBoolPref(PREF_EM_STRICT_COMPATIBILITY, aValue);
    }
  },

  get checkUpdateSecurityDefault() {
    return gCheckUpdateSecurityDefault;
  },

  get checkUpdateSecurity() {
    return gCheckUpdateSecurity;
  },

  set checkUpdateSecurity(aValue) {
    aValue = !!aValue;
    if (aValue != gCheckUpdateSecurity) {
      if (aValue != gCheckUpdateSecurityDefault) {
        Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, aValue);
      } else {
        Services.prefs.clearUserPref(PREF_EM_CHECK_UPDATE_SECURITY);
      }
    }
  },

  get updateEnabled() {
    return gUpdateEnabled;
  },

  set updateEnabled(aValue) {
    aValue = !!aValue;
    if (aValue != gUpdateEnabled) {
      Services.prefs.setBoolPref(PREF_EM_UPDATE_ENABLED, aValue);
    }
  },

  /**
   * Verify whether we need to show the 3rd party install prompt.
   *
   * Bypass the third party install prompt if this is an install:
   *   - is an install from a recognized source
   *   - is a an addon that can bypass the panel, such as a recommended addon
   *
   * @param {browser}      browser browser user is installing from
   * @param {nsIURI}       url     URI for the principal of the installing source
   * @param {AddonInstallWrapper} install
   * @param {object}       info    information such as addon wrapper
   * @param {AddonWrapper} info.addon
   * @param {string}       source  simplified string describing source of install and is
   *                               generated based on the installing principal and checking
   *                               against site permissions and enterprise policy.
   *                               It may be one of "AMO", "local" or "other".
   * @returns {Promise}            Rejected when the installation should not proceed.
   */
  _verifyThirdPartyInstall(browser, url, install, info, source) {
    // If we are not post-download processing, this panel was already shown.
    // Otherwise, if this is from AMO or local, bypass the prompt.
    if (
      !lazy.WEBEXT_POSTDOWNLOAD_THIRD_PARTY ||
      ["AMO", "local"].includes(source)
    ) {
      return Promise.resolve();
    }

    // verify both the installing source and the xpi url are allowed.
    if (
      !info.addon.validInstallOrigins({
        installFrom: url,
        source: install.sourceURI,
      })
    ) {
      install.error = AddonManager.ERROR_INVALID_DOMAIN;
      return Promise.reject();
    }

    // Some addons such as recommended addons do not result in this prompt.
    if (info.addon.canBypassThirdParyInstallPrompt) {
      return Promise.resolve();
    }

    return new Promise((resolve, reject) => {
      this.installNotifyObservers(
        "addon-install-blocked",
        browser,
        url,
        install,
        resolve,
        reject
      );
    });
  },

  setupPromptHandler(browser, url, install, requireConfirm, source) {
    install.promptHandler = info =>
      new Promise((resolve, reject) => {
        this._verifyThirdPartyInstall(browser, url, install, info, source)
          .then(() => {
            // All installs end up in this callback when the add-on is available
            // for installation.  There are numerous different things that can
            // happen from here though.  For webextensions, if the application
            // implements webextension permission prompts, those always take
            // precedence.
            // If this add-on is not a webextension or if the application does not
            // implement permission prompts, no confirmation is displayed for
            // installs created from about:addons (in which case requireConfirm
            // is false).
            // In the remaining cases, a confirmation prompt is displayed but the
            // application may override it either by implementing the
            // "@mozilla.org/addons/web-install-prompt;1" contract or by setting
            // the customConfirmationUI preference and responding to the
            // "addon-install-confirmation" notification.  If the application
            // does not implement its own prompt, use the built-in xul dialog.
            if (info.addon.installPermissions) {
              let subject = {
                wrappedJSObject: {
                  target: browser,
                  info: Object.assign({ resolve, reject, source }, info),
                },
              };
              subject.wrappedJSObject.info.permissions =
                info.addon.installPermissions;
              Services.obs.notifyObservers(
                subject,
                "webextension-permission-prompt"
              );
            } else if (info.addon.sitePermissions) {
              // Handle prompting for DOM permissions in SitePermission addons.
              let { sitePermissions, siteOrigin } = info.addon;
              let subject = {
                wrappedJSObject: {
                  target: browser,
                  info: Object.assign(
                    { resolve, reject, source, sitePermissions, siteOrigin },
                    info
                  ),
                },
              };
              Services.obs.notifyObservers(
                subject,
                "webextension-permission-prompt"
              );
            } else if (requireConfirm) {
              // The methods below all want to call the install() or cancel()
              // method on the provided AddonInstall object to either accept
              // or reject the confirmation.  Fit that into our promise-based
              // control flow by wrapping the install object.  However,
              // xpInstallConfirm.xul matches the install object it is passed
              // with the argument passed to an InstallListener, so give it
              // access to the underlying object through the .wrapped property.
              let proxy = new Proxy(install, {
                get(target, property) {
                  if (property == "install") {
                    return resolve;
                  } else if (property == "cancel") {
                    return reject;
                  } else if (property == "wrapped") {
                    return target;
                  }
                  let result = target[property];
                  return typeof result == "function"
                    ? result.bind(target)
                    : result;
                },
              });

              // Check for a custom installation prompt that may be provided by the
              // applicaton
              if ("@mozilla.org/addons/web-install-prompt;1" in Cc) {
                try {
                  let prompt = Cc[
                    "@mozilla.org/addons/web-install-prompt;1"
                  ].getService(Ci.amIWebInstallPrompt);
                  prompt.confirm(browser, url, [proxy]);
                  return;
                } catch (e) {}
              }

              this.installNotifyObservers(
                "addon-install-confirmation",
                browser,
                url,
                proxy
              );
            } else {
              resolve();
            }
          })
          .catch(e => {
            // Error is undefined if the promise was rejected.
            if (e) {
              Cu.reportError(`Install prompt handler error: ${e}`);
            }
            reject();
          });
      });
  },

  webAPI: {
    // installs maps integer ids to AddonInstall instances.
    installs: new Map(),
    nextInstall: 0,

    sendEvent: null,
    setEventHandler(fn) {
      this.sendEvent = fn;
    },

    async getAddonByID(target, id) {
      return webAPIForAddon(await AddonManager.getAddonByID(id));
    },

    // helper to copy (and convert) the properties we care about
    copyProps(install, obj) {
      obj.state = AddonManager.stateToString(install.state);
      obj.error = AddonManager.errorToString(install.error);
      obj.progress = install.progress;
      obj.maxProgress = install.maxProgress;
    },

    forgetInstall(id) {
      let info = this.installs.get(id);
      if (!info) {
        throw new Error(`forgetInstall cannot find ${id}`);
      }
      info.install.removeListener(info.listener);
      this.installs.delete(id);
    },

    createInstall(target, options) {
      // Throw an appropriate error if the given URL is not valid
      // as an installation source.  Return silently if it is okay.
      function checkInstallUri(uri) {
        if (Services.policies && !Services.policies.allowedInstallSource(uri)) {
          // eslint-disable-next-line no-throw-literal
          return {
            success: false,
            code: "addon-install-policy-blocked",
            message: `Install from ${uri.spec} not permitted by policy`,
          };
        }

        if (WEBAPI_INSTALL_HOSTS.includes(uri.host)) {
          return { success: true };
        }
        if (
          Services.prefs.getBoolPref(PREF_WEBAPI_TESTING, false) &&
          WEBAPI_TEST_INSTALL_HOSTS.includes(uri.host)
        ) {
          return { success: true };
        }

        // eslint-disable-next-line no-throw-literal
        return {
          success: false,
          code: "addon-install-webapi-blocked",
          message: `Install from ${uri.host} not permitted`,
        };
      }

      const makeListener = (id, mm) => {
        const events = [
          "onDownloadStarted",
          "onDownloadProgress",
          "onDownloadEnded",
          "onDownloadCancelled",
          "onDownloadFailed",
          "onInstallStarted",
          "onInstallEnded",
          "onInstallCancelled",
          "onInstallFailed",
        ];

        let listener = {};
        let installPromise = new Promise((resolve, reject) => {
          events.forEach(event => {
            listener[event] = (install, addon) => {
              let data = { event, id };
              AddonManager.webAPI.copyProps(install, data);
              this.sendEvent(mm, data);
              if (event == "onInstallEnded") {
                resolve(addon);
              } else if (
                event == "onDownloadFailed" ||
                event == "onInstallFailed"
              ) {
                reject({ message: "install failed" });
              } else if (
                event == "onDownloadCancelled" ||
                event == "onInstallCancelled"
              ) {
                reject({ message: "install cancelled" });
              } else if (event == "onDownloadEnded") {
                if (
                  install.addon.appDisabled ||
                  install.addon.blocklistState !=
                    Ci.nsIBlocklistService.STATE_NOT_BLOCKED
                ) {
                  install.cancel();
                  AddonManagerInternal.installNotifyObservers(
                    "addon-install-failed",
                    target,
                    Services.io.newURI(options.url),
                    install
                  );
                }
              }
            };
          });
        });

        // We create the promise here since this is where we're setting
        // up the InstallListener, but if the install is never started,
        // no handlers will be attached so make sure we terminate errors.
        installPromise.catch(() => {});

        return { listener, installPromise };
      };

      let uri;
      try {
        uri = Services.io.newURI(options.url);
        const { success, code, message } = checkInstallUri(uri);
        if (!success) {
          let info = {
            wrappedJSObject: {
              browser: target,
              originatingURI: uri,
              installs: [],
            },
          };
          Cu.reportError(`${code}: ${message}`);
          Services.obs.notifyObservers(info, code);
          return Promise.reject({ code, message });
        }
      } catch (err) {
        // Reject Components.Exception errors (e.g. NS_ERROR_MALFORMED_URI) as is.
        if (err instanceof Components.Exception) {
          return Promise.reject({ message: err.message });
        }
        return Promise.reject({
          message: "Install Failed on unexpected error",
        });
      }

      return AddonManagerInternal.getInstallForURL(options.url, {
        browser: target,
        triggeringPrincipal: options.triggeringPrincipal,
        hash: options.hash,
        telemetryInfo: {
          source: AddonManager.getInstallSourceFromHost(options.sourceHost),
          sourceURL: options.sourceURL,
          method: "amWebAPI",
        },
      }).then(install => {
        let requireConfirm = true;
        if (
          target.contentDocument &&
          target.contentDocument.nodePrincipal.isSystemPrincipal
        ) {
          requireConfirm = false;
        }
        AddonManagerInternal.setupPromptHandler(
          target,
          null,
          install,
          requireConfirm,
          "AMO"
        );

        let id = this.nextInstall++;
        let { listener, installPromise } = makeListener(
          id,
          target.messageManager
        );
        install.addListener(listener);

        this.installs.set(id, {
          install,
          target,
          listener,
          installPromise,
          messageManager: target.messageManager,
        });

        let result = { id };
        this.copyProps(install, result);
        return result;
      });
    },

    async sendAbuseReport(target, addonId, data, options) {
      return lazy.AbuseReporter.sendAbuseReport(addonId, data, options);
    },

    async addonUninstall(target, id) {
      let addon = await AddonManager.getAddonByID(id);
      if (!addon) {
        return false;
      }

      if (!(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
        return Promise.reject({ message: "Addon cannot be uninstalled" });
      }

      try {
        addon.uninstall();
        return true;
      } catch (err) {
        Cu.reportError(err);
        return false;
      }
    },

    async addonSetEnabled(target, id, value) {
      let addon = await AddonManager.getAddonByID(id);
      if (!addon) {
        throw new Error(`No such addon ${id}`);
      }

      if (value) {
        await addon.enable();
      } else {
        await addon.disable();
      }
    },

    async addonInstallDoInstall(target, id) {
      let state = this.installs.get(id);
      if (!state) {
        throw new Error(`invalid id ${id}`);
      }

      let addon = await state.install.install();

      if (addon.type == "theme" && !addon.appDisabled) {
        await addon.enable();
      }

      await new Promise(resolve => {
        let subject = {
          wrappedJSObject: { target, addon, callback: resolve },
        };
        Services.obs.notifyObservers(subject, "webextension-install-notify");
      });
    },

    addonInstallCancel(target, id) {
      let state = this.installs.get(id);
      if (!state) {
        return Promise.reject(`invalid id ${id}`);
      }
      return Promise.resolve(state.install.cancel());
    },

    clearInstalls(ids) {
      for (let id of ids) {
        this.forgetInstall(id);
      }
    },

    clearInstallsFrom(mm) {
      for (let [id, info] of this.installs) {
        if (info.messageManager == mm) {
          this.forgetInstall(id);
        }
      }
    },
  },
};

/**
 * Should not be used outside of core Mozilla code. This is a private API for
 * the startup and platform integration code to use. Refer to the methods on
 * AddonManagerInternal for documentation however note that these methods are
 * subject to change at any time.
 */
export var AddonManagerPrivate = {
  startup() {
    AddonManagerInternal.startup();
  },

  addonIsActive(addonId) {
    return AddonManagerInternal._getProviderByName("XPIProvider").addonIsActive(
      addonId
    );
  },

  /**
   * Gets an array of add-ons which were side-loaded prior to the last
   * startup, and are currently disabled.
   *
   * @returns {Promise<Array<Addon>>}
   */
  getNewSideloads() {
    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).getNewSideloads();
  },

  get browserUpdated() {
    return gBrowserUpdated;
  },

  registerProvider(aProvider, aTypes) {
    AddonManagerInternal.registerProvider(aProvider, aTypes);
  },

  unregisterProvider(aProvider) {
    AddonManagerInternal.unregisterProvider(aProvider);
  },

  /**
   * Get a list of addon types that was passed to registerProvider for the
   * provider with the given name.
   *
   * @param {string} aProviderName
   * @returns {Array<string>}
   */
  getAddonTypesByProvider(aProviderName) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    for (let [provider, addonTypes] of AddonManagerInternal.typesByProvider) {
      if (providerName(provider) === aProviderName) {
        // Return an array because methods such as getAddonsByTypes expect
        // aTypes to be an array.
        return Array.from(addonTypes);
      }
    }
    throw Components.Exception(
      `No addonTypes found for provider: ${aProviderName}`,
      Cr.NS_ERROR_INVALID_ARG
    );
  },

  markProviderSafe(aProvider) {
    AddonManagerInternal.markProviderSafe(aProvider);
  },

  backgroundUpdateCheck() {
    return AddonManagerInternal.backgroundUpdateCheck();
  },

  backgroundUpdateTimerHandler() {
    // Don't return the promise here, since the caller doesn't care.
    AddonManagerInternal.backgroundUpdateCheck();
  },

  addStartupChange(aType, aID) {
    AddonManagerInternal.addStartupChange(aType, aID);
  },

  removeStartupChange(aType, aID) {
    AddonManagerInternal.removeStartupChange(aType, aID);
  },

  notifyAddonChanged(aID, aType, aPendingRestart) {
    return AddonManagerInternal.notifyAddonChanged(aID, aType, aPendingRestart);
  },

  updateAddonAppDisabledStates() {
    AddonManagerInternal.updateAddonAppDisabledStates();
  },

  updateAddonRepositoryData() {
    return AddonManagerInternal.updateAddonRepositoryData();
  },

  callInstallListeners(...aArgs) {
    return AddonManagerInternal.callInstallListeners.apply(
      AddonManagerInternal,
      aArgs
    );
  },

  callAddonListeners(...aArgs) {
    AddonManagerInternal.callAddonListeners.apply(AddonManagerInternal, aArgs);
  },

  AddonAuthor,

  AddonScreenshot,

  get BOOTSTRAP_REASONS() {
    // BOOTSTRAP_REASONS is a set of constants, and may be accessed before the
    // provider has fully been started.
    // See https://bugzilla.mozilla.org/show_bug.cgi?id=1760146#c1
    return gXPIProvider.BOOTSTRAP_REASONS;
  },

  setAddonStartupData(addonId, startupData) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    // TODO bug 1761079: Ensure that XPIProvider is available before calling it.
    gXPIProvider.setStartupData(addonId, startupData);
  },

  unregisterDictionaries(aDicts) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    // TODO bug 1761093: Use _getProviderByName instead of gXPIProvider.
    gXPIProvider.unregisterDictionaries(aDicts);
  },

  _simpleMeasures: {},
  recordSimpleMeasure(name, value) {
    this._simpleMeasures[name] = value;
  },

  recordException(aModule, aContext, aException) {
    let report = {
      module: aModule,
      context: aContext,
    };

    if (typeof aException == "number") {
      report.message = Components.Exception("", aException).name;
    } else {
      report.message = aException.toString();
      if (aException.fileName) {
        report.file = aException.fileName;
        report.line = aException.lineNumber;
      }
    }

    Glean.addonsManager.exception.set(report);
    this._simpleMeasures.exception = report;
  },

  getSimpleMeasures() {
    return this._simpleMeasures;
  },

  getTelemetryDetails() {
    return AddonManagerInternal.telemetryDetails;
  },

  setTelemetryDetails(aProvider, aDetails) {
    AddonManagerInternal.telemetryDetails[aProvider] = aDetails;
  },

  // Start a timer, record a simple measure of the time interval when
  // timer.done() is called
  simpleTimer(aName) {
    let startTime = ChromeUtils.now();
    return {
      done: () =>
        this.recordSimpleMeasure(
          aName,
          Math.round(ChromeUtils.now() - startTime)
        ),
    };
  },

  async recordTiming(name, task) {
    let timer = this.simpleTimer(name);
    try {
      return await task();
    } finally {
      timer.done();
    }
  },

  /**
   * Helper to call update listeners when no update is available.
   *
   * This can be used as an implementation for Addon.findUpdates() when
   * no update mechanism is available.
   */
  callNoUpdateListeners(addon, listener) {
    if ("onNoCompatibilityUpdateAvailable" in listener) {
      safeCall(listener.onNoCompatibilityUpdateAvailable.bind(listener), addon);
    }
    if ("onNoUpdateAvailable" in listener) {
      safeCall(listener.onNoUpdateAvailable.bind(listener), addon);
    }
    if ("onUpdateFinished" in listener) {
      safeCall(listener.onUpdateFinished.bind(listener), addon);
    }
  },

  get webExtensionsMinPlatformVersion() {
    return gWebExtensionsMinPlatformVersion;
  },

  hasUpgradeListener(aId) {
    return AddonManagerInternal.upgradeListeners.has(aId);
  },

  getUpgradeListener(aId) {
    return AddonManagerInternal.upgradeListeners.get(aId);
  },

  get externalExtensionLoaders() {
    return AddonManagerInternal.externalExtensionLoaders;
  },

  /**
   * Predicate that returns true if we think the given extension ID
   * might have been generated by XPIProvider.
   */
  isTemporaryInstallID(extensionId) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }

    if (!extensionId || typeof extensionId != "string") {
      throw Components.Exception(
        "extensionId must be a string",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).isTemporaryInstallID(extensionId);
  },

  isDBLoaded() {
    let provider = AddonManagerInternal._getProviderByName("XPIProvider");
    return provider ? provider.isDBLoaded : false;
  },

  get databaseReady() {
    let provider = AddonManagerInternal._getProviderByName("XPIProvider");
    return provider ? provider.databaseReady : new Promise(() => {});
  },

  /**
   * Async shutdown barrier which blocks the completion of add-on
   * manager shutdown. This should generally only be used by add-on
   * providers (i.e., XPIProvider) to complete their final shutdown
   * tasks.
   */
  get finalShutdown() {
    return gFinalShutdownBarrier.client;
  },

  // Used by tests to call repo shutdown.
  overrideAddonRepository(mockRepo) {
    lazy.AddonRepository = mockRepo;
  },

  // Used by tests to shut down AddonManager.
  overrideAsyncShutdown(mockAsyncShutdown) {
    AsyncShutdown = mockAsyncShutdown;
  },
};

/**
 * This is the public API that UI and developers should be calling. All methods
 * just forward to AddonManagerInternal.
 *
 * @class
 */
export var AddonManager = {
  // Map used to convert the known install source hostnames into the value to set into the
  // telemetry events.
  _installHostSource: new Map([
    ["addons.mozilla.org", "amo"],
    ["discovery.addons.mozilla.org", "disco"],
  ]),

  // Constants for the AddonInstall.state property
  // These will show up as AddonManager.STATE_* (eg, STATE_AVAILABLE)
  _states: new Map([
    // The install is available for download.
    ["STATE_AVAILABLE", 0],
    // The install is being downloaded.
    ["STATE_DOWNLOADING", 1],
    // The install is checking the update for compatibility information.
    ["STATE_CHECKING_UPDATE", 2],
    // The install is downloaded and ready to install.
    ["STATE_DOWNLOADED", 3],
    // The download failed.
    ["STATE_DOWNLOAD_FAILED", 4],
    // The install may not proceed until the user accepts a prompt
    ["STATE_AWAITING_PROMPT", 5],
    // Any prompts are done
    ["STATE_PROMPTS_DONE", 6],
    // The install has been postponed.
    ["STATE_POSTPONED", 7],
    // The install is ready to be applied.
    ["STATE_READY", 8],
    // The add-on is being installed.
    ["STATE_INSTALLING", 9],
    // The add-on has been installed.
    ["STATE_INSTALLED", 10],
    // The install failed.
    ["STATE_INSTALL_FAILED", 11],
    // The install has been cancelled.
    ["STATE_CANCELLED", 12],
  ]),

  // Constants representing different types of errors while downloading an
  // add-on as a preparation for installation.
  // These will show up as AddonManager.ERROR_* (eg, ERROR_NETWORK_FAILURE)
  // The _errors codes are translated to text for a panel in browser-addons.js.
  // The localized messages are located in extensionsUI.ftl.
  // Errors with the "Updates only:" prefix are not translated
  // because the error is dumped to the console instead of a panel.
  _errors: new Map([
    // The download failed due to network problems.
    ["ERROR_NETWORK_FAILURE", -1],
    // The downloaded file did not match the provided hash.
    ["ERROR_INCORRECT_HASH", -2],
    // The downloaded file seems to be corrupted in some way.
    ["ERROR_CORRUPT_FILE", -3],
    // An error occurred trying to write to the filesystem.
    ["ERROR_FILE_ACCESS", -4],
    // The add-on must be signed and isn't.
    ["ERROR_SIGNEDSTATE_REQUIRED", -5],
    // Updates only: The downloaded add-on had a different type than expected.
    ["ERROR_UNEXPECTED_ADDON_TYPE", -6],
    // Updates only: The addon did not have the expected ID.
    ["ERROR_INCORRECT_ID", -7],
    // The addon install_origins does not list the 3rd party domain.
    ["ERROR_INVALID_DOMAIN", -8],
    // Updates only: The downloaded add-on had a different version than expected.
    ["ERROR_UNEXPECTED_ADDON_VERSION", -9],
    // The add-on is blocklisted.
    ["ERROR_BLOCKLISTED", -10],
    // The add-on is incompatible (w.r.t. the compatibility range).
    ["ERROR_INCOMPATIBLE", -11],
    // The add-on type is not supported by the platform.
    ["ERROR_UNSUPPORTED_ADDON_TYPE", -12],
    // The add-on can only be installed via enterprise policy.
    ["ERROR_ADMIN_INSTALL_ONLY", -13],
    // The add-on is soft-blocked (and so new installation are expected to fail).
    ["ERROR_SOFT_BLOCKED", -14],
  ]),
  // The update check timed out
  ERROR_TIMEOUT: -1,
  // There was an error while downloading the update information.
  ERROR_DOWNLOAD_ERROR: -2,
  // The update information was malformed in some way.
  ERROR_PARSE_ERROR: -3,
  // The update information was not in any known format.
  ERROR_UNKNOWN_FORMAT: -4,
  // The update information was not correctly signed or there was an SSL error.
  ERROR_SECURITY_ERROR: -5,
  // The update was cancelled
  ERROR_CANCELLED: -6,
  // These must be kept in sync with AddonUpdateChecker.
  // No error was encountered.
  UPDATE_STATUS_NO_ERROR: 0,
  // The update check timed out
  UPDATE_STATUS_TIMEOUT: -1,
  // There was an error while downloading the update information.
  UPDATE_STATUS_DOWNLOAD_ERROR: -2,
  // The update information was malformed in some way.
  UPDATE_STATUS_PARSE_ERROR: -3,
  // The update information was not in any known format.
  UPDATE_STATUS_UNKNOWN_FORMAT: -4,
  // The update information was not correctly signed or there was an SSL error.
  UPDATE_STATUS_SECURITY_ERROR: -5,
  // The update was cancelled.
  UPDATE_STATUS_CANCELLED: -6,
  // Constants to indicate why an update check is being performed
  // Update check has been requested by the user.
  UPDATE_WHEN_USER_REQUESTED: 1,
  // Update check is necessary to see if the Addon is compatibile with a new
  // version of the application.
  UPDATE_WHEN_NEW_APP_DETECTED: 2,
  // Update check is necessary because a new application has been installed.
  UPDATE_WHEN_NEW_APP_INSTALLED: 3,
  // Update check is a regular background update check.
  UPDATE_WHEN_PERIODIC_UPDATE: 16,
  // Update check is needed to check an Addon that is being installed.
  UPDATE_WHEN_ADDON_INSTALLED: 17,

  // Constants for operations in Addon.pendingOperations
  // Indicates that the Addon has no pending operations.
  PENDING_NONE: 0,
  // Indicates that the Addon will be enabled after the application restarts.
  PENDING_ENABLE: 1,
  // Indicates that the Addon will be disabled after the application restarts.
  PENDING_DISABLE: 2,
  // Indicates that the Addon will be uninstalled after the application restarts.
  PENDING_UNINSTALL: 4,
  // Indicates that the Addon will be installed after the application restarts.
  PENDING_INSTALL: 8,
  PENDING_UPGRADE: 16,

  // Constants for operations in Addon.operationsRequiringRestart
  // Indicates that restart isn't required for any operation.
  OP_NEEDS_RESTART_NONE: 0,
  // Indicates that restart is required for enabling the addon.
  OP_NEEDS_RESTART_ENABLE: 1,
  // Indicates that restart is required for disabling the addon.
  OP_NEEDS_RESTART_DISABLE: 2,
  // Indicates that restart is required for uninstalling the addon.
  OP_NEEDS_RESTART_UNINSTALL: 4,
  // Indicates that restart is required for installing the addon.
  OP_NEEDS_RESTART_INSTALL: 8,

  // Constants for permissions in Addon.permissions.
  // Indicates that the Addon can be uninstalled.
  PERM_CAN_UNINSTALL: 1,
  // Indicates that the Addon can be enabled by the user.
  PERM_CAN_ENABLE: 2,
  // Indicates that the Addon can be disabled by the user.
  PERM_CAN_DISABLE: 4,
  // Indicates that the Addon can be upgraded.
  PERM_CAN_UPGRADE: 8,
  // Indicates that the Addon can be set to be allowed/disallowed
  // in private browsing windows.
  PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS: 32,
  // Indicates that internal APIs can uninstall the add-on, even if the
  // front-end cannot.
  PERM_API_CAN_UNINSTALL: 64,

  // General descriptions of where items are installed.
  // Installed in this profile.
  SCOPE_PROFILE: 1,
  // Installed for all of this user's profiles.
  SCOPE_USER: 2,
  // Installed and owned by the application.
  SCOPE_APPLICATION: 4,
  // Installed for all users of the computer.
  SCOPE_SYSTEM: 8,
  // Installed temporarily
  SCOPE_TEMPORARY: 16,
  // The combination of all scopes.
  SCOPE_ALL: 31,

  // Constants for Addon.applyBackgroundUpdates.
  // Indicates that the Addon should not update automatically.
  AUTOUPDATE_DISABLE: 0,
  // Indicates that the Addon should update automatically only if
  // that's the global default.
  AUTOUPDATE_DEFAULT: 1,
  // Indicates that the Addon should update automatically.
  AUTOUPDATE_ENABLE: 2,

  // Constants for how Addon options should be shown.
  // Options will be displayed in a new tab, if possible
  OPTIONS_TYPE_TAB: 3,
  // Similar to OPTIONS_TYPE_INLINE, but rather than generating inline
  // options from a specially-formatted XUL file, the contents of the
  // file are simply displayed in an inline <browser> element.
  OPTIONS_TYPE_INLINE_BROWSER: 5,

  // Constants for displayed or hidden options notifications
  // Options notification will be displayed
  OPTIONS_NOTIFICATION_DISPLAYED: "addon-options-displayed",
  // Options notification will be hidden
  OPTIONS_NOTIFICATION_HIDDEN: "addon-options-hidden",

  // Constants for getStartupChanges, addStartupChange and removeStartupChange
  // Add-ons that were detected as installed during startup. Doesn't include
  // add-ons that were pending installation the last time the application ran.
  STARTUP_CHANGE_INSTALLED: "installed",
  // Add-ons that were detected as changed during startup. This includes an
  // add-on moving to a different location, changing version or just having
  // been detected as possibly changed.
  STARTUP_CHANGE_CHANGED: "changed",
  // Add-ons that were detected as uninstalled during startup. Doesn't include
  // add-ons that were pending uninstallation the last time the application ran.
  STARTUP_CHANGE_UNINSTALLED: "uninstalled",
  // Add-ons that were detected as disabled during startup, normally because of
  // an application change making an add-on incompatible. Doesn't include
  // add-ons that were pending being disabled the last time the application ran.
  STARTUP_CHANGE_DISABLED: "disabled",
  // Add-ons that were detected as enabled during startup, normally because of
  // an application change making an add-on compatible. Doesn't include
  // add-ons that were pending being enabled the last time the application ran.
  STARTUP_CHANGE_ENABLED: "enabled",

  // Constants for Addon.signedState. Any states that should cause an add-on
  // to be unusable in builds that require signing should have negative values.
  // Add-on signing is not required, e.g. because the pref is disabled.
  SIGNEDSTATE_NOT_REQUIRED: undefined,
  // Add-on is signed but signature verification has failed.
  SIGNEDSTATE_BROKEN: -2,
  // Add-on may be signed but by an certificate that doesn't chain to our
  // our trusted certificate.
  SIGNEDSTATE_UNKNOWN: -1,
  // Add-on is unsigned.
  SIGNEDSTATE_MISSING: 0,
  // Add-on is preliminarily reviewed.
  SIGNEDSTATE_PRELIMINARY: 1,
  // Add-on is fully reviewed.
  SIGNEDSTATE_SIGNED: 2,
  // Add-on is system add-on.
  SIGNEDSTATE_SYSTEM: 3,
  // Add-on is signed with a "Mozilla Extensions" certificate
  SIGNEDSTATE_PRIVILEGED: 4,

  get __AddonManagerInternal__() {
    return AppConstants.DEBUG ? AddonManagerInternal : undefined;
  },

  /** Boolean indicating whether AddonManager startup has completed. */
  get isReady() {
    return gStartupComplete && !gShutdownInProgress;
  },

  /**
   * A promise that is resolved when the AddonManager startup has completed.
   * This may be rejected if startup of the AddonManager is not successful, or
   * if shutdown is started before the AddonManager has finished starting.
   */
  get readyPromise() {
    return gStartedPromise.promise;
  },

  /** @class */
  init() {
    this._stateToString = new Map();
    for (let [name, value] of this._states) {
      this[name] = value;
      this._stateToString.set(value, name);
    }
    this._errorToString = new Map();
    for (let [name, value] of this._errors) {
      this[name] = value;
      this._errorToString.set(value, name);
    }
  },

  stateToString(state) {
    return this._stateToString.get(state);
  },

  errorToString(err) {
    return err ? this._errorToString.get(err) : null;
  },

  getInstallSourceFromHost(host) {
    if (this._installHostSource.has(host)) {
      return this._installHostSource.get(host);
    }

    if (WEBAPI_TEST_INSTALL_HOSTS.includes(host)) {
      return "test-host";
    }

    return "unknown";
  },

  getInstallForURL(aUrl, aOptions) {
    return AddonManagerInternal.getInstallForURL(aUrl, aOptions);
  },

  getInstallForFile(
    aFile,
    aMimetype,
    aTelemetryInfo,
    aUseSystemLocation = false
  ) {
    return AddonManagerInternal.getInstallForFile(
      aFile,
      aMimetype,
      aTelemetryInfo,
      aUseSystemLocation
    );
  },

  uninstallSystemProfileAddon(aID) {
    return AddonManagerInternal.uninstallSystemProfileAddon(aID);
  },

  stageLangpacksForAppUpdate(appVersion, platformVersion) {
    return AddonManagerInternal._getProviderByName(
      "XPIProvider"
    ).stageLangpacksForAppUpdate(appVersion, platformVersion);
  },

  /**
   * Gets an array of add-on IDs that changed during the most recent startup.
   *
   * @param  aType
   *         The type of startup change to get
   * @return An array of add-on IDs
   */
  getStartupChanges(aType) {
    if (!(aType in AddonManagerInternal.startupChanges)) {
      return [];
    }
    return AddonManagerInternal.startupChanges[aType].slice(0);
  },

  getAddonByID(aID) {
    return AddonManagerInternal.getAddonByID(aID);
  },

  getAddonBySyncGUID(aGUID) {
    return AddonManagerInternal.getAddonBySyncGUID(aGUID);
  },

  getAddonsByIDs(aIDs) {
    return AddonManagerInternal.getAddonsByIDs(aIDs);
  },

  getAddonsByTypes(aTypes) {
    return AddonManagerInternal.getAddonsByTypes(aTypes);
  },

  getActiveAddons(aTypes) {
    return AddonManagerInternal.getActiveAddons(aTypes);
  },

  getAllAddons() {
    return AddonManagerInternal.getAllAddons();
  },

  /**
   * Gets the version of the auto-install built-in add-on
   * with the given addonID (if a built-in with that add-on
   * id exists).
   *
   * NOTE:
   * - This method is limited to built-in add-ons
   *   auto-installed in the "app-builtin-addons" location
   *   based on the list bundled in Firefox Desktop builds as
   *   "chrome://browser/content/built_in_addons.json".
   * - This method will throws an exception if called before
   *   the AddonManager and the XPIProvider has been started.
   *
   * @param  {string} addonId
   *   Addon id of the built-in add-on to get a version for.
   * @return {string|void}
   *   Returns the built-in add-on version if one is found.
   */
  getBuiltinAddonVersion(addonId) {
    return AddonManagerInternal.getBuiltinAddonVersion(addonId);
  },

  getInstallsByTypes(aTypes) {
    return AddonManagerInternal.getInstallsByTypes(aTypes);
  },

  getAllInstalls() {
    return AddonManagerInternal.getAllInstalls();
  },

  shouldShowBlocklistAttention() {
    return AddonManagerInternal.shouldShowBlocklistAttention();
  },

  getBlocklistAttentionInfo() {
    return AddonManagerInternal.getBlocklistAttentionInfo();
  },

  isInstallEnabled(aType) {
    return AddonManagerInternal.isInstallEnabled(aType);
  },

  isInstallAllowed(aType, aInstallingPrincipal) {
    return AddonManagerInternal.isInstallAllowed(aType, aInstallingPrincipal);
  },

  installSitePermsAddonFromWebpage(
    aBrowser,
    aInstallingPrincipal,
    aPermission
  ) {
    return AddonManagerInternal.installSitePermsAddonFromWebpage(
      aBrowser,
      aInstallingPrincipal,
      aPermission
    );
  },

  installAddonFromWebpage(
    aType,
    aBrowser,
    aInstallingPrincipal,
    aInstall,
    details
  ) {
    AddonManagerInternal.installAddonFromWebpage(
      aType,
      aBrowser,
      aInstallingPrincipal,
      aInstall,
      details
    );
  },

  installAddonFromAOM(aBrowser, aUri, aInstall) {
    AddonManagerInternal.installAddonFromAOM(aBrowser, aUri, aInstall);
  },

  installAddonFromAOMWithOptions(aBrowser, aUri, aInstall, options = {}) {
    AddonManagerInternal.installAddonFromAOM(aBrowser, aUri, aInstall, options);
  },

  installTemporaryAddon(aDirectory) {
    return AddonManagerInternal.installTemporaryAddon(aDirectory);
  },

  installBuiltinAddon(aBase) {
    return AddonManagerInternal.installBuiltinAddon(aBase);
  },

  maybeInstallBuiltinAddon(aID, aVersion, aBase) {
    return AddonManagerInternal.maybeInstallBuiltinAddon(aID, aVersion, aBase);
  },

  addManagerListener(aListener) {
    AddonManagerInternal.addManagerListener(aListener);
  },

  removeManagerListener(aListener) {
    AddonManagerInternal.removeManagerListener(aListener);
  },

  addInstallListener(aListener) {
    AddonManagerInternal.addInstallListener(aListener);
  },

  removeInstallListener(aListener) {
    AddonManagerInternal.removeInstallListener(aListener);
  },

  getUpgradeListener(aId) {
    return AddonManagerInternal.upgradeListeners.get(aId);
  },

  addUpgradeListener(aInstanceID, aCallback) {
    AddonManagerInternal.addUpgradeListener(aInstanceID, aCallback);
  },

  removeUpgradeListener(aInstanceID) {
    return AddonManagerInternal.removeUpgradeListener(aInstanceID);
  },

  addExternalExtensionLoader(loader) {
    return AddonManagerInternal.addExternalExtensionLoader(loader);
  },

  addAddonListener(aListener) {
    AddonManagerInternal.addAddonListener(aListener);
  },

  removeAddonListener(aListener) {
    AddonManagerInternal.removeAddonListener(aListener);
  },

  hasAddonType(addonType) {
    return AddonManagerInternal.hasAddonType(addonType);
  },

  hasProvider(name) {
    if (!gStarted) {
      throw Components.Exception(
        "AddonManager is not initialized",
        Cr.NS_ERROR_NOT_INITIALIZED
      );
    }
    return !!AddonManagerInternal._getProviderByName(name);
  },

  /**
   * Determines whether an Addon should auto-update or not.
   *
   * @param  aAddon
   *         The Addon representing the add-on
   * @return true if the addon should auto-update, false otherwise.
   */
  shouldAutoUpdate(aAddon) {
    if (!aAddon || typeof aAddon != "object") {
      throw Components.Exception(
        "aAddon must be specified",
        Cr.NS_ERROR_INVALID_ARG
      );
    }

    if (!("applyBackgroundUpdates" in aAddon)) {
      return false;
    }
    if (!(aAddon.permissions & AddonManager.PERM_CAN_UPGRADE)) {
      return false;
    }
    if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_ENABLE) {
      return true;
    }
    if (aAddon.applyBackgroundUpdates == AddonManager.AUTOUPDATE_DISABLE) {
      return false;
    }
    return this.autoUpdateDefault;
  },

  get checkCompatibility() {
    return AddonManagerInternal.checkCompatibility;
  },

  set checkCompatibility(aValue) {
    AddonManagerInternal.checkCompatibility = aValue;
  },

  get strictCompatibility() {
    return AddonManagerInternal.strictCompatibility;
  },

  set strictCompatibility(aValue) {
    AddonManagerInternal.strictCompatibility = aValue;
  },

  get checkUpdateSecurityDefault() {
    return AddonManagerInternal.checkUpdateSecurityDefault;
  },

  get checkUpdateSecurity() {
    return AddonManagerInternal.checkUpdateSecurity;
  },

  set checkUpdateSecurity(aValue) {
    AddonManagerInternal.checkUpdateSecurity = aValue;
  },

  get updateEnabled() {
    return AddonManagerInternal.updateEnabled;
  },

  set updateEnabled(aValue) {
    AddonManagerInternal.updateEnabled = aValue;
  },

  get autoUpdateDefault() {
    return AddonManagerInternal.autoUpdateDefault;
  },

  set autoUpdateDefault(aValue) {
    AddonManagerInternal.autoUpdateDefault = aValue;
  },

  escapeAddonURI(aAddon, aUri, aAppVersion) {
    return AddonManagerInternal.escapeAddonURI(aAddon, aUri, aAppVersion);
  },

  getPreferredIconURL(aAddon, aSize, aWindow = undefined) {
    return AddonManagerInternal.getPreferredIconURL(aAddon, aSize, aWindow);
  },

  get webAPI() {
    return AddonManagerInternal.webAPI;
  },

  /**
   * Async shutdown barrier which blocks the start of AddonManager
   * shutdown. Callers should add blockers to this barrier if they need
   * to complete add-on manager operations before it shuts down.
   */
  get beforeShutdown() {
    return gBeforeShutdownBarrier.client;
  },

  /**
   * Returns a promise that should be attached to an installation prompt
   * handler when we update an add-on.
   */
  async updatePromptHandler(aInfo) {
    return AddonManagerInternal.updatePromptHandler(aInfo);
  },
};

/**
 * Manage AddonManager settings propagated over RemoteSettings synced data.
 *
 * See :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>`.
 *
 * .. warning::
 *   Before landing any change to ``AMRemoteSettings`` or the format expected for the
 *   remotely controlled settings (on the service or Firefos side), please read the
 *   documentation page linked above and make sure to keep the JSON Schema described
 *   and controlled settings groups included in that documentation page in sync with
 *   the one actually set on the RemoteSettings service side.
 */
AMRemoteSettings = {
  RS_COLLECTION: "addons-manager-settings",

  /**
   * RemoteSettings settings group map.
   *
   * .. note::
   *   Please keep in sync the "Controlled Settings Groups" documentation from
   *   :doc:`AMRemoteSettings Overview <AMRemoteSettings-overview>` in sync with
   *   the settings groups defined here.
   */
  RS_ENTRIES_MAP: {
    installTriggerDeprecation: ["extensions.InstallTrigger.enabled"],
    quarantinedDomains: ["extensions.quarantinedDomains.list"],
  },

  client: null,
  onSync: null,
  promiseStartup: null,

  init() {
    try {
      if (!this.promiseStartup) {
        // Creating a promise to resolved when the browser startup was completed,
        // used to process the existing entries (if any) after the startup is completed
        // and to only to it ones.
        this.promiseStartup = new Promise(resolve => {
          function observer() {
            resolve();
            Services.obs.removeObserver(
              observer,
              "browser-delayed-startup-finished"
            );
          }
          Services.obs.addObserver(
            observer,
            "browser-delayed-startup-finished"
          );
        });
      }

      if (Services.prefs.getBoolPref(PREF_REMOTESETTINGS_DISABLED, false)) {
        return;
      }

      if (!this.client) {
        this.client = lazy.RemoteSettings(this.RS_COLLECTION);
        this.onSync = this.processEntries.bind(this);
        this.client.on("sync", this.onSync);
        // Process existing entries if any, once the browser has been fully initialized.
        this.promiseStartup.then(() => this.processEntries());
      }
    } catch (err) {
      logger.error("Failure to initialize AddonManager RemoteSettings", err);
    }
  },

  shutdown() {
    try {
      if (this.client) {
        this.client.off("sync", this.onSync);
        this.client = null;
        this.onSync = null;
      }
      this.promiseStartup = null;
    } catch (err) {
      logger.error("Failure on shutdown AddonManager RemoteSettings", err);
    }
  },

  /**
   * Process all the settings groups that are included in the collection entry with ``"id"`` set to ``"AddonManagerSettings"``
   * (if any).
   *
   * .. note::
   *   This method may need to be updated if the preference value type is not yet expected by this method
   *   (which means that it would be ignored until handled explicitly).
   */
  async processEntries() {
    const entries = await this.client.get({ syncIfEmpty: false }).catch(err => {
      logger.error("Failure to process AddonManager RemoteSettings", err);
      return [];
    });

    const processEntryPref = (entryId, groupName, prefName, prefValue) => {
      try {
        logger.debug(
          `Process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`
        );

        // Support for controlling boolean and string AddonManager settings.
        switch (typeof prefValue) {
          case "boolean":
            Services.prefs.setBoolPref(prefName, prefValue);
            break;
          case "string":
            Services.prefs.setStringPref(prefName, prefValue);
            break;
          default:
            throw new Error(`Unexpected type ${typeof prefValue}`);
        }

        // Notify observers about the pref set from AMRemoteSettings.
        Services.obs.notifyObservers(
          { entryId, groupName, prefName, prefValue },
          "am-remote-settings-setpref"
        );
      } catch (e) {
        logger.error(
          `Failed to process AddonManager RemoteSettings "${entryId}" - "${groupName}": ${prefName}`,
          e
        );
      }
    };

    for (const entry of entries) {
      logger.debug(`Processing AddonManager RemoteSettings "${entry.id}"`);

      for (const [groupName, prefs] of Object.entries(this.RS_ENTRIES_MAP)) {
        const data = entry[groupName];
        if (!data) {
          continue;
        }

        for (const pref of prefs) {
          // Skip the pref if it is not included in the remote settings data.
          if (!(pref in data)) {
            continue;
          }

          processEntryPref(entry.id, groupName, pref, data[pref]);
        }
      }
    }
  },
};

/**
 * Encapsulates the Glean addons.activeAddons/activeTheme/activeGMPlugins data collection.
 *
 * In Firefox Desktop build the EnvironmentAddonBuilder is managed from the TelemetryEnvironment,
 * whereas on Firefox for Android / GeckoView builds it is managed from the AMTelemetry singleton
 * defined in this same ES module.
 */
export class EnvironmentAddonBuilder {
  // Used by TelemetryEnvironment to determine if it is still
  // responsible for creating and managing the EnvironmentAddonBuilder
  // instance as part of the legacy TelemetryEnvironment.
  static isTelemetryEnvironmentEnabled = () =>
    !lazy.AMTELEMETRY_ADDONS_BUILDER_ENABLED;

  constructor(environment) {
    this._environment = environment;

    // The pending task blocks addon manager shutdown. It can either be the initial load
    // or a change load.
    this._pendingTask = null;

    // Have we added an observer to listen for blocklist changes that still needs to be
    // removed:
    this._gmpProviderObserverAdded = false;

    // Set to true once initial load is complete and we're watching for changes.
    this._loaded = false;

    // Set to true once the shutdown has been completed.
    this._shutdownCompleted = false;

    // The state reported by the shutdown blocker if we hang shutdown.
    this._shutdownState = "Initial";

    // Addons may contain partial or full data depending on whether the Addons DB
    // has had a chance to load. Do we have full data yet?
    this._addonsAreFull = false;

    // a DeferredTask coalescing multiple addons list updates into a single
    // submission of the Glean Ping `addons` with the reason `updated`.
    this._submitGleanPingAddonsUpdatedTask = null;

    this._log = console.createInstance({
      prefix: "EnvironmentAddonBuilder",
      maxLogLevel: Services.prefs.getBoolPref(PREF_LOGGING_ENABLED, false)
        ? "Debug"
        : "Warn",
    });

    this._currentData = {};
  }

  /**
   * Returns a substring of the input string.
   *
   * @param {string} aString The input string.
   * @param {Integer} aMaxLength The maximum length of the returned substring. If this is
   *        greater than the length of the input string, we return the whole input string.
   * @return {string} The substring or null if the input string is null.
   */
  limitStringToLength(aString, aMaxLength) {
    if (typeof aString !== "string") {
      return null;
    }
    return aString.substring(0, aMaxLength);
  }

  /**
   * Enforces the parameter to a boolean value.
   *
   * @param aValue The input value.
   * @return {boolean | object} If aValue is a boolean or a number, returns its truthfulness
   *         value. Otherwise, return null.
   */
  enforceBoolean(aValue) {
    if (typeof aValue !== "number" && typeof aValue !== "boolean") {
      return null;
    }
    return Boolean(aValue);
  }

  /**
   * Get the initial set of addons.
   *
   * @returns Promise<void> when the initial load is complete.
   */
  async init() {
    AddonManager.beforeShutdown.addBlocker(
      "EnvironmentAddonBuilder",
      () => this._shutdownBlocker(),
      { fetchState: () => this._shutdownState }
    );

    this._pendingTask = (async () => {
      try {
        this._shutdownState = "Awaiting _updateAddons";
        // Gather initial addons details
        await this._updateAddons();

        if (!this._addonsAreFull) {
          // The addon database has not been loaded, wait for it to
          // initialize and gather full data as soon as it does.
          this._shutdownState = "Awaiting AddonManagerPrivate.databaseReady";
          await AddonManagerPrivate.databaseReady;

          // Now gather complete addons details.
          this._shutdownState = "Awaiting second _updateAddons";
          await this._updateAddons();
        }
      } catch (err) {
        this._log.error("init - Exception in _updateAddons", err);
      } finally {
        this._pendingTask = null;
        this._shutdownState = "_pendingTask init complete. No longer blocking.";
        this._log.debug("init - completed");
        // Submit the addons Glean ping with reason "startup" right after
        // the EnvironmentAddonBuilder has been initialized as part of the
        // AddonManager and application startup.
        GleanPings.addons.submit("startup");
      }
    })();

    return this._pendingTask;
  }

  async uninit() {
    if (this._shutdownCompleted) {
      return;
    }
    this._finalizeGleanPingAddonsUpdatedTask();
    await this._shutdownBlocker();
  }

  /**
   * Register an addon listener and watch for changes.
   */
  watchForChanges() {
    this._log.trace("watchForChanges");
    this._loaded = true;
    AddonManager.addAddonListener(this);
  }

  // AddonListener
  onEnabled(addon) {
    this._onAddonChange(addon);
  }

  onDisabled(addon) {
    this._onAddonChange(addon);
  }

  onInstalled(addon) {
    this._onAddonChange(addon);
  }

  onUninstalling(addon) {
    this._onAddonChange(addon);
  }

  onUninstalled(addon) {
    this._onAddonChange(addon);
  }

  onPropertyChanged(addon, propertiesChanged) {
    // Avoid to update the telemetry environment for onPropertyChanged
    // calls that we are not actually interested in (and quarantineIgnoredByApp
    // is not expected to change at runtime, unless the entire active addons
    // entry is also replaced, e.g. on the extension being uninstalled and
    // installed again).
    if (!propertiesChanged.includes("quarantineIgnoredByUser")) {
      return;
    }
    this._onAddonChange(addon);
  }

  // nsIObserver
  observe(aSubject, aTopic) {
    this._log.trace("observe - Topic " + aTopic);
    if (aTopic == GMP_PROVIDER_REGISTERED_TOPIC) {
      Services.obs.removeObserver(this, GMP_PROVIDER_REGISTERED_TOPIC);
      this._gmpProviderObserverAdded = false;
      let gmpPluginsPromise = this._getActiveGMPlugins();
      gmpPluginsPromise.then(
        gmpPlugins => {
          let { addons } = this._currentEnvironment;
          addons.activeGMPlugins = gmpPlugins;
        },
        err => {
          this._log.error("blocklist observe: Error collecting plugins", err);
        }
      );
    }
  }

  // Internal helper methods and getters.
  get _currentEnvironment() {
    return this._environment?._currentEnvironment ?? this._currentData;
  }

  _onEnvironmentChange(changeReason, oldEnvironment) {
    this._environment?._onEnvironmentChange(changeReason, oldEnvironment);
  }

  _onAddonChange(addon) {
    // On builds where this metric is also reported in the legacy telemetry main ping
    // as part of the TelemetryEnvironment, return earlier if the add-on is a non-system builtin
    // to keep the old behavior unchanged (to reduce the change of changing the frequency of the
    // legacy telemetry main ping as an unexpected side-effect).
    //
    // NOTE: on android builds this check is expected to never be true, and the non-system builtin
    // add-ons like webcompat to be part of the activeAddons Glean metric. This early return
    // can be dropped completely (along with the similar logic from the _getActiveAddons method)
    // once we have migrated the Desktop builds away from the legacy TelemetryEnvironment.
    if (
      !lazy.AMTELEMETRY_ADDONS_BUILDER_ENABLED &&
      addon &&
      addon.isBuiltin &&
      !addon.isSystem
    ) {
      return;
    }
    this._log.trace(`_onAddonChange ${addon?.id ? addon.id : ""}`);
    this._checkForChanges("addons-changed");
  }

  _checkForChanges(changeReason) {
    if (this._pendingTask) {
      this._log.trace(
        "_checkForChanges - task already pending, dropping change with reason " +
          changeReason
      );
      return;
    }

    this._shutdownState = "_checkForChanges awaiting _updateAddons";
    this._pendingTask = this._updateAddons().then(
      result => {
        this._pendingTask = null;
        this._shutdownState = "No longer blocking, _updateAddons resolved";
        if (result.changed) {
          this._onEnvironmentChange(changeReason, result.oldEnvironment);
          this._scheduleGleanPingAddonsUpdated();
        }
      },
      err => {
        this._pendingTask = null;
        this._shutdownState = "No longer blocking, _updateAddons rejected";
        this._log.error("_checkForChanges: Error collecting addons", err);
      }
    );
  }

  _scheduleGleanPingAddonsUpdated() {
    if (!this._submitGleanPingAddonsUpdatedTask) {
      this._submitGleanPingAddonsUpdatedTask = new lazy.DeferredTask(
        () => {
          if (lazy.GLEAN_PING_ADDONS_UPDATED_TESTING) {
            Services.obs.notifyObservers(
              null,
              "test-glean-ping-addons-updated"
            );
          }
          // Submit the addons Glean ping with reason "updated" when
          // the list of addons/theme/GMPlugins has changed.
          GleanPings.addons.submit("updated");
        },
        lazy.GLEAN_PING_ADDONS_UPDATED_DELAY_MS,
        lazy.GLEAN_PING_ADDONS_UPDATED_IDLE_TIMEOUT_MS
      );
      AddonManager.beforeShutdown.addBlocker(
        "EnvironmentAddonBuilder::GleanPingAddonsUpdated",
        () => this._finalizeGleanPingAddonsUpdatedTask(),
        { fetchState: () => this._shutdownState }
      );
    }
    this._submitGleanPingAddonsUpdatedTask.arm();
  }

  _finalizeGleanPingAddonsUpdatedTask() {
    try {
      this._submitGleanPingAddonsUpdatedTask?.disarm();
      this._submitGleanPingAddonsUpdatedTask?.finalize();
    } catch (err) {
      this._log.error(
        "Unexpected failure on disarming and finalizing _submitGleanPingAddonsUpdatedTask",
        err
      );
    } finally {
      if (this._submitGleanPingAddonsUpdatedTask?.isFinalized) {
        this._submitGleanPingAddonsUpdatedTask = null;
      }
    }
  }

  async _shutdownBlocker() {
    if (this._loaded) {
      AddonManager.removeAddonListener(this);
      if (this._gmpProviderObserverAdded) {
        Services.obs.removeObserver(this, GMP_PROVIDER_REGISTERED_TOPIC);
        this._gmpProviderObserverAdded = false;
      }
    }

    // At startup, _pendingTask is set to a Promise that does not resolve
    // until the addons database has been read so complete details about
    // addons are available.  Returning it here will cause it to block
    // profileBeforeChange, guranteeing that full information will be
    // available by the time profileBeforeChangeTelemetry is fired.
    await this._pendingTask;
    this._shutdownCompleted = true;
  }

  /**
   * Collect the addon data for the environment.
   *
   * This should only be called from _pendingTask; otherwise we risk
   * running this during addon manager shutdown.
   *
   * @returns Promise<Object> This returns a Promise resolved with a status object with the following members:
   *   changed - Whether the environment changed.
   *   oldEnvironment - Only set if a change occured, contains the environment data before the change.
   */
  async _updateAddons() {
    this._log.trace("_updateAddons");

    let addons = {
      activeAddons: await this._getActiveAddons(),
      // NOTE: about:telemetry expects `theme` to always be set to an object (potentially empty
      // if no theme was yet found installed and active), see Bug 1994389.
      theme: (await this._getActiveTheme()) ?? {},
      activeGMPlugins: await this._getActiveGMPlugins(),
    };

    let result = {
      changed:
        !this._currentEnvironment.addons ||
        !lazy.ObjectUtils.deepEqual(
          addons.activeAddons,
          this._currentEnvironment.addons.activeAddons
        ),
    };

    if (result.changed) {
      this._log.trace("_updateAddons: addons differ");
      result.oldEnvironment = Cu.cloneInto(this._currentEnvironment, {});
    }
    this._currentEnvironment.addons = addons;

    // Convert into the appropriate schema and record the addon environment
    // data in Glean
    let activeAddonsGlean = Object.entries(addons.activeAddons).map(
      ([id, { type, ...rest }]) => ({ id, addonType: type, ...rest })
    );
    Glean.addons.activeAddons.set(activeAddonsGlean);
    // On android builds theme add-ons are not supported, and so
    // if _getActiveTheme returned an empty object we do not set
    // the Glean.addons.theme object metric (given that setting it
    // to null is not a valid value for a Glean object metric).
    if (addons.theme) {
      Glean.addons.theme.set(addons.theme);
    }
    Glean.addons.activeGMPlugins.set(
      Object.entries(addons.activeGMPlugins).map(([id, value]) => ({
        id,
        ...value,
      }))
    );

    return result;
  }

  /**
   * Get the addon data in object form.
   *
   * @return Promise<object> containing the addon data.
   */
  async _getActiveAddons() {
    // Request addons, asynchronously.
    // "theme" is excluded because it is already handled by _getActiveTheme.
    let { addons: allAddons, fullData } = await AddonManager.getActiveAddons(
      AddonManagerPrivate.getAddonTypesByProvider("XPIProvider").filter(
        addonType => addonType != "theme"
      )
    );

    this._addonsAreFull = fullData;
    let activeAddons = {};
    for (let addon of allAddons) {
      // NOTE: This if block should be dropped completely (along with the similar one
      // on the _onAddonChange side) once we have migrated the Desktop builds away from
      // the legacy TelemetryEnvironment
      if (
        !lazy.AMTELEMETRY_ADDONS_BUILDER_ENABLED &&
        addon.isBuiltin &&
        !addon.isSystem
      ) {
        continue;
      }

      // Weird addon data in the wild can lead to exceptions while collecting
      // the data.
      try {
        // Make sure to have valid dates (built-in add-ons are
        // expected to not have a valid update date).
        let updateDate = isNaN(addon.updateDate?.valueOf())
          ? new Date(0)
          : new Date(Math.max(0, addon.updateDate));

        activeAddons[addon.id] = {
          version: this.limitStringToLength(
            addon.version,
            MAX_ADDON_STRING_LENGTH
          ),
          scope: addon.scope,
          type: addon.type,
          updateDay: lazy.TelemetryUtils.millisecondsToDays(
            updateDate.getTime()
          ),
          isSystem: addon.isSystem,
          isWebExtension: addon.isWebExtension,
          multiprocessCompatible: true,
        };

        // getActiveAddons() gives limited data during startup and full
        // data after the addons database is loaded.
        if (fullData) {
          // Make sure to have valid dates (built-in add-ons are
          // expected to not have a valid install date).
          let installDate = isNaN(addon.installDate?.valueOf())
            ? new Date(0)
            : new Date(Math.max(0, addon.installDate));

          Object.assign(activeAddons[addon.id], {
            blocklisted:
              addon.blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
            description: this.limitStringToLength(
              addon.description,
              MAX_ADDON_STRING_LENGTH
            ),
            name: this.limitStringToLength(addon.name, MAX_ADDON_STRING_LENGTH),
            userDisabled: this.enforceBoolean(addon.userDisabled),
            appDisabled: addon.appDisabled,
            foreignInstall: this.enforceBoolean(addon.foreignInstall),
            hasBinaryComponents: false,
            installDay: lazy.TelemetryUtils.millisecondsToDays(
              installDate.getTime()
            ),
            signedState: addon.signedState,
            signedTypes: JSON.stringify(addon.signedTypes),
            quarantineIgnoredByApp: this.enforceBoolean(
              addon.quarantineIgnoredByApp
            ),
            quarantineIgnoredByUser: this.enforceBoolean(
              addon.quarantineIgnoredByUser
            ),
          });
        }
      } catch (ex) {
        this._log.error(
          "_getActiveAddons - An addon was discarded due to an error",
          ex
        );
        continue;
      }
    }

    return activeAddons;
  }

  /**
   * Get the currently active theme data in object form.
   *
   * @return Promise<object> containing the active theme data.
   */
  async _getActiveTheme() {
    // Request themes, asynchronously.
    let { addons: themes } = await AddonManager.getActiveAddons(["theme"]);

    let activeTheme = null;
    // We only store information about the active theme.
    let theme = themes.find(themeAddon => themeAddon.isActive);
    if (theme) {
      // Make sure to have valid dates.
      let installDate = new Date(Math.max(0, theme.installDate));
      let updateDate = new Date(Math.max(0, theme.updateDate));

      activeTheme = {
        id: theme.id,
        blocklisted:
          theme.blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED,
        description: this.limitStringToLength(
          theme.description,
          MAX_ADDON_STRING_LENGTH
        ),
        name: this.limitStringToLength(theme.name, MAX_ADDON_STRING_LENGTH),
        userDisabled: this.enforceBoolean(theme.userDisabled),
        appDisabled: theme.appDisabled,
        version: this.limitStringToLength(
          theme.version,
          MAX_ADDON_STRING_LENGTH
        ),
        scope: theme.scope,
        foreignInstall: this.enforceBoolean(theme.foreignInstall),
        hasBinaryComponents: false,
        installDay: lazy.TelemetryUtils.millisecondsToDays(
          installDate.getTime()
        ),
        updateDay: lazy.TelemetryUtils.millisecondsToDays(updateDate.getTime()),
        signedState: theme.signedState,
        signedTypes: JSON.stringify(theme.signedTypes),
      };
    }

    return activeTheme;
  }

  /**
   * Get the GMPlugins data in object form.
   *
   * @return Object containing the GMPlugins data.
   *
   * This should only be called from _pendingTask; otherwise we risk
   * running this during addon manager shutdown.
   */
  async _getActiveGMPlugins() {
    // If we haven't yet loaded the blocklist, pass back dummy data for now,
    // and add an observer to update this data as soon as we get it.
    if (!AddonManager.hasProvider("GMPProvider")) {
      if (!this._gmpProviderObserverAdded) {
        Services.obs.addObserver(this, GMP_PROVIDER_REGISTERED_TOPIC);
        this._gmpProviderObserverAdded = true;
      }
      return {
        "dummy-gmp": {
          version: "0.1",
          userDisabled: false,
          applyBackgroundUpdates: 1,
        },
      };
    }
    // Request plugins, asynchronously.
    let allPlugins = await AddonManager.getAddonsByTypes(["plugin"]);

    let activeGMPlugins = {};
    for (let plugin of allPlugins) {
      // Only get info for active GMplugins.
      if (!plugin.isGMPlugin || !plugin.isActive) {
        continue;
      }

      try {
        activeGMPlugins[plugin.id] = {
          version: plugin.version,
          userDisabled: this.enforceBoolean(plugin.userDisabled),
          applyBackgroundUpdates: plugin.applyBackgroundUpdates,
        };
      } catch (ex) {
        this._log.error(
          "_getActiveGMPlugins - A GMPlugin was discarded due to an error",
          ex
        );
        continue;
      }
    }

    return activeGMPlugins;
  }
}

/**
 * Listens to the AddonManager install and addon events and send telemetry events.
 */
AMTelemetry = {
  telemetrySetupDone: false,
  telemetryAddonBuilder: null,

  // This method is called by the AddonManager, once it has been started, so that we can
  // init the telemetry event category and start listening for the events related to the
  // addons installation and management.
  onStartup() {
    if (this.telemetrySetupDone) {
      return;
    }

    this.telemetrySetupDone = true;

    Services.obs.addObserver(this, "addon-install-origin-blocked");
    Services.obs.addObserver(this, "addon-install-disabled");
    Services.obs.addObserver(this, "addon-install-blocked");

    AddonManager.addInstallListener(this);
    AddonManager.addAddonListener(this);

    if (lazy.AMTELEMETRY_ADDONS_BUILDER_ENABLED) {
      this.telemetryAddonBuilder = new EnvironmentAddonBuilder();
      this.telemetryAddonBuilder.init().finally(() => {
        this.telemetryAddonBuilder.watchForChanges();
      });
    }
  },

  // NOTE: used by AddonTestUtils.promiseShutdownManager to ensure
  // we can initialize it again in AddonTestUtils.promiseStartupManager.
  // Also used by test_blocklist_statechange_telemetry.js.
  async uninit() {
    if (!this.telemetrySetupDone) {
      return;
    }

    Services.obs.removeObserver(this, "addon-install-origin-blocked");
    Services.obs.removeObserver(this, "addon-install-disabled");
    Services.obs.removeObserver(this, "addon-install-blocked");

    AddonManager.removeInstallListener(this);
    AddonManager.removeAddonListener(this);

    await this.telemetryAddonBuilder?.uninit();
    this.telemetryAddonBuilder = null;

    this.telemetrySetupDone = false;
  },

  // Observer Service notification callback.

  observe(subject, topic) {
    switch (topic) {
      case "addon-install-blocked": {
        const { installs } = subject.wrappedJSObject;
        this.recordInstallEvent(installs[0], { step: "site_warning" });
        break;
      }
      case "addon-install-origin-blocked": {
        const { installs } = subject.wrappedJSObject;
        this.recordInstallEvent(installs[0], { step: "site_blocked" });
        break;
      }
      case "addon-install-disabled": {
        const { installs } = subject.wrappedJSObject;
        this.recordInstallEvent(installs[0], {
          step: "install_disabled_warning",
        });
        break;
      }
    }
  },

  // AddonManager install listener callbacks.

  onNewInstall(install) {
    this.recordInstallEvent(install, { step: "started" });
  },

  onInstallCancelled(install) {
    this.recordInstallEvent(install, { step: "cancelled" });
  },

  onInstallPostponed(install) {
    this.recordInstallEvent(install, { step: "postponed" });
  },

  onInstallFailed(install) {
    this.recordInstallEvent(install, { step: "failed" });
  },

  onInstallEnded(install) {
    this.recordInstallEvent(install, { step: "completed" });
    // Skip install_stats events for install objects related to.
    // add-on updates.
    if (!install.existingAddon) {
      this.recordInstallStatsEvent(install);
    }
  },

  onDownloadStarted(install) {
    this.recordInstallEvent(install, { step: "download_started" });
  },

  onDownloadCancelled(install) {
    this.recordInstallEvent(install, { step: "cancelled" });
  },

  onDownloadEnded(install) {
    let download_time = Math.round(
      ChromeUtils.now() - install.downloadStartedAt
    );
    this.recordInstallEvent(install, {
      step: "download_completed",
      download_time,
    });
  },

  onDownloadFailed(install) {
    let download_time = Math.round(
      ChromeUtils.now() - install.downloadStartedAt
    );
    this.recordInstallEvent(install, {
      step: "download_failed",
      download_time,
    });
  },

  // Addon listeners callbacks.

  onUninstalled(addon) {
    this.recordManageEvent(addon, "uninstall");
  },

  onEnabled(addon) {
    this.recordManageEvent(addon, "enable");
  },

  onDisabled(addon) {
    this.recordManageEvent(addon, "disable");
  },

  // Internal helpers methods.

  /**
   * Get a trimmed version of the given string if it is longer than 80 chars.
   *
   * @param {string} str
   *        The original string content.
   *
   * @returns {string}
   *          The trimmed version of the string when longer than 80 chars, or the given string
   *          unmodified otherwise.
   */
  getTrimmedString(str) {
    if (str.length <= 80) {
      return str;
    }

    const length = str.length;

    // Trim the string to prevent a flood of warnings messages logged internally by recordLegacyEvent,
    // the trimmed version is going to be composed by the first 40 chars and the last 37 and 3 dots
    // that joins the two parts, to visually indicate that the string has been trimmed.
    return `${str.slice(0, 40)}...${str.slice(length - 37, length)}`;
  },

  /**
   * Retrieve the addonId for the given AddonInstall instance.
   *
   * @param {AddonInstall} install
   *        The AddonInstall instance to retrieve the addonId from.
   *
   * @returns {string | null}
   *          The addonId for the given AddonInstall instance (if any).
   */
  getAddonIdFromInstall(install) {
    // Returns the id of the extension that is being installed, as soon as the
    // addon is available in the AddonInstall instance (after being downloaded
    // and validated successfully).
    if (install.addon) {
      return install.addon.id;
    }

    // While updating an addon, the existing addon can be
    // used to retrieve the addon id since the first update event.
    if (install.existingAddon) {
      return install.existingAddon.id;
    }

    return null;
  },

  /**
   * Retrieve the telemetry event's object property value for the given
   * AddonInstall instance.
   *
   * @param {AddonInstall} install
   *        The AddonInstall instance to retrieve the event object from.
   *
   * @returns {string}
   *          The object for the given AddonInstall instance.
   */
  getEventObjectFromInstall(install) {
    let addonType;

    if (install.type) {
      // The AddonInstall wrapper already provides a type (if it was known when the
      // install object has been created).
      addonType = install.type;
    } else if (install.addon) {
      // The install flow has reached a step that has an addon instance which we can
      // check to know the extension type (e.g. after download for the DownloadAddonInstall).
      addonType = install.addon.type;
    } else if (install.existingAddon) {
      // The install flow is an update and we can look the existingAddon to check which was
      // the add-on type that is being installed.
      addonType = install.existingAddon.type;
    }

    return this.getEventObjectFromAddonType(addonType);
  },

  /**
   * Retrieve the telemetry event source for the given AddonInstall instance.
   *
   * @param {AddonInstall} install
   *        The AddonInstall instance to retrieve the source from.
   *
   * @returns {object | null}
   *          The telemetry infor ({source, method}) from the given AddonInstall instance.
   */
  getInstallTelemetryInfo(install) {
    if (install.installTelemetryInfo) {
      return install.installTelemetryInfo;
    } else if (
      install.existingAddon &&
      install.existingAddon.installTelemetryInfo
    ) {
      // Get the install source from the existing addon (e.g. for an extension update).
      return install.existingAddon.installTelemetryInfo;
    }

    return null;
  },

  /**
   * Get the telemetry event's object property for the given addon type
   *
   * @param {string} addonType
   *        The addon type to convert into the related telemetry event object.
   *
   * @returns {string}
   *          The object for the given addon type.
   */
  getEventObjectFromAddonType(addonType) {
    // NOTE: Telemetry events' object maximum length is 20 chars (See https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/collection/events.html#limits)
    // and the value needs to matching the "^[a-zA-Z][a-zA-Z0-9_.]*[a-zA-Z0-9]$" pattern.
    switch (addonType) {
      case undefined:
        return "unknown";
      case "extension":
      case "theme":
      case "locale":
      case "dictionary":
      case "sitepermission":
        return addonType;
      default:
        // Currently this should only include gmp-plugins ("plugin").
        return "other";
    }
  },

  convertToString(value) {
    if (value == null) {
      // Convert null and undefined to empty strings.
      return "";
    }
    switch (typeof value) {
      case "string":
        return value;
      case "boolean":
        return value ? "1" : "0";
    }
    return String(value);
  },

  /**
   * Return the UTM parameters found in `sourceURL` for AMO attribution data.
   *
   * @param {string} sourceURL
   *        The source URL from where the add-on has been installed.
   *
   * @returns {object}
   *          An object containing the attribution data for AMO if any. Keys
   *          are defined in `AMO_ATTRIBUTION_DATA_KEYS`. Values are strings.
   */
  parseAttributionDataForAMO(sourceURL) {
    let searchParams = URL.parse(sourceURL)?.searchParams;
    if (!searchParams) {
      return {};
    }

    const utmKeys = [...searchParams.keys()].filter(key =>
      AMO_ATTRIBUTION_DATA_KEYS.includes(key)
    );

    return utmKeys.reduce((params, key) => {
      let value = searchParams.get(key);
      if (typeof value === "string") {
        value = value.slice(0, AMO_ATTRIBUTION_DATA_MAX_LENGTH);
      }

      return { ...params, [key]: value };
    }, {});
  },

  /**
   * Record an "install stats" event when the source is included in
   * `AMO_ATTRIBUTION_ALLOWED_SOURCES`.
   *
   * @param {AddonInstall} install
   *        The AddonInstall instance to record an install_stats event for.
   */
  recordInstallStatsEvent(install) {
    const telemetryInfo = this.getInstallTelemetryInfo(install);

    if (!AMO_ATTRIBUTION_ALLOWED_SOURCES.includes(telemetryInfo?.source)) {
      return;
    }

    const method = "install_stats";
    const object = this.getEventObjectFromInstall(install);
    const addonId = this.getAddonIdFromInstall(install);

    if (!addonId) {
      Cu.reportError(
        "Missing addonId when trying to record an install_stats event"
      );
      return;
    }

    let extra = {
      addon_id: this.getTrimmedString(addonId),
    };

    if (
      telemetryInfo?.source === "amo" &&
      typeof telemetryInfo?.sourceURL === "string"
    ) {
      extra = {
        ...extra,
        ...this.parseAttributionDataForAMO(telemetryInfo.sourceURL),
      };
    }

    if (
      telemetryInfo?.source === "disco" &&
      typeof telemetryInfo?.taarRecommended === "boolean"
    ) {
      extra = {
        ...extra,
        taar_based: this.convertToString(telemetryInfo.taarRecommended),
      };
    }

    this.recordLegacyEvent({
      method,
      object,
      value: install.hashedAddonId,
      extra,
    });
    Glean.addonsManager.installStats.record(
      this.formatExtraVars({
        addon_id: addonId,
        addon_type: object,
        hashed_addon_id: install.hashedAddonId,
        taar_based: extra.taar_based,
        utm_campaign: extra.utm_campaign,
        utm_content: extra.utm_content,
        utm_medium: extra.utm_medium,
        utm_source: extra.utm_source,
      })
    );
  },

  /**
   * Convert all the telemetry event's extra_vars into strings, if needed.
   *
   * @param {object} extraVars
   * @returns {object} The formatted extra vars.
   */
  formatExtraVars(extraVars) {
    // All the extra_vars in a telemetry event have to be strings.
    for (var [key, value] of Object.entries(extraVars)) {
      if (value == undefined) {
        delete extraVars[key];
      } else {
        extraVars[key] = this.convertToString(value);
      }
    }

    return extraVars;
  },

  /**
   * Record an install or update event for the given AddonInstall instance.
   *
   * @param {AddonInstall} install
   *        The AddonInstall instance to record an install or update event for.
   * @param {object} extraVars
   *        The additional extra_vars to include in the recorded event.
   * @param {string} extraVars.step
   *        The current step in the install or update flow.
   * @param {string} extraVars.download_time
   *        The number of ms needed to download the extension.
   * @param {string} extraVars.num_strings
   *        The number of permission description string for the extension
   *        permission doorhanger.
   */
  recordInstallEvent(install, extraVars) {
    // Early exit if AMTelemetry's telemetry setup has not been done yet.
    if (!this.telemetrySetupDone) {
      return;
    }

    let extra = {};

    let telemetryInfo = this.getInstallTelemetryInfo(install);
    if (telemetryInfo && typeof telemetryInfo.source === "string") {
      extra.source = telemetryInfo.source;
    }

    if (extra.source === "internal") {
      // Do not record the telemetry event for installation sources
      // that are marked as "internal".
      return;
    }

    // Also include the install source's method when applicable (e.g. install events with
    // source "about:addons" may have "install-from-file" or "url" as their source method).
    if (telemetryInfo && typeof telemetryInfo.method === "string") {
      extra.method = telemetryInfo.method;
    }

    let addonId = this.getAddonIdFromInstall(install);
    let object = this.getEventObjectFromInstall(install);

    let installId = String(install.installId);
    let eventMethod = install.existingAddon ? "update" : "install";

    if (addonId) {
      extra.addon_id = this.getTrimmedString(addonId);
    }

    if (install.error) {
      extra.error = AddonManager.errorToString(install.error);
    }

    if (
      eventMethod === "install" &&
      Services.prefs.getBoolPref("extensions.install_origins.enabled", true)
    ) {
      // This is converted to "1" / "0".
      extra.install_origins = Array.isArray(install.addon?.installOrigins);
    }

    if (eventMethod === "update") {
      // For "update" telemetry events, also include an extra var which determine
      // if the update has been requested by the user.
      extra.updated_from = install.isUserRequestedUpdate ? "user" : "app";
    }

    // All the extra vars in a telemetry event have to be strings.
    extra = this.formatExtraVars({ ...extraVars, ...extra });

    this.recordLegacyEvent({
      method: eventMethod,
      object,
      value: installId,
      extra,
    });
    Glean.addonsManager[eventMethod]?.record(
      this.formatExtraVars({
        addon_id: extra.addon_id,
        addon_type: object,
        install_id: installId,
        download_time: extra.download_time,
        error: extra.error,
        source: extra.source,
        source_method: extra.method,
        num_strings: extra.num_strings,
        updated_from: extra.updated_from,
        install_origins: extra.install_origins,
        step: extra.step,
      })
    );
  },

  /**
   * Record a manage event for the given addon.
   *
   * @param {AddonWrapper} addon
   *        The AddonWrapper instance.
   * @param {object} extraVars
   *        The additional extra_vars to include in the recorded event.
   * @param {string} extraVars.num_strings
   *        The number of permission description string for the extension
   *        permission doorhanger.
   */
  recordManageEvent(addon, method, extraVars) {
    // Early exit if AMTelemetry's telemetry setup has not been done yet.
    if (!this.telemetrySetupDone) {
      return;
    }

    let extra = {};

    if (addon.installTelemetryInfo) {
      if ("source" in addon.installTelemetryInfo) {
        extra.source = addon.installTelemetryInfo.source;
      }

      // Also include the install source's method when applicable (e.g. install events with
      // source "about:addons" may have "install-from-file" or "url" as their source method).
      if ("method" in addon.installTelemetryInfo) {
        extra.method = addon.installTelemetryInfo.method;
      }
    }

    extra.blocklist_state = `${addon.blocklistState}`;

    if (extra.source === "internal") {
      // Do not record the telemetry event for installation sources
      // that are marked as "internal".
      return;
    }

    let object = this.getEventObjectFromAddonType(addon.type);
    let value = this.getTrimmedString(addon.id);

    extra = { ...extraVars, ...extra };

    let hasExtraVars = !!Object.keys(extra).length;
    extra = this.formatExtraVars(extra);

    this.recordLegacyEvent({
      method,
      object,
      value,
      extra: hasExtraVars ? extra : null,
    });
    Glean.addonsManager.manage.record(
      this.formatExtraVars({
        method,
        addon_id: value,
        addon_type: object,
        source: extra.source,
        source_method: extra.method,
        num_strings: extra.num_strings,
        blocklist_state: extra.blocklist_state,
      })
    );
  },

  /**
   * @param {object} opts
   * @param {nsIURI} opts.displayURI
   */
  recordSuspiciousSiteEvent({ displayURI }) {
    let site = displayURI?.displayHost ?? "(unknown)";
    Glean.addonsManager.reportSuspiciousSite.record(
      this.formatExtraVars({ suspicious_site: site })
    );
  },

  recordLegacyEvent({ method, object, value, extra }) {
    if (typeof value == "string") {
      if (!extra) {
        extra = {};
      }
      extra.value = value;
    }
    const eventName = `${method}_${object}`.replace(/(_[a-z])/g, c =>
      c[1].toUpperCase()
    );
    Glean.addonsManager[eventName].record(extra);
  },
};

/**
 * AMBrowserExtensionsImport is used by the migration wizard to import/install
 * Firefox add-ons based on a set of non-Firefox browser extensions.
 */
AMBrowserExtensionsImport = {
  TELEMETRY_SOURCE: "browser-import",
  TOPIC_CANCELLED: "webextension-imported-addons-cancelled",
  TOPIC_COMPLETE: "webextension-imported-addons-complete",
  TOPIC_PENDING: "webextension-imported-addons-pending",

  // AddonId => AddonInstall
  _pendingInstallsMap: new Map(),
  _importInProgress: false,
  _canCompleteOrCancelInstalls: false,
  // Prompt handler set on the AddonInstall instances part of the imports
  // (which currently makes sure we are not prompting for permissions when the
  // imported addons are being downloaded, staged and then installed).
  _installPromptHandler: () => {},
  // Optionally override the `AddonRepository`, mainly for testing purposes.
  _addonRepository: null,

  get hasPendingImportedAddons() {
    return !!this._pendingInstallsMap.size;
  },

  get importedAddonIDs() {
    return Array.from(this._pendingInstallsMap.keys());
  },

  get canCompleteOrCancelInstalls() {
    return this._canCompleteOrCancelInstalls && this.hasPendingImportedAddons;
  },

  get addonRepository() {
    return this._addonRepository || lazy.AddonRepository;
  },

  /**
   * Stage an install for each add-on mapped to a browser extension ID in the
   * list of IDs passed to this method.
   *
   * @param {string} browserId A browser identifier.
   * @param {Array<string} extensionIDs A list of non-Firefox extension IDs.
   * @returns {Promise<object>} The return value is an object with data for
   *                            the caller.
   * @throws {Error} When there are pending imported add-ons.
   */
  async stageInstalls(browserId, extensionIDs) {
    // In case we have an import in progress, we throw so that the caller knows
    // that there is already an import in progress, which it may want to either
    // cancel or complete.
    if (this._importInProgress) {
      throw new Error(
        "Cannot stage installs because there are pending imported add-ons"
      );
    }
    this._importInProgress = true;
    this._canCompleteOrCancelInstalls = false;

    let importedAddons = [];
    // We first retrieve a list of `AddonSearchResult`, which are the Firefox
    // add-ons mapped to the list of extension IDs passed to this method. We
    // might not have as many mapped add-ons as extension IDs because not all
    // browser extensions will be mapped to Firefox add-ons.
    try {
      let matchedIDs = [];
      let unmatchedIDs = [];

      ({
        addons: importedAddons,
        matchedIDs,
        unmatchedIDs,
      } = await this.addonRepository.getMappedAddons(browserId, extensionIDs));

      Glean.browserMigration.matchedExtensions.set(matchedIDs);
      Glean.browserMigration.unmatchedExtensions.set(unmatchedIDs);
    } catch (err) {
      Cu.reportError(err);
    }

    const alreadyInstalledIDs = (await AddonManager.getAllAddons()).map(
      addon => addon.id
    );

    const { _pendingInstallsMap } = this;

    const results = await Promise.allSettled(
      // For each add-on to import, we create an `AddonInstall` instance and we
      // start the install process until we reach the "downloaded ended" step.
      // At this point, we call `postpone()`, and we are done when the add-on
      // install has been postponed.
      importedAddons
        // Do not import add-ons already installed.
        .filter(({ id }) => !alreadyInstalledIDs.includes(id))
        .map(async ({ id, sourceURI, name, version, icons }) => {
          let addonInstall;

          try {
            addonInstall = await AddonManager.getInstallForURL(sourceURI.spec, {
              name,
              version,
              icons,
              telemetryInfo: { source: this.TELEMETRY_SOURCE },
              promptHandler: this._installPromptHandler,
            });
          } catch (err) {
            return Promise.reject(err);
          }

          return new Promise((resolve, reject) => {
            const rejectWithMessage = err => () => reject(new Error(err));

            addonInstall.addListener({
              onDownloadEnded(install) {
                install
                  .postpone(null, /* requiresRestart */ false)
                  .then(_pendingInstallsMap.set(id, install));
              },

              onInstallPostponed() {
                resolve();
              },

              onDownloadCancelled: rejectWithMessage("Download cancelled"),
              onDownloadFailed: rejectWithMessage("Download failed"),
              onInstallCancelled: rejectWithMessage("Install cancelled"),
              onInstallFailed: rejectWithMessage("Install failed"),
            });

            addonInstall.install();
          });
        })
    );
    this._reportErrors(results);

    // All the imported add-ons should have been staged for install at this
    // point, unless there was no add-on mapped OR some errors.
    const { importedAddonIDs } = this;

    this._canCompleteOrCancelInstalls = !!importedAddonIDs.length;
    this._importInProgress = !!importedAddonIDs.length;

    if (importedAddonIDs.length) {
      Services.obs.notifyObservers(null, this.TOPIC_PENDING);
    }

    return { importedAddonIDs };
  },

  /**
   * Finalize the installation of the add-ons for which we staged their install.
   *
   * @returns {Promise<void>}
   * @throws {Error} When there is no import in progress.
   */
  async completeInstalls() {
    if (!this._importInProgress) {
      throw new Error("No import in progress");
    }

    const results = await Promise.allSettled(
      Array.from(this._pendingInstallsMap.values()).map(install => {
        return install.continuePostponedInstall();
      })
    );
    this._reportErrors(results);
    this._clearInternalState();

    Services.obs.notifyObservers(null, this.TOPIC_COMPLETE);
  },

  /**
   * Cancel the installation of the add-ons for which we staged their install.
   *
   * @returns {Promise<void>}
   * @throws {Error} When there is no import in progress.
   */
  async cancelInstalls() {
    if (!this._importInProgress) {
      throw new Error("No import in progress");
    }

    const results = await Promise.allSettled(
      Array.from(this._pendingInstallsMap.values()).map(install => {
        return install.cancel();
      })
    );
    this._reportErrors(results);
    this._clearInternalState();

    Services.obs.notifyObservers(null, this.TOPIC_CANCELLED);
  },

  _reportErrors(results) {
    results
      .filter(result => result.status === "rejected")
      .forEach(result => Cu.reportError(result.reason));
  },

  _clearInternalState() {
    this._pendingInstallsMap.clear();
    this._importInProgress = false;
    this._canCompleteOrCancelInstalls = false;
  },
};

AddonManager.init();

// Setup the AMTelemetry once the AddonManager has been started.
AddonManager.addManagerListener(AMTelemetry);
Object.freeze(AddonManagerInternal);
Object.freeze(AddonManagerPrivate);
Object.freeze(AddonManager);
