chromium/chrome/browser/resources/ash/settings/os_languages_page/languages.ts

// Copyright 2015 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * @fileoverview 'settings-languages' handles Chrome's language and input
 * method settings. The 'languages' property, which reflects the current
 * language settings, must not be changed directly. Instead, changes to
 * language settings should be made using the LanguageHelper APIs provided by
 * this class via languageHelper.
 */

// TODO(b/263828712): Upstream and downstream changes from browser settings, and
// consider merging the two.

import '/shared/settings/prefs/prefs.js';

import {PrefsMixin} from '/shared/settings/prefs/prefs_mixin.js';
import {CrSettingsPrefs} from '/shared/settings/prefs/prefs_types.js';
import {assert} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {castExists} from '../assert_extras.js';

import {LanguagesBrowserProxy, LanguagesBrowserProxyImpl} from './languages_browser_proxy.js';
import {LanguageHelper, LanguagesModel, LanguageState, SpellCheckLanguageState} from './languages_types.js';

const MoveType = chrome.languageSettingsPrivate.MoveType;

// Translate server treats some language codes the same.
// See also: components/translate/core/common/translate_util.cc.
const kLanguageCodeToTranslateCode = {
  'nb': 'no',
  'fil': 'tl',
  'zh-HK': 'zh-TW',
  'zh-MO': 'zh-TW',
  'zh-SG': 'zh-CN',
} as const;

// Some ISO 639 language codes have been renamed, e.g. "he" to "iw", but
// Translate still uses the old versions. TODO(michaelpg): Chrome does too.
// Follow up with Translate owners to understand the right thing to do.
const kTranslateLanguageSynonyms = {
  he: 'iw',
  jv: 'jw',
} as const;

// The fake language name used for ARC IMEs. The value must be in sync with the
// one in ui/base/ime/ash/extension_ime_util.h.
const kArcImeLanguage = '_arc_ime_language_';

// The IME ID for the Accessibility Common extension used by Dictation.
export const ACCESSIBILITY_COMMON_IME_ID =
    '_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';

interface ModelArgs {
  // Unused.
  supportedLanguages: chrome.languageSettingsPrivate.Language[];
  translateTarget: string;
  alwaysTranslateCodes: string[];
  neverTranslateCodes: string[];
  startingUILanguage: string;
  // TODO(b/263824661): Remove undefined from these definitions if we do not
  // share this file with Chrome browser.
  /** Always defined on CrOS. */
  supportedInputMethods: (chrome.languageSettingsPrivate.InputMethod[]|
                          undefined);
  /** Always defined on CrOS. */
  currentInputMethodId: (string|undefined);
}

/**
 * Singleton element that generates the languages model on start-up and
 * updates it whenever Chrome's pref store and other settings change.
 */
const SettingsLanguagesElementBase = PrefsMixin(PolymerElement);

export class SettingsLanguagesElement extends SettingsLanguagesElementBase
    implements LanguageHelper {
  static get is() {
    return 'settings-languages' as const;
  }

  static get properties() {
    return {
      languages: {
        type: Object,
        notify: true,
        // TODO(b/238031866): Remove readOnly here and set `this.languages` with
        // an assignment instead of a `this._setProperty`. See
        // https://crrev.com/c/3176181/comment/63b644b9_ee7ad7df/ for more
        // details.
        readOnly: true,
      },

      /**
       * This element, as a LanguageHelper instance for API usage.
       */
      languageHelper: {
        type: Object,
        notify: true,
        readOnly: true,
        value(this: SettingsLanguagesElement): LanguageHelper {
          return this;
        },
      },

      /**
       * PromiseResolver to be resolved when the singleton has been initialized.
       */
      resolver_: {
        type: Object,
        value() {
          return new PromiseResolver();
        },
      },

      /**
       * Hash map of supported languages by language codes for fast lookup.
       */
      supportedLanguageMap_: {
        type: Object,
        value() {
          return new Map();
        },
      },

      /**
       * Hash set of enabled language codes for membership testing.
       */
      enabledLanguageSet_: {
        type: Object,
        value() {
          return new Set();
        },
      },

      /**
       * Hash map of supported input methods by ID for fast lookup.
       */
      supportedInputMethodMap_: {
        type: Object,
        value() {
          return new Map();
        },
      },

      /**
       * Hash map of input methods supported for each language.
       */
      languageInputMethods_: {
        type: Object,
        value() {
          return new Map();
        },
      },

      /**
       * Hash set of enabled input methods id for mebership testings
       */
      enabledInputMethodSet_: {
        type: Object,
        value() {
          return new Set();
        },
      },

      /** Prospective UI language when the page was loaded. */
      originalProspectiveUILanguage_: String,
    };
  }

  static get observers() {
    return [
      // All observers wait for the model to be populated by including the
      // |languages| property.
      'alwaysTranslateLanguagesPrefChanged_(' +
          'prefs.translate_allowlists.value.*, languages)',
      'neverTranslateLanguagesPrefChanged_(' +
          'prefs.translate_blocked_languages.value.*, languages)',
      'prospectiveUiLanguageChanged_(prefs.intl.app_locale.value, languages)',
      'preferredLanguagesPrefChanged_(' +
          'prefs.intl.accept_languages.value, languages)',
      'preferredLanguagesPrefChanged_(' +
          'prefs.intl.forced_languages.value.*, languages)',
      'spellCheckDictionariesPrefChanged_(' +
          'prefs.spellcheck.dictionaries.value.*, ' +
          'prefs.spellcheck.forced_dictionaries.value.*, ' +
          'prefs.spellcheck.blocked_dictionaries.value.*, languages)',
      'translateLanguagesPrefChanged_(' +
          'prefs.translate_blocked_languages.value.*, languages)',
      'translateTargetPrefChanged_(' +
          'prefs.translate_recent_target.value, languages)',
      'updateRemovableLanguages_(' +
          'prefs.intl.app_locale.value, languages.enabled)',
      'updateRemovableLanguages_(' +
          'prefs.translate_blocked_languages.value.*)',
      // Observe Chrome OS prefs (ignored for non-Chrome OS).
      'updateRemovableLanguages_(' +
          'prefs.settings.language.preload_engines.value, ' +
          'prefs.settings.language.enabled_extension_imes.value, ' +
          'languages)',
    ];
  }

  // Public API: Bidirectional data flow.
  // override prefs: any;  // From PrefsMixin.

  // Public API: Upwards data flow.
  languages?: LanguagesModel;
  languageHelper: LanguageHelper;

  // API proxies.
  private browserProxy_: LanguagesBrowserProxy =
      LanguagesBrowserProxyImpl.getInstance();
  private languageSettingsPrivate_: typeof chrome.languageSettingsPrivate =
      this.browserProxy_.getLanguageSettingsPrivate();
  private inputMethodPrivate_: typeof chrome.inputMethodPrivate =
      this.browserProxy_.getInputMethodPrivate();

  // Internal state.
  private resolver_: PromiseResolver<undefined>;
  private supportedLanguageMap_:
      Map<string, chrome.languageSettingsPrivate.Language>;
  private enabledLanguageSet_: Set<string>;
  private supportedInputMethodMap_:
      Map<string, chrome.languageSettingsPrivate.InputMethod>;
  private languageInputMethods_:
      Map<string, chrome.languageSettingsPrivate.InputMethod[]>;
  private enabledInputMethodSet_: Set<string>;
  private originalProspectiveUILanguage_?: string;

  // Bound methods.
  // Instances of SettingsLanguagesElement below should be replaced with
  // (typeof this) due to possible subclasses of SettingsLanguagesElement
  // replacing these methods with a Liskov substitution principle-compatible
  // method. However, that type is too complicated for TypeScript to check (it
  // results in incorrect type errors), and we don't expect there to be any
  // subclasses.
  private boundOnSpellcheckDictionariesChanged_: OmitThisParameter<
      SettingsLanguagesElement['onSpellcheckDictionariesChanged_']>|null = null;
  private boundOnInputMethodAdded_:
      OmitThisParameter<SettingsLanguagesElement['onInputMethodAdded_']>|null =
          null;
  private boundOnInputMethodRemoved_:
      OmitThisParameter<SettingsLanguagesElement['onInputMethodRemoved_']>|
      null = null;
  private boundOnInputMethodChanged_:
      OmitThisParameter<SettingsLanguagesElement['onInputMethodChanged_']>|
      null = null;
  private boundOnLanguagePackStatusChanged_: OmitThisParameter<
      SettingsLanguagesElement['onLanguagePackStatusChanged_']>|null = null;

  // loadTimeData flags.
  // We do not expect this to change over the lifetime of this element, so this
  // is not included in `properties()` above.
  private languagePacksInSettingsEnabled_ =
      loadTimeData.getBoolean('languagePacksInSettingsEnabled');

  override connectedCallback(): void {
    super.connectedCallback();

    const promises = [];

    /**
     * An object passed into createModel to keep track of platform-specific
     * arguments, populated by the "promises" array.
     */
    const args: ModelArgs = {
      supportedLanguages: [],
      translateTarget: '',
      alwaysTranslateCodes: [],
      neverTranslateCodes: [],
      startingUILanguage: '',

      supportedInputMethods: [],
      currentInputMethodId: '',
    };

    // Wait until prefs are initialized before creating the model, so we can
    // include information about enabled languages.
    promises.push(CrSettingsPrefs.initialized);

    // Get the language list.
    promises.push(this.languageSettingsPrivate_.getLanguageList().then(
        result => args.supportedLanguages = result));

    // Get the translate target language.
    promises.push(
        this.languageSettingsPrivate_.getTranslateTargetLanguage().then(
            result => args.translateTarget = result));

    promises.push(this.languageSettingsPrivate_.getInputMethodLists().then(
        lists => args.supportedInputMethods =
            lists.componentExtensionImes.concat(
                lists.thirdPartyExtensionImes)));

    promises.push(this.inputMethodPrivate_.getCurrentInputMethod().then(
        result => args.currentInputMethodId = result));

    // Get the list of language-codes to always translate.
    promises.push(
        this.languageSettingsPrivate_.getAlwaysTranslateLanguages().then(
            result => args.alwaysTranslateCodes = result));

    // Get the list of language-codes to never translate.
    promises.push(
        this.languageSettingsPrivate_.getNeverTranslateLanguages().then(
            result => args.neverTranslateCodes = result));

    // Fetch the starting UI language, which affects which actions should be
    // enabled.
    promises.push(this.browserProxy_.getProspectiveUiLanguage().then(
        prospectiveUILanguage => {
          this.originalProspectiveUILanguage_ =
              prospectiveUILanguage || window.navigator.language;
        }));

    Promise.all(promises).then(() => {
      if (!this.isConnected) {
        // Return early if this element was detached from the DOM before
        // this async callback executes (can happen during testing).
        return;
      }

      this.createModel_(args);

      this.boundOnSpellcheckDictionariesChanged_ =
          this.onSpellcheckDictionariesChanged_.bind(this);
      this.languageSettingsPrivate_.onSpellcheckDictionariesChanged.addListener(
          this.boundOnSpellcheckDictionariesChanged_);
      this.languageSettingsPrivate_.getSpellcheckDictionaryStatuses().then(
          this.boundOnSpellcheckDictionariesChanged_);

      if (this.languagePacksInSettingsEnabled_) {
        // Get the initial state of language pack statuses.
        // Do so in the next microtask to prevent `connectedCallback()` from
        // failing and stalling tests.
        Promise.resolve().then(() => this.fetchMissingLanguagePackStatuses_());
        this.boundOnLanguagePackStatusChanged_ =
            this.onLanguagePackStatusChanged_.bind(this);
        this.inputMethodPrivate_.onLanguagePackStatusChanged.addListener(
            this.boundOnLanguagePackStatusChanged_);
      }

      this.resolver_.resolve(undefined);
    });

    this.boundOnInputMethodChanged_ = this.onInputMethodChanged_.bind(this);
    this.inputMethodPrivate_.onChanged.addListener(
        this.boundOnInputMethodChanged_);
    this.boundOnInputMethodAdded_ = this.onInputMethodAdded_.bind(this);
    this.languageSettingsPrivate_.onInputMethodAdded.addListener(
        this.boundOnInputMethodAdded_);
    this.boundOnInputMethodRemoved_ = this.onInputMethodRemoved_.bind(this);
    this.languageSettingsPrivate_.onInputMethodRemoved.addListener(
        this.boundOnInputMethodRemoved_);
  }

  override disconnectedCallback(): void {
    super.disconnectedCallback();

    // Safety: All bound methods here were set in `connectedCallback`,
    // which is guaranteed to be run before `disconnectedCallback`.
    this.inputMethodPrivate_.onChanged.removeListener(
        castExists(this.boundOnInputMethodChanged_));
    this.boundOnInputMethodChanged_ = null;
    this.languageSettingsPrivate_.onInputMethodAdded.removeListener(
        castExists(this.boundOnInputMethodAdded_));
    this.boundOnInputMethodAdded_ = null;
    this.languageSettingsPrivate_.onInputMethodRemoved.removeListener(
        castExists(this.boundOnInputMethodRemoved_));
    this.boundOnInputMethodRemoved_ = null;

    if (this.boundOnSpellcheckDictionariesChanged_) {
      this.languageSettingsPrivate_.onSpellcheckDictionariesChanged
          .removeListener(this.boundOnSpellcheckDictionariesChanged_);
      this.boundOnSpellcheckDictionariesChanged_ = null;
    }
    if (this.boundOnLanguagePackStatusChanged_) {
      this.inputMethodPrivate_.onLanguagePackStatusChanged.removeListener(
          this.boundOnLanguagePackStatusChanged_);
      this.boundOnLanguagePackStatusChanged_ = null;
    }
  }

  /**
   * Updates the prospective UI language based on the new pref value.
   */
  private prospectiveUiLanguageChanged_(prospectiveUILanguage: string): void {
    this.set(
        'languages.prospectiveUILanguage',
        prospectiveUILanguage || this.originalProspectiveUILanguage_);
  }

  /**
   * Updates the list of enabled languages from the preferred languages pref.
   */
  private preferredLanguagesPrefChanged_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }

    const enabledLanguageStates = this.getEnabledLanguageStates_(
        this.languages.translateTarget, this.languages.prospectiveUILanguage);

    // Recreate the enabled language set before updating languages.enabled.
    this.enabledLanguageSet_.clear();
    for (const enabledLanguageState of enabledLanguageStates) {
      this.enabledLanguageSet_.add(enabledLanguageState.language.code);
    }

    this.set('languages.enabled', enabledLanguageStates);

    if (this.boundOnSpellcheckDictionariesChanged_) {
      this.languageSettingsPrivate_.getSpellcheckDictionaryStatuses().then(
          this.boundOnSpellcheckDictionariesChanged_);
    }

    // Update translate target language.
    this.languageSettingsPrivate_.getTranslateTargetLanguage().then(result => {
      this.set('languages.translateTarget', result);
    });
  }

  /**
   * Updates the spellCheckEnabled state of each enabled language.
   */
  private spellCheckDictionariesPrefChanged_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }

    const spellCheckSet = this.makeSetFromArray_(
        this.getPref<string[]>('spellcheck.dictionaries').value);
    const spellCheckForcedSet = this.makeSetFromArray_(
        this.getPref<string[]>('spellcheck.forced_dictionaries').value);
    const spellCheckBlockedSet = this.makeSetFromArray_(
        this.getPref<string[]>('spellcheck.blocked_dictionaries').value);

    for (const [i, languageState] of this.languages.enabled.entries()) {
      const isUser = spellCheckSet.has(languageState.language.code);
      const isForced = spellCheckForcedSet.has(languageState.language.code);
      const isBlocked = spellCheckBlockedSet.has(languageState.language.code);
      this.set(
          `languages.enabled.${i}.spellCheckEnabled`,
          (isUser && !isBlocked) || isForced);
      this.set(`languages.enabled.${i}.isManaged`, isForced || isBlocked);
    }

    const {on: spellCheckOnLanguages, off: spellCheckOffLanguages} =
        this.getSpellCheckLanguages_(this.languages.supported);
    this.set('languages.spellCheckOnLanguages', spellCheckOnLanguages);
    this.set('languages.spellCheckOffLanguages', spellCheckOffLanguages);
  }

  /**
   * Returns two arrays of SpellCheckLanguageStates for spell check languages:
   * one for spell check on, one for spell check off.
   * @param supportedLanguages The list of supported languages, normally
   *     this.languages.supported.
   */
  private getSpellCheckLanguages_(
      supportedLanguages: chrome.languageSettingsPrivate.Language[]):
      {on: SpellCheckLanguageState[], off: SpellCheckLanguageState[]} {
    // The spell check preferences are prioritised in this order:
    // forced_dictionaries, blocked_dictionaries, dictionaries.

    // The set of all language codes seen thus far.
    const seenCodes = new Set<string>();

    /**
     * Gets the list of language codes indicated by the preference name, and
     * de-duplicates it with all other language codes.
     */
    const getPrefAndDedupe = (prefName: string): string[] => {
      const result =
          this.getPref<string[]>(prefName).value.filter(x => !seenCodes.has(x));
      result.forEach(code => seenCodes.add(code));
      return result;
    };

    const forcedCodes = getPrefAndDedupe('spellcheck.forced_dictionaries');
    const forcedCodesSet = new Set(forcedCodes);
    const blockedCodes = getPrefAndDedupe('spellcheck.blocked_dictionaries');
    const blockedCodesSet = new Set(blockedCodes);
    const enabledCodes = getPrefAndDedupe('spellcheck.dictionaries');

    const on: SpellCheckLanguageState[] = [];
    // We want to add newly enabled languages to the end of the "on" list, so we
    // should explicitly move the forced languages to the front of the list.
    for (const code of [...forcedCodes, ...enabledCodes]) {
      const language = this.supportedLanguageMap_.get(code);
      // language could be undefined if code is not in supportedLanguageMap_.
      // This should be rare, but could happen if supportedLanguageMap_ is
      // missing languages or the prefs are manually modified. We want to fail
      // gracefully if this happens - throwing an error here would cause
      // language settings to not load.
      if (language) {
        on.push({
          language,
          isManaged: forcedCodesSet.has(code),
          spellCheckEnabled: true,
          downloadDictionaryStatus: null,
          downloadDictionaryFailureCount: 0,
        });
      }
    }

    // Because the list of "spell check supported" languages is only exposed
    // through "supported languages", we need to filter that list along with
    // whether we've seen the language before.
    // We don't want to split this list in "forced" / "not-forced" like the
    // spell check on list above, as we don't want to explicitly surface / hide
    // blocked languages to the user.
    const off: SpellCheckLanguageState[] = [];

    for (const language of supportedLanguages) {
      // If spell check is off for this language, it must either not be in any
      // spell check pref, or be in the blocked dictionaries pref.
      if (language.supportsSpellcheck &&
          (!seenCodes.has(language.code) ||
           blockedCodesSet.has(language.code))) {
        off.push({
          language,
          isManaged: blockedCodesSet.has(language.code),
          spellCheckEnabled: false,
          downloadDictionaryStatus: null,
          downloadDictionaryFailureCount: 0,
        });
      }
    }

    return {
      on,
      off,
    };
  }

  /**
   * Updates the list of always translate languages from translate prefs.
   */
  private alwaysTranslateLanguagesPrefChanged_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }
    const alwaysTranslateCodes = Object.keys(
        this.getPref<Record<string, string>>('translate_allowlists').value);
    const alwaysTranslateLanguages =
        // This `getLanguage` assertion is potentially unsafe and could fail.
        // TODO(b/265554088): Prove that this assertion is safe, or rewrite this
        // to avoid this assertion.
        alwaysTranslateCodes.map(code => this.getLanguage(code)!);
    this.set('languages.alwaysTranslate', alwaysTranslateLanguages);
  }

  /**
   * Updates the list of never translate languages from translate prefs.
   */
  private neverTranslateLanguagesPrefChanged_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }
    const neverTranslateCodes =
        this.getPref<string[]>('translate_blocked_languages').value;
    const neverTranslateLanguages =
        // This `getLanguage` assertion is potentially unsafe and could fail.
        // TODO(b/265554088): Prove that this assertion is safe, or rewrite this
        // to avoid this assertion.
        neverTranslateCodes.map(code => this.getLanguage(code)!);
    this.set('languages.neverTranslate', neverTranslateLanguages);
  }

  private translateLanguagesPrefChanged_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }

    const translateBlockedPref =
        this.getPref<string[]>('translate_blocked_languages');
    const translateBlockedSet =
        this.makeSetFromArray_(translateBlockedPref.value);

    for (const [i, languageState] of this.languages.enabled.entries()) {
      const language = languageState.language;
      const translateEnabled = this.isTranslateEnabled_(
          language.code, !!language.supportsTranslate, translateBlockedSet,
          this.languages.translateTarget, this.languages.prospectiveUILanguage);
      this.set(
          'languages.enabled.' + i + '.translateEnabled', translateEnabled);
    }
  }

  private translateTargetPrefChanged_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }
    this.set(
        'languages.translateTarget',
        this.getPref('translate_recent_target').value);
  }

  /**
   * Constructs the languages model.
   * @param args used to populate the model above.
   */
  private createModel_(args: ModelArgs): void {
    // Populate the hash map of supported languages.
    for (const language of args.supportedLanguages) {
      language.supportsUI = !!language.supportsUI;
      language.supportsTranslate = !!language.supportsTranslate;
      language.supportsSpellcheck = !!language.supportsSpellcheck;
      language.isProhibitedLanguage = !!language.isProhibitedLanguage;
      this.supportedLanguageMap_.set(language.code, language);
    }

    // The below getPref call should always be defined, so the
    // `this.originalProspectiveUILanguage_` part of this expression is
    // redundant.
    // TODO(b/238031866): Investigate why we have two ways of getting the
    // prospective UI language, and simplify this expression if necessary.
    const prospectiveUILanguage =
        this.getPref<string>('intl.app_locale').value ||
        // Safety: This method is only called after all the promises
        // in `connectedCallback()` have resolved, which includes a promise
        // which sets `this.originalProspectiveUILanguage_`.
        // TODO(b/238031866): Move this variable to `ModelArgs` to avoid this
        // assertion.
        this.originalProspectiveUILanguage_!;

    // Create a list of enabled languages from the supported languages.
    const enabledLanguageStates = this.getEnabledLanguageStates_(
        args.translateTarget, prospectiveUILanguage);
    // Populate the hash set of enabled languages.
    for (const enabledLanguageState of enabledLanguageStates) {
      this.enabledLanguageSet_.add(enabledLanguageState.language.code);
    }

    const {on: spellCheckOnLanguages, off: spellCheckOffLanguages} =
        this.getSpellCheckLanguages_(args.supportedLanguages);

    const alwaysTranslateLanguages =
        // This `getLanguage` assertion is potentially unsafe and could fail.
        // TODO(b/265554088): Prove that this assertion is safe, or rewrite this
        // to avoid this assertion.
        args.alwaysTranslateCodes.map(code => this.getLanguage(code)!);

    const neverTranslateLangauges =
        // This `getLanguage` assertion is potentially unsafe and could fail.
        // TODO(b/265554088): Prove that this assertion is safe, or rewrite this
        // to avoid this assertion.
        args.neverTranslateCodes.map(code => this.getLanguage(code)!);

    // TODO(b/238031866): Remove the use of Partial here.
    const model: Partial<LanguagesModel> = {
      supported: args.supportedLanguages,
      enabled: enabledLanguageStates,
      translateTarget: args.translateTarget,
      alwaysTranslate: alwaysTranslateLanguages,
      neverTranslate: neverTranslateLangauges,
      spellCheckOnLanguages,
      spellCheckOffLanguages,
    };

    model.prospectiveUILanguage = prospectiveUILanguage;

    if (args.supportedInputMethods) {
      this.createInputMethodModel_(args.supportedInputMethods);
    }
    model.inputMethods = {
      // Safety: `ModelArgs.supportedInputMethods` is always defined on CrOS.
      supported: args.supportedInputMethods!,
      enabled: this.getEnabledInputMethods_(),
      // Safety: `ModelArgs.currentInputMethodId` is always defined on CrOS.
      currentId: args.currentInputMethodId!,
      imeLanguagePackStatus: {},
    };

    // Initialize the Polymer languages model.
    // Safety: All properties of `LanguagesModel` were set above.
    this._setProperty('languages', model as LanguagesModel);
  }

  /**
   * Returns a list of LanguageStates for each enabled language in the supported
   * languages list.
   * This must be called after `whenReady()` is resolved.
   * @param translateTarget Language code of the default translate
   *     target language.
   * @param prospectiveUILanguage Prospective UI display
   *     language. Only defined on Windows and Chrome OS.
   */
  private getEnabledLanguageStates_(
      translateTarget: string,
      prospectiveUILanguage: (string|undefined)): LanguageState[] {
    // Safety: Enforced in documentation.
    assert(CrSettingsPrefs.isInitialized);

    const pref = this.getPref<string>('intl.accept_languages');
    const enabledLanguageCodes = pref.value.split(',');
    const languagesForcedPref = this.getPref<string[]>('intl.forced_languages');
    const spellCheckPref = this.getPref<string[]>('spellcheck.dictionaries');
    const spellCheckForcedPref =
        this.getPref<string[]>('spellcheck.forced_dictionaries');
    const spellCheckBlockedPref =
        this.getPref<string[]>('spellcheck.blocked_dictionaries');
    const languageForcedSet = this.makeSetFromArray_(languagesForcedPref.value);
    const spellCheckSet = this.makeSetFromArray_(
        (spellCheckPref.value.concat(spellCheckForcedPref.value)));
    const spellCheckForcedSet =
        this.makeSetFromArray_(spellCheckForcedPref.value);
    const spellCheckBlockedSet =
        this.makeSetFromArray_(spellCheckBlockedPref.value);

    const translateBlockedPref =
        this.getPref<string[]>('translate_blocked_languages');
    const translateBlockedSet =
        this.makeSetFromArray_(translateBlockedPref.value);

    const enabledLanguageStates: LanguageState[] = [];

    for (const code of enabledLanguageCodes) {
      const language = this.supportedLanguageMap_.get(code);
      // Skip unsupported languages.
      if (!language) {
        continue;
      }
      // TODO(b/238031866): Remove the use of Partial here.
      const languageState: Partial<LanguageState> = {};
      languageState.language = language;
      languageState.spellCheckEnabled =
          spellCheckSet.has(code) && !spellCheckBlockedSet.has(code) ||
          spellCheckForcedSet.has(code);
      languageState.translateEnabled = this.isTranslateEnabled_(
          code, !!language.supportsTranslate, translateBlockedSet,
          translateTarget, prospectiveUILanguage);
      languageState.isManaged =
          spellCheckForcedSet.has(code) || spellCheckBlockedSet.has(code);
      languageState.isForced = languageForcedSet.has(code);
      languageState.downloadDictionaryFailureCount = 0;
      // This cast is very unsafe as `downloadDictionaryStatus` and `removable`
      // have not been set.
      // TODO(b/265554105): Investigate and remove this cast if possible.
      enabledLanguageStates.push(languageState as LanguageState);
    }
    return enabledLanguageStates;
  }

  /**
   * True iff we translate pages that are in the given language.
   * @param code Language code.
   * @param supportsTranslate If translation supports the given language.
   * @param translateBlockedSet Set of languages for which translation is
   *     blocked.
   * @param translateTarget Language code of the default translate target
   *     language.
   * @param prospectiveUILanguage Prospective UI display language. Only defined
   *     on Windows and Chrome OS.
   */
  private isTranslateEnabled_(
      code: string, supportsTranslate: boolean,
      translateBlockedSet: Set<string>, translateTarget: string,
      prospectiveUILanguage: (string|undefined)): boolean {
    const translateCode = this.convertLanguageCodeForTranslate(code);
    return supportsTranslate && !translateBlockedSet.has(translateCode) &&
        translateCode !== translateTarget &&
        (!prospectiveUILanguage || code !== prospectiveUILanguage);
  }

  /**
   * Updates the dictionary download status for spell check languages in order
   * to track the number of times a spell check dictionary download has failed.
   */
  private onSpellcheckDictionariesChanged_(
      statuses: chrome.languageSettingsPrivate.SpellcheckDictionaryStatus[]):
      void {
    const statusMap = new Map<
        string, chrome.languageSettingsPrivate.SpellcheckDictionaryStatus>();
    statuses.forEach(status => {
      statusMap.set(status.languageCode, status);
    });

    const collectionNames =
        ['enabled', 'spellCheckOnLanguages', 'spellCheckOffLanguages'] as const;
    for (const collectionName of collectionNames) {
      // This assertion of `this.languages` is potentially unsafe and could
      // fail.
      // TODO(b/265553377): Prove that this assertion is safe, or rewrite this
      // to avoid this assertion.
      this.languages![collectionName].forEach((languageState, index) => {
        const status = statusMap.get(languageState.language.code);
        if (!status) {
          return;
        }

        const previousStatus = languageState.downloadDictionaryStatus;
        const keyPrefix = `languages.${collectionName}.${index}`;
        this.set(`${keyPrefix}.downloadDictionaryStatus`, status);

        const failureCountKey = `${keyPrefix}.downloadDictionaryFailureCount`;
        if (status.downloadFailed &&
            !(previousStatus && previousStatus.downloadFailed)) {
          const failureCount = languageState.downloadDictionaryFailureCount + 1;
          this.set(failureCountKey, failureCount);
        } else if (
            status.isReady && !(previousStatus && previousStatus.isReady)) {
          this.set(failureCountKey, 0);
        }
      });
    }
  }

  /**
   * Updates the |removable| property of the enabled language states based
   * on what other languages and input methods are enabled.
   */
  private updateRemovableLanguages_(): void {
    if (this.prefs === undefined || this.languages === undefined) {
      return;
    }

    // TODO(michaelpg): Enabled input methods can affect which languages are
    // removable, so run updateEnabledInputMethods_ first (if it has been
    // scheduled).
    this.updateEnabledInputMethods_();

    for (const [i, languageState] of this.languages.enabled.entries()) {
      this.set(
          'languages.enabled.' + i + '.removable',
          this.canDisableLanguage(languageState));
    }
  }

  /**
   * Creates a Set from the elements of the array.
   */
  private makeSetFromArray_<T>(list: T[]): Set<T> {
    // TODO(b/238031866): Inline these calls.
    return new Set(list);
  }

  // LanguageHelper implementation.
  // TODO(michaelpg): replace duplicate docs with @override once b/24294625
  // is fixed.
  whenReady(): Promise<void> {
    return this.resolver_.promise;
  }

  /**
   * Sets the prospective UI language to the chosen language. This won't affect
   * the actual UI language until a restart.
   */
  setProspectiveUiLanguage(languageCode: string): void {
    this.browserProxy_.setProspectiveUiLanguage(languageCode);
  }

  /**
   * True if the prospective UI language was changed from its starting value.
   */
  // TODO(b/263824661): Remove this unused method if we do not share this file
  // with browser settings.
  requiresRestart(): boolean {
    return this.originalProspectiveUILanguage_ !==
        // This assertion of `this.languages` is potentially unsafe and could
        // fail.
        // TODO(b/265553377): Prove that this assertion is safe, or rewrite this
        // to avoid this assertion.
        this.languages!.prospectiveUILanguage;
  }

  /**
   * @return The language code for ARC IMEs.
   */
  getArcImeLanguageCode(): string {
    return kArcImeLanguage;
  }

  /**
   * @return True if the language is for ARC IMEs.
   */
  isLanguageCodeForArcIme(languageCode: string): boolean {
    return languageCode === kArcImeLanguage;
  }

  /**
   * @return True if the language can be translated by Chrome.
   */
  isLanguageTranslatable(language: chrome.languageSettingsPrivate.Language):
      boolean {
    if (language.code === 'zh-CN' || language.code === 'zh-TW') {
      // In Translate, general Chinese is not used, and the sub code is
      // necessary as a language code for the Translate server.
      return true;
    }
    if (language.code === this.getLanguageCodeWithoutRegion(language.code) &&
        language.supportsTranslate) {
      return true;
    }
    return false;
  }

  /**
   * @return True if the language is enabled.
   */
  isLanguageEnabled(languageCode: string): boolean {
    return this.enabledLanguageSet_.has(languageCode);
  }

  /**
   * Enables the language, making it available for spell check and input.
   */
  enableLanguage(languageCode: string): void {
    if (!CrSettingsPrefs.isInitialized) {
      return;
    }

    this.languageSettingsPrivate_.enableLanguage(languageCode);
  }

  /**
   * Disables the language.
   */
  disableLanguage(languageCode: string): void {
    if (!CrSettingsPrefs.isInitialized) {
      return;
    }

    // Chrome Browser removes the web language from spell check, as web
    // languages and spell check languages are coupled.
    // On ChromeOS, we decouple web languages and spell check languages, so
    // we intentionally omit this behaviour.

    // Remove the language from preferred languages.
    this.languageSettingsPrivate_.disableLanguage(languageCode);
  }

  isOnlyTranslateBlockedLanguage(languageState: LanguageState): boolean {
    return !languageState.translateEnabled &&
        // This assertion of `this.languages` is potentially unsafe and could
        // fail.
        // TODO(b/265553377): Prove that this assertion is safe, or rewrite this
        // to avoid this assertion.
        this.languages!.enabled.filter(lang => !lang.translateEnabled)
            .length === 1;
  }

  canDisableLanguage(languageState: LanguageState): boolean {
    // Cannot disable the only enabled language.
    // This assertion of `this.languages` is potentially unsafe and could fail.
    // TODO(b/265553377): Prove that this assertion is safe, or rewrite this to
    // avoid this assertion.
    if (this.languages!.enabled.length === 1) {
      return false;
    }

    // Cannot disable the last translate blocked language.
    if (this.isOnlyTranslateBlockedLanguage(languageState)) {
      return false;
    }

    return true;
  }

  /**
   * @return true if the given language can be enabled
   */
  canEnableLanguage(language: chrome.languageSettingsPrivate.Language):
      boolean {
    return !(
        this.isLanguageEnabled(language.code) ||
        language.isProhibitedLanguage ||
        this.isLanguageCodeForArcIme(language.code) /* internal use only */);
  }

  /**
   * Sets whether a given language should always be automatically translated.
   */
  setLanguageAlwaysTranslateState(
      languageCode: string, alwaysTranslate: boolean): void {
    this.languageSettingsPrivate_.setLanguageAlwaysTranslateState(
        languageCode, alwaysTranslate);
  }

  /**
   * Moves the language in the list of enabled languages either up (toward the
   * front of the list) or down (toward the back).
   * @param upDirection True if we need to move up, false if we
   *     need to move down
   */
  moveLanguage(languageCode: string, upDirection: boolean): void {
    if (!CrSettingsPrefs.isInitialized) {
      return;
    }

    if (upDirection) {
      this.languageSettingsPrivate_.moveLanguage(languageCode, MoveType.UP);
    } else {
      this.languageSettingsPrivate_.moveLanguage(languageCode, MoveType.DOWN);
    }
  }

  /**
   * Moves the language directly to the front of the list of enabled languages.
   */
  moveLanguageToFront(languageCode: string): void {
    if (!CrSettingsPrefs.isInitialized) {
      return;
    }

    this.languageSettingsPrivate_.moveLanguage(languageCode, MoveType.TOP);
  }

  /**
   * Enables translate for the given language by removing the translate
   * language from the blocked languages preference.
   */
  enableTranslateLanguage(languageCode: string): void {
    this.languageSettingsPrivate_.setEnableTranslationForLanguage(
        languageCode, true);
  }

  /**
   * Disables translate for the given language by adding the translate
   * language to the blocked languages preference.
   */
  disableTranslateLanguage(languageCode: string): void {
    this.languageSettingsPrivate_.setEnableTranslationForLanguage(
        languageCode, false);
  }

  /**
   * Sets the translate target language and adds it to the content languages if
   * not already there.
   */
  setTranslateTargetLanguage(languageCode: string): void {
    this.languageSettingsPrivate_.setTranslateTargetLanguage(languageCode);
  }

  /**
   * Enables or disables spell check for the given language.
   */
  toggleSpellCheck(languageCode: string, enable: boolean): void {
    if (!this.languages) {
      return;
    }

    if (enable) {
      this.appendPrefListItem('spellcheck.dictionaries', languageCode);
    } else {
      this.deletePrefListItem('spellcheck.dictionaries', languageCode);
    }
  }

  /**
   * Converts the language code for translate. There are some differences
   * between the language set the Translate server uses and that for
   * Accept-Language.
   * @return The converted language code.
   */
  convertLanguageCodeForTranslate(languageCode: string): string {
    if (languageCode in kLanguageCodeToTranslateCode) {
      // Work around https://github.com/microsoft/TypeScript/issues/21732.
      // As of writing, it is marked as fixed by
      // https://github.com/microsoft/TypeScript/pull/50666, but that PR does
      // not address this specific issue of narrowing a `string` down to keys of
      // an object.
      type LanguageCode = keyof typeof kLanguageCodeToTranslateCode;
      // Safety: We checked that languageCode is a key above.
      return kLanguageCodeToTranslateCode[languageCode as LanguageCode];
    }

    const main = languageCode.split('-')[0];
    if (main === undefined) {
      // The only time a split could return 0 items is if the string is empty.
      throw new Error('languageCode cannot be empty');
    }
    if (main === 'zh') {
      // In Translate, general Chinese is not used, and the sub code is
      // necessary as a language code for the Translate server.
      return languageCode;
    }
    if (main in kTranslateLanguageSynonyms) {
      type TranslateSynonymKey = keyof typeof kTranslateLanguageSynonyms;
      // Safety: We checked that languageCode is a key above.
      return kTranslateLanguageSynonyms[main as TranslateSynonymKey];
    }

    return main;
  }

  /**
   * Given a language code, returns just the base language. E.g., converts
   * 'en-GB' to 'en'.
   */
  getLanguageCodeWithoutRegion(languageCode: string): string {
    // The Norwegian languages fall under the 'no' macrolanguage.
    if (languageCode === 'nb' || languageCode === 'nn') {
      return 'no';
    }

    // The installer still uses the old language code "iw", instead of "he",
    // for Hebrew. It needs to be converted to "he", otherwise it will not be
    // found in supportedLanguageMap_.
    //
    // Note that this value is saved in the user's local state. Even
    // if the installer is changed to use "he", because the installer does not
    // overwrite this value, the conversion is still needed for old users.
    if (languageCode === 'iw') {
      return 'he';
    }

    // Match the characters before the hyphen.
    // This assertion is unsafe if `languageCode` is an empty string, or starts
    // with a hyphen.
    // TODO(b/265554105): Gracefully handle this case.
    const result = languageCode.match(/^([^-]+)-?/)!;
    // Safety: The regex above has one non-optional capturing group.
    assert(result.length === 2);
    return result[1]!;
  }

  getLanguage(languageCode: string): chrome.languageSettingsPrivate.Language
      |undefined {
    // If a languageCode is not found, try language without location.
    return this.supportedLanguageMap_.get(languageCode) ||
        this.supportedLanguageMap_.get(
            this.getLanguageCodeWithoutRegion(languageCode));
  }

  /**
   * Retries downloading the dictionary for |languageCode|.
   */
  retryDownloadDictionary(languageCode: string): void {
    this.languageSettingsPrivate_.retryDownloadDictionary(languageCode);
  }

  /**
   * Constructs the input method part of the languages model.
   */
  private createInputMethodModel_(
      supportedInputMethods: chrome.languageSettingsPrivate.InputMethod[]):
      void {
    // Populate the hash map of supported input methods.
    this.supportedInputMethodMap_.clear();
    this.languageInputMethods_.clear();
    for (const inputMethod of supportedInputMethods) {
      inputMethod.enabled = !!inputMethod.enabled;
      inputMethod.isProhibitedByPolicy = !!inputMethod.isProhibitedByPolicy;
      // Add the input method to the map of IDs.
      this.supportedInputMethodMap_.set(inputMethod.id, inputMethod);
      // Add the input method to the list of input methods for each language
      // it supports.
      for (const languageCode of inputMethod.languageCodes) {
        if (!this.supportedLanguageMap_.has(languageCode)) {
          continue;
        }
        const inputMethods = this.languageInputMethods_.get(languageCode);
        if (inputMethods === undefined) {
          this.languageInputMethods_.set(languageCode, [inputMethod]);
        } else {
          inputMethods.push(inputMethod);
        }
      }
    }
  }

  /**
   * Returns a list of enabled input methods.
   *
   * This must be called after `whenReady()` is resolved.
   */
  private getEnabledInputMethods_():
      chrome.languageSettingsPrivate.InputMethod[] {
    // Safety: Enforced in documentation.
    assert(CrSettingsPrefs.isInitialized);

    let enabledInputMethodIds =
        this.getPref<string>('settings.language.preload_engines')
            .value.split(',');
    enabledInputMethodIds = enabledInputMethodIds.concat(
        this.getPref<string>('settings.language.enabled_extension_imes')
            .value.split(','));
    this.enabledInputMethodSet_ = new Set(enabledInputMethodIds);

    // Return only supported input methods. Don't include the Dictation
    // (Accessibility Common) input method.
    return enabledInputMethodIds
        .map(id => this.supportedInputMethodMap_.get(id))
        .filter(
            <T>(inputMethod: T): inputMethod is NonNullable<T> => !!inputMethod)
        .filter(inputMethod => inputMethod.id !== ACCESSIBILITY_COMMON_IME_ID);
  }

  private async updateSupportedInputMethods_(): Promise<void> {
    const lists = await this.languageSettingsPrivate_.getInputMethodLists();
    const supportedInputMethods =
        lists.componentExtensionImes.concat(lists.thirdPartyExtensionImes);
    this.createInputMethodModel_(supportedInputMethods);
    // The two lines below are potentially unsafe and could fail, as they assume
    // that `this.languages` is defined.
    // TODO(b/265553377): Prove that this assertion is safe, or rewrite this to
    // avoid this assertion.
    this.set('languages.inputMethods.supported', supportedInputMethods);
    this.updateEnabledInputMethods_();
  }

  private updateEnabledInputMethods_(): void {
    const enabledInputMethods = this.getEnabledInputMethods_();
    const enabledInputMethodSet = this.makeSetFromArray_(enabledInputMethods);

    // This assertion of `this.languages` is potentially unsafe and could fail.
    // TODO(b/265553377): Prove that this assertion is safe, or rewrite this to
    // avoid this assertion.
    // Safety: `LanguagesModel.inputMethods` is always defined on CrOS.
    for (const [i, inputMethod] of this.languages!.inputMethods!.supported
             .entries()) {
      this.set(
          'languages.inputMethods.supported.' + i + '.enabled',
          enabledInputMethodSet.has(inputMethod));
    }
    this.set('languages.inputMethods.enabled', enabledInputMethods);
    if (this.languagePacksInSettingsEnabled_) {
      this.fetchMissingLanguagePackStatuses_();
    }
  }

  addInputMethod(id: string): void {
    if (!this.supportedInputMethodMap_.has(id)) {
      return;
    }
    this.languageSettingsPrivate_.addInputMethod(id);
  }

  removeInputMethod(id: string): void {
    if (!this.supportedInputMethodMap_.has(id)) {
      return;
    }
    this.languageSettingsPrivate_.removeInputMethod(id);
  }

  setCurrentInputMethod(id: string): void {
    this.inputMethodPrivate_.setCurrentInputMethod(id);
  }

  getCurrentInputMethod(): Promise<string> {
    return this.inputMethodPrivate_.getCurrentInputMethod();
  }

  getInputMethodsForLanguage(languageCode: string):
      chrome.languageSettingsPrivate.InputMethod[] {
    return this.languageInputMethods_.get(languageCode) || [];
  }

  /**
   * Returns the input methods that support any of the given languages.
   */
  getInputMethodsForLanguages(languageCodes: string[]):
      chrome.languageSettingsPrivate.InputMethod[] {
    // Input methods that have already been listed for this language.
    const usedInputMethods = new Set<string>();
    const combinedInputMethods: chrome.languageSettingsPrivate.InputMethod[] =
        [];
    for (const languageCode of languageCodes) {
      const inputMethods = this.getInputMethodsForLanguage(languageCode);
      // Get the language's unused input methods and mark them as used.
      const newInputMethods = inputMethods.filter(
          inputMethod => !usedInputMethods.has(inputMethod.id));
      newInputMethods.forEach(
          inputMethod => usedInputMethods.add(inputMethod.id));
      combinedInputMethods.push(...newInputMethods);
    }
    return combinedInputMethods;
  }

  /**
   * @return list of enabled language code.
   */
  getEnabledLanguageCodes(): Set<string> {
    return this.enabledLanguageSet_;
  }

  /**
   * @param id the input method id
   * @return True if the input method is enabled
   */
  isInputMethodEnabled(id: string): boolean {
    return this.enabledInputMethodSet_.has(id);
  }

  isComponentIme(inputMethod: chrome.languageSettingsPrivate.InputMethod):
      boolean {
    return inputMethod.id.startsWith('_comp_');
  }

  /** @param id Input method ID. */
  openInputMethodOptions(id: string): void {
    this.inputMethodPrivate_.openOptionsPage(id);
  }

  /** @param id New current input method ID. */
  private onInputMethodChanged_(id: string): void {
    this.set('languages.inputMethods.currentId', id);
  }

  /** @param _id Added input method ID. */
  private onInputMethodAdded_(_id: string): void {
    this.updateSupportedInputMethods_();
  }

  /** @param id Removed input method ID. */
  private onInputMethodRemoved_(_id: string): void {
    this.updateSupportedInputMethods_();
  }

  /**
   * @param id Input method ID.
   */
  getInputMethodDisplayName(id: string): string {
    const inputMethod = this.supportedInputMethodMap_.get(id);
    if (inputMethod === undefined) {
      return '';
    }
    return inputMethod.displayName;
  }

  private setLanguagePackStatus_(
      id: string, status: chrome.inputMethodPrivate.LanguagePackStatus): void {
    this.set(
        ['languages', 'inputMethods', 'imeLanguagePackStatus', id], status);
  }

  /**
   * Fetch the language pack status of enabled input methods which we do not
   * have a status for.
   */
  private fetchMissingLanguagePackStatuses_(): void {
    if (!this.languages) {
      return;
    }
    // Safety: `LanguagesModel.inputMethods` is always defined on CrOS.
    for (const inputMethod of this.languages.inputMethods!.enabled) {
      if (this.languages.inputMethods!.imeLanguagePackStatus[inputMethod.id] ===
          undefined) {
        // Explicitly set this input method status to unknown to prevent future
        // calls of this method from fetching this again.
        this.languages.inputMethods!.imeLanguagePackStatus[inputMethod.id] =
            chrome.inputMethodPrivate.LanguagePackStatus.UNKNOWN;

        void this.inputMethodPrivate_.getLanguagePackStatus(inputMethod.id)
            .then((status) => {
              this.setLanguagePackStatus_(inputMethod.id, status);
            });
      }
    }
  }

  private onLanguagePackStatusChanged_(
      change: chrome.inputMethodPrivate.LanguagePackStatusChange): void {
    for (const engineId of change.engineIds) {
      this.setLanguagePackStatus_(engineId, change.status);
    }
  }

  getImeLanguagePackStatus(id: string):
      chrome.inputMethodPrivate.LanguagePackStatus {
    // Safety: `LanguagesModel.inputMethods` is always defined on CrOS.
    return this.languages?.inputMethods!.imeLanguagePackStatus[id] ??
        chrome.inputMethodPrivate.LanguagePackStatus.UNKNOWN;
  }
}

customElements.define(SettingsLanguagesElement.is, SettingsLanguagesElement);

declare global {
  interface HTMLElementTagNameMap {
    [SettingsLanguagesElement.is]: SettingsLanguagesElement;
  }
}