// 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;
}
}