chromium/chrome/browser/resources/chromeos/accessibility/select_to_speak/prefs_manager.ts

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

import {TestImportManager} from '/common/testing/test_import_manager.js';

import {SelectToSpeakConstants} from './select_to_speak_constants.js';

/**
 * Manages getting and storing user preferences.
 */
export class PrefsManager {
  /** Please keep fields in alphabetical order. */

  private backgroundShadingEnabled_ = false;
  private color_ = '#da36e8';
  /**
   * Whether to allow enhanced network voices in Select-to-Speak. Unlike
   * |this.enhancedNetworkVoicesEnabled_|, which represents the user's
   * preference, |this.enhancedNetworkVoicesAllowed_| is set by admin via
   * policy. |this.enhancedNetworkVoicesAllowed_| does not override
   * |this.enhancedNetworkVoicesEnabled_| but changes
   * this.enhancedNetworkVoicesEnabled().
   */
  private enhancedNetworkVoicesAllowed_ = true;
  /**
   * A pref indicating whether the user enables the network voices. The pref
   * is synced to local storage as "enhancedNetworkVoices". Use
   * this.enhancedNetworkVoicesEnabled() to refer whether to enable the
   * network voices instead of using this pref directly.
   */
  private enhancedNetworkVoicesEnabled_ = false;
  private enhancedVoiceName_ = PrefsManager.DEFAULT_NETWORK_VOICE;
  private enhancedVoicesDialogShown_ = false;
  private extensionForVoice_: Map<string, string> = new Map();
  private highlightColor_ = '#5e9bff';
  private migrationInProgress_ = false;
  private navigationControlsEnabled_ = true;
  private speechRate_ = 1.0;
  private validVoiceNames_: Set<string> = new Set();
  private voiceNameFromLocale_: string|null = null;
  private voiceNameFromPrefs_: string|null = null;
  private wordHighlight_ = true;

  /** TODO(crbug.com/950391): Ask UX about the default value here. */
  private voiceSwitching_ = false;

  /**
   * Used by tests to wait for settings changes to be propagated.
   */
  private updateSettingsPrefsCallbackForTest_: (() => {})|null = null;

  constructor() {}

  /**
   * Get the list of TTS voices, and set the default voice if not already set.
   */
  private updateDefaultVoice_(): void {
    var uiLocale = chrome.i18n.getMessage('@@ui_locale');
    uiLocale = uiLocale.replace('_', '-').toLowerCase();

    chrome.tts.getVoices(voices => {
      this.validVoiceNames_ = new Set();

      if (voices.length === 0) {
        return;
      }

      voices.forEach(voice => {
        // TODO(b/270623046): voice.eventTypes may be undefined.
        if (!voice.eventTypes!.includes(chrome.tts.EventType.START) ||
            !voice.eventTypes!.includes(chrome.tts.EventType.END) ||
            !voice.eventTypes!.includes(chrome.tts.EventType.WORD) ||
            !voice.eventTypes!.includes(chrome.tts.EventType.CANCELLED)) {
          return;
        }

        if (voice.voiceName) {
          this.extensionForVoice_.set(voice.voiceName, voice.extensionId || '');
          if ((voice.extensionId !== PrefsManager.ENHANCED_TTS_EXTENSION_ID) &&
              !voice.remote) {
            // Don't consider network voices when computing default.
            this.validVoiceNames_.add(voice.voiceName);
          }
        }
      });

      voices.sort(function(a, b) {
        function score(voice: chrome.tts.TtsVoice): number {
          if (voice.lang === undefined) {
            return -1;
          }
          var lang = voice.lang.toLowerCase();
          var s = 0;
          if (lang === uiLocale) {
            s += 2;
          }
          if (lang.substr(0, 2) === uiLocale.substr(0, 2)) {
            s += 1;
          }
          return s;
        }
        return score(b) - score(a);
      });

      const firstVoiceName = voices[0].voiceName;
      if (firstVoiceName) {
        this.voiceNameFromLocale_ = firstVoiceName;
      }
    });
  }

  /**
   * Migrates Select-to-Speak rate and pitch settings to global Text-to-Speech
   * settings. This is a one-time migration that happens on upgrade to M70.
   * See http://crbug.com/866550.
   */
  private migrateToGlobalTtsSettings_(rateStr: string, pitchStr: string): void {
    if (this.migrationInProgress_) {
      return;
    }
    this.migrationInProgress_ = true;
    let stsRate = PrefsManager.DEFAULT_RATE;
    let stsPitch = PrefsManager.DEFAULT_PITCH;
    let globalRate = PrefsManager.DEFAULT_RATE;
    let globalPitch = PrefsManager.DEFAULT_PITCH;

    if (rateStr !== undefined) {
      stsRate = parseFloat(rateStr);
    }
    if (pitchStr !== undefined) {
      stsPitch = parseFloat(pitchStr);
    }
    // Get global prefs using promises so that we can receive both pitch and
    // rate before doing migration logic.
    const getPrefsPromises = [];
    getPrefsPromises.push(new Promise<void>((resolve, reject) => {
      chrome.settingsPrivate.getPref('settings.tts.speech_rate', pref => {
        if (pref === undefined) {
          reject();
        }
        globalRate = pref.value;
        resolve();
      });
    }));
    getPrefsPromises.push(new Promise<void>((resolve, reject) => {
      chrome.settingsPrivate.getPref('settings.tts.speech_pitch', pref => {
        if (pref === undefined) {
          reject();
        }
        globalPitch = pref.value;
        resolve();
      });
    }));
    Promise.all(getPrefsPromises)
        .then(
            () => {
              const stsOptionsModified =
                  stsRate !== PrefsManager.DEFAULT_RATE ||
                  stsPitch !== PrefsManager.DEFAULT_PITCH;
              const globalOptionsModified =
                  globalRate !== PrefsManager.DEFAULT_RATE ||
                  globalPitch !== PrefsManager.DEFAULT_PITCH;
              const optionsEqual =
                  stsRate === globalRate && stsPitch === globalPitch;
              if (optionsEqual) {
                // No need to write global prefs if all the prefs are the same
                // as defaults. Just remove STS rate and pitch.
                this.onTtsSettingsMigrationSuccess_();
                return;
              }
              if (stsOptionsModified && !globalOptionsModified) {
                // Set global prefs using promises so we can set both rate and
                // pitch successfully before removing the preferences from
                // chrome.storage.sync.
                const setPrefsPromises = [];
                setPrefsPromises.push(new Promise<void>((resolve, reject) => {
                  chrome.settingsPrivate.setPref(
                      'settings.tts.speech_rate', stsRate,
                      '' /* unused, see crbug.com/866161 */, success => {
                        if (success) {
                          resolve();
                        } else {
                          reject();
                        }
                      });
                }));
                setPrefsPromises.push(new Promise<void>((resolve, reject) => {
                  chrome.settingsPrivate.setPref(
                      'settings.tts.speech_pitch', stsPitch,
                      '' /* unused, see crbug.com/866161 */, success => {
                        if (success) {
                          resolve();
                        } else {
                          reject();
                        }
                      });
                }));
                Promise.all(setPrefsPromises)
                    .then(
                        () => this.onTtsSettingsMigrationSuccess_(), error => {
                          console.log(error);
                          this.migrationInProgress_ = false;
                        });
              } else if (globalOptionsModified) {
                // Global options were already modified, so STS will use global
                // settings regardless of whether STS was modified yet or not.
                this.onTtsSettingsMigrationSuccess_();
              }
            },
            error => {
              console.log(error);
              this.migrationInProgress_ = false;
            });
  }

  /**
   * When TTS settings are successfully migrated, removes rate and pitch from
   * chrome.storage.sync.
   */
  private onTtsSettingsMigrationSuccess_(): void {
    chrome.storage.sync.remove('rate');
    chrome.storage.sync.remove('pitch');
    this.migrationInProgress_ = false;
  }

  /**
   * Loads prefs and policy from chrome.settingsPrivate.
   */
  private updateSettingsPrefs_(prefs: chrome.settingsPrivate.PrefObject[]):
      void {
    for (const pref of prefs) {
      switch (pref.key) {
        case PrefsManager.VOICE_NAME_KEY:
          this.voiceNameFromPrefs_ = pref.value;
          break;
        case PrefsManager.SPEECH_RATE_KEY:
          this.speechRate_ = pref.value;
          break;
        case PrefsManager.WORD_HIGHLIGHT_KEY:
          this.wordHighlight_ = pref.value;
          break;
        case PrefsManager.HIGHLIGHT_COLOR_KEY:
          this.highlightColor_ = pref.value;
          break;
        case PrefsManager.BACKGROUND_SHADING_KEY:
          this.backgroundShadingEnabled_ = pref.value;
          break;
        case PrefsManager.NAVIGATION_CONTROLS_KEY:
          this.navigationControlsEnabled_ = pref.value;
          break;
        case PrefsManager.ENHANCED_NETWORK_VOICES_KEY:
          this.enhancedNetworkVoicesEnabled_ = pref.value;
          break;
        case PrefsManager.ENHANCED_VOICES_DIALOG_SHOWN_KEY:
          this.enhancedVoicesDialogShown_ = pref.value;
          break;
        case PrefsManager.ENHANCED_VOICE_NAME_KEY:
          this.enhancedVoiceName_ = pref.value;
          break;
        case PrefsManager.ENHANCED_VOICES_POLICY_KEY:
          this.enhancedNetworkVoicesAllowed_ = pref.value;
          break;
        case PrefsManager.VOICE_SWITCHING_KEY:
          this.voiceSwitching_ = pref.value;
          break;
      }
    }
    if (this.updateSettingsPrefsCallbackForTest_) {
      this.updateSettingsPrefsCallbackForTest_();
    }
  }

  /**
   * Migrates prefs from chrome.storage to Chrome settings prefs. This will
   * enable us to move Select-to-speak options into the Chrome OS Settings app.
   * This should only occur once per pref, as we remove the chrome.storage pref
   * after we copy it over.
   */
  private migrateStorageToSettingsPref_(
      storagePrefName: string, settingsPrefName: string, value: any): void {
    chrome.settingsPrivate.setPref(settingsPrefName, value);
    chrome.storage.sync.remove(storagePrefName);
  }

  /**
   * Loads prefs from chrome.storage and sets values in settings prefs if
   * necessary.
   */
  private async updateStoragePrefs_(): Promise<void> {
    const prefs: Record<string, any> = await new Promise(
        resolve => chrome.storage.sync.get(
            [
              'voice',
              'rate',
              'pitch',
              'wordHighlight',
              'highlightColor',
              'backgroundShading',
              'navigationControls',
              'enhancedNetworkVoices',
              'enhancedVoicesDialogShown',
              'enhancedVoiceName',
              'voiceSwitching',
            ],
            resolve));

    if (prefs['voice']) {
      this.voiceNameFromPrefs_ = prefs['voice'];
      this.migrateStorageToSettingsPref_(
          'voice', PrefsManager.VOICE_NAME_KEY, this.voiceNameFromPrefs_);
    }
    if (prefs['wordHighlight'] !== undefined) {
      this.wordHighlight_ = prefs['wordHighlight'];
      this.migrateStorageToSettingsPref_(
          'wordHighlight', PrefsManager.WORD_HIGHLIGHT_KEY,
          this.wordHighlight_);
    }
    if (prefs['highlightColor']) {
      this.highlightColor_ = prefs['highlightColor'];
      this.migrateStorageToSettingsPref_(
          'highlightColor', PrefsManager.HIGHLIGHT_COLOR_KEY,
          this.highlightColor_);
    }
    if (prefs['backgroundShading'] !== undefined) {
      this.backgroundShadingEnabled_ = prefs['backgroundShading'];
      this.migrateStorageToSettingsPref_(
          'backgroundShading', PrefsManager.BACKGROUND_SHADING_KEY,
          this.backgroundShadingEnabled_);
    }
    if (prefs['navigationControls'] !== undefined) {
      this.navigationControlsEnabled_ = prefs['navigationControls'];
      this.migrateStorageToSettingsPref_(
          'navigationControls', PrefsManager.NAVIGATION_CONTROLS_KEY,
          this.navigationControlsEnabled_);
    }
    if (prefs['enhancedNetworkVoices'] !== undefined) {
      this.enhancedNetworkVoicesEnabled_ = prefs['enhancedNetworkVoices'];
      this.migrateStorageToSettingsPref_(
          'enhancedNetworkVoices', PrefsManager.ENHANCED_NETWORK_VOICES_KEY,
          this.enhancedNetworkVoicesEnabled_);
    }
    if (prefs['enhancedVoicesDialogShown'] !== undefined) {
      this.enhancedVoicesDialogShown_ = prefs['enhancedVoicesDialogShown'];
      this.migrateStorageToSettingsPref_(
          'enhancedVoicesDialogShown',
          PrefsManager.ENHANCED_VOICES_DIALOG_SHOWN_KEY,
          this.enhancedVoicesDialogShown_);
    }
    if (prefs['enhancedVoiceName'] !== undefined) {
      this.enhancedVoiceName_ = prefs['enhancedVoiceName'];
      this.migrateStorageToSettingsPref_(
          'enhancedVoiceName', PrefsManager.ENHANCED_VOICE_NAME_KEY,
          this.enhancedVoiceName_);
    }
    if (prefs['voiceSwitching'] !== undefined) {
      this.voiceSwitching_ = prefs['voiceSwitching'];
      this.migrateStorageToSettingsPref_(
          'voiceSwitching', PrefsManager.VOICE_SWITCHING_KEY,
          this.voiceSwitching_);
    }
    if (prefs['rate'] && prefs['pitch']) {
      // Removes 'rate' and 'pitch' prefs after migrating data to global
      // TTS settings if appropriate.
      this.migrateToGlobalTtsSettings_(prefs['rate'], prefs['pitch']);
    }
  }

  /**
   * Loads prefs and policy from chrome.storage and chrome.settingsPrivate,
   * sets default values if necessary, and registers a listener to update prefs
   * and policy when they change.
   */
  async initPreferences(): Promise<void> {
    // Migrate from storage prefs if necessary.
    await this.updateStoragePrefs_();

    // Initialize prefs from settings.
    const settingsPrefs: chrome.settingsPrivate.PrefObject[] =
        await new Promise(
            resolve => chrome.settingsPrivate.getAllPrefs(resolve));
    this.updateSettingsPrefs_(settingsPrefs);

    chrome.settingsPrivate.onPrefsChanged.addListener(
        prefs => this.updateSettingsPrefs_(prefs));
    chrome.storage.onChanged.addListener(() => this.updateStoragePrefs_());

    this.updateDefaultVoice_();
    chrome.tts.onVoicesChanged.addListener(() => {
      this.updateDefaultVoice_();
    });
  }

  /**
   * Get the voice name of the user's preferred local voice.
   * @return Name of preferred local voice.
   */
  getLocalVoice(): string|undefined {
    // To use the default (system) voice: don't specify options['voiceName'].
    if (this.voiceNameFromPrefs_ === PrefsManager.SYSTEM_VOICE) {
      return undefined;
    }

    // Pick the voice name from prefs first, or the one that matches
    // the locale next, but don't pick a voice that isn't currently
    // loaded. If no voices are found, leave the voiceName option
    // unset to let the browser try to route the speech request
    // anyway if possible.
    if (this.voiceNameFromPrefs_ &&
        this.validVoiceNames_.has(this.voiceNameFromPrefs_)) {
      return this.voiceNameFromPrefs_;
    } else if (
        this.voiceNameFromLocale_ &&
        this.validVoiceNames_.has(this.voiceNameFromLocale_)) {
      return this.voiceNameFromLocale_;
    }

    return undefined;
  }

  /**
   * Generates the basic speech options for Select-to-Speak based on user
   * preferences. Call for each chrome.tts.speak.
   */
  getSpeechOptions(voiceSwitchingData:
                       SelectToSpeakConstants.VoiceSwitchingData|
                   null): chrome.tts.TtsOptions {
    const options: chrome.tts.TtsOptions = {};
    const data: SelectToSpeakConstants.VoiceSwitchingData =
        voiceSwitchingData || {
          language: undefined,
          useVoiceSwitching: false,
        };
    const useEnhancedVoices =
        this.enhancedNetworkVoicesEnabled() && navigator.onLine;

    if (useEnhancedVoices) {
      options['voiceName'] = this.enhancedVoiceName_;
    } else {
      const useVoiceSwitching = data.useVoiceSwitching;
      const language = data.language;
      // If `useVoiceSwitching` is true, then we should omit `voiceName` from
      // options and let the TTS engine pick the right voice for the language.
      const localVoice = useVoiceSwitching ? undefined : this.getLocalVoice();
      if (localVoice !== undefined) {
        options['voiceName'] = localVoice;
      }
      if (language !== undefined) {
        options['lang'] = language;
      }
    }
    return options;
  }

  /**
   * Returns extension ID of the TTS engine for given voice name.
   * @param voiceName Voice name specified in TTS options
   * @return extension ID of TTS engine
   */
  ttsExtensionForVoice(voiceName: string): string {
    return this.extensionForVoice_.get(voiceName) || '';
  }

  /**
   * Checks if the voice is an enhanced network TTS voice.
   * @returns {boolean} True if the voice is an enhanced network TTS voice.
   */
  isNetworkVoice(voiceName: string): boolean {
    return this.ttsExtensionForVoice(voiceName) ===
        PrefsManager.ENHANCED_TTS_EXTENSION_ID;
  }
  /**
   * Gets the user's word highlighting enabled preference.
   * @return True if word highlighting is enabled.
   */
  wordHighlightingEnabled(): boolean {
    return this.wordHighlight_;
  }

  /**
   * Gets the user's word highlighting color preference.
   * @return Highlight color.
   */
  highlightColor(): string {
    return this.highlightColor_;
  }

  /**
   * Gets the focus ring color. This is not currently a user preference but it
   * could be in the future; stored here for similarity to highlight color.
   * @return Highlight color.
   */
  focusRingColor(): string {
    return this.color_;
  }

  /**
   * Gets the user's focus ring background color. If the user disabled greying
   * out the background, alpha will be set to fully transparent.
   * @return True if the background shade should be drawn.
   */
  backgroundShadingEnabled(): boolean {
    return this.backgroundShadingEnabled_;
  }

  /**
   * Gets the user's preference for showing navigation controls that allow them
   * to navigate to next/previous sentences, paragraphs, and more.
   * @return True if navigation controls should be shown when STS is
   *     active.
   */
  navigationControlsEnabled(): boolean {
    return this.navigationControlsEnabled_;
  }

  /**
   * Gets the user's preference for speech rate.
   * @return Current TTS speech rate.
   */
  speechRate(): number {
    return this.speechRate_;
  }

  /**
   * Gets the user's preference for whether enhanced network TTS voices are
   * enabled. Always returns false if the policy disallows the feature.
   * @return True if enhanced TTS voices are enabled.
   */
  enhancedNetworkVoicesEnabled(): boolean {
    return this.enhancedNetworkVoicesAllowed_ ?
        this.enhancedNetworkVoicesEnabled_ :
        false;
  }

  /**
   * Gets the admin's policy for whether enhanced network TTS voices are
   * allowed.
   * @return True if enhanced TTS voices are allowed.
   */
  enhancedNetworkVoicesAllowed(): boolean {
    return this.enhancedNetworkVoicesAllowed_;
  }

  /**
   * Gets whether the initial popup authorizing enhanced network voices has been
   * shown to the user or not.
   *
   * @returns True if the initial popup dialog has been shown already.
   */
  enhancedVoicesDialogShown(): boolean {
    return this.enhancedVoicesDialogShown_;
  }

  /**
   * Sets whether enhanced network voices are enabled or not from initial popup.
   * @param enabled Specifies if the user enabled enhanced voices in
   *     the popup.
   */
  setEnhancedNetworkVoicesFromDialog(enabled: boolean): void {
    if (enabled === undefined) {
      return;
    }
    this.enhancedNetworkVoicesEnabled_ = enabled;
    chrome.settingsPrivate.setPref(
        PrefsManager.ENHANCED_NETWORK_VOICES_KEY,
        this.enhancedNetworkVoicesEnabled_);

    this.enhancedVoicesDialogShown_ = true;
    chrome.settingsPrivate.setPref(
        PrefsManager.ENHANCED_VOICES_DIALOG_SHOWN_KEY,
        this.enhancedVoicesDialogShown_);

    if (!this.enhancedNetworkVoicesAllowed_) {
      console.warn(
          'Network voices dialog was shown when the policy disallows it.');
    }
  }

  /**
   * Gets the user's preference for whether automatic voice switching between
   * languages is enabled.
   */
  voiceSwitchingEnabled(): boolean {
    return this.voiceSwitching_;
  }
}

export namespace PrefsManager {
  /**
   * Constant used as the value for a menu option representing the current
   * device language.
   */
  export const USE_DEVICE_LANGUAGE = 'select_to_speak_device_language';

  /**
   * Constant representing the system TTS voice.
   */
  export const SYSTEM_VOICE = 'select_to_speak_system_voice';

  /**
   * Constant representing the voice name for the default (server-selected)
   * network TTS voice.
   */
  export const DEFAULT_NETWORK_VOICE = 'default-wavenet';

  /**
   * Extension ID of the enhanced network TTS voices extension.
   */
  export const ENHANCED_TTS_EXTENSION_ID = 'jacnkoglebceckolkoapelihnglgaicd';

  /**
   * Extension ID of the Google TTS voices extension.
   */
  export const GOOGLE_TTS_EXTENSION_ID = 'gjjabgpgjpampikjhjpfhneeoapjbjaf';

  /**
   * Extension ID of the eSpeak TTS voices extension.
   */
  export const ESPEAK_EXTENSION_ID = 'dakbfdmgjiabojdgbiljlhgjbokobjpg';

  /**
   * Default speech rate for both Select-to-Speak and global prefs.
   */
  export const DEFAULT_RATE = 1.0;

  /**
   * Default speech pitch for both Select-to-Speak and global prefs.
   */
  export const DEFAULT_PITCH = 1.0;

  /**
   * Settings key for the pref for whether to shade the background area of the
   * screen (where text isn't currently being spoken).
   */
  export const BACKGROUND_SHADING_KEY =
      'settings.a11y.select_to_speak_background_shading';

  /**
   * Settings key for the pref for whether enhanced network TTS voices are
   * enabled.
   */
  export const ENHANCED_NETWORK_VOICES_KEY =
      'settings.a11y.select_to_speak_enhanced_network_voices';

  /**
   * Settings key for the pref indicating the user's enhanced voice preference.
   */
  export const ENHANCED_VOICE_NAME_KEY =
      'settings.a11y.select_to_speak_enhanced_voice_name';

  /**
   * Settings key for the pref indicating whether initial popup authorizing
   * enhanced network voices has been shown to the user or not.
   */
  export const ENHANCED_VOICES_DIALOG_SHOWN_KEY =
      'settings.a11y.select_to_speak_enhanced_voices_dialog_shown';

  /**
   * Settings key for the policy indicating whether to allow enhanced network
   * voices.
   */
  export const ENHANCED_VOICES_POLICY_KEY =
      'settings.a11y.enhanced_network_voices_in_select_to_speak_allowed';

  /**
   * Settings key for the pref indicating the user's word highlighting color
   * preference.
   */
  export const HIGHLIGHT_COLOR_KEY =
      'settings.a11y.select_to_speak_highlight_color';

  /**
   * Settings key for the pref for showing navigation controls.
   */
  export const NAVIGATION_CONTROLS_KEY =
      'settings.a11y.select_to_speak_navigation_controls';

  /**
   * Settings key for the pref indicating the user's system-wide preference TTS
   * speech rate.
   */
  export const SPEECH_RATE_KEY = 'settings.tts.speech_rate';

  /**
   * Settings key for the pref indicating the user's voice preference.
   */
  export const VOICE_NAME_KEY = 'settings.a11y.select_to_speak_voice_name';

  /**
   * Settings key for the pref for enabling automatic voice switching between
   * languages.
   */
  export const VOICE_SWITCHING_KEY =
      'settings.a11y.select_to_speak_voice_switching';

  /**
   * Settings key for the pref indicating whether to enable word highlighting.
   */
  export const WORD_HIGHLIGHT_KEY =
      'settings.a11y.select_to_speak_word_highlight';
}

TestImportManager.exportForTesting(PrefsManager);