chromium/chrome/browser/resources/chromeos/accessibility/chromevox/common/locale_output_helper.ts

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

/**
 * @fileoverview Provides locale output services for ChromeVox, which
 * uses language information to automatically change the TTS voice.
 * Please note: we use the term 'locale' to refer to language codes e.g.
 * 'en-US'. For more information on locales:
 * https://en.wikipedia.org/wiki/Locale_(computer_software)
 */
import {TestImportManager} from '/common/testing/test_import_manager.js';

import {Msgs} from './msgs.js';

type AutomationNode = chrome.automation.AutomationNode;
type TtsVoice = chrome.tts.TtsVoice;

interface TextWithLocale {
  text: string;
  locale: string;
}

export class LocaleOutputHelper {
  private availableVoices_: TtsVoice[];
  private currentLocale_: string;
  private lastSpokenLocale_: string;

  static instance: LocaleOutputHelper;

  private constructor() {
    this.currentLocale_ = BROWSER_UI_LOCALE || '';
    this.lastSpokenLocale_ = this.currentLocale_;
    this.availableVoices_ = [];

    const setAvailableVoices = (): void => {
      chrome.tts.getVoices(voices => {
        this.availableVoices_ = voices || [];
      });
    };
    setAvailableVoices();
    if (window.speechSynthesis) {
      window.speechSynthesis.addEventListener(
          'voiceschanged', setAvailableVoices, /* useCapture */ false);
    }
  }

  /**
   * Computes |this.currentLocale_| and |outputString|, and returns them.
   * @param contextNode The AutomationNode that owns |text|.
   */
  computeTextAndLocale(text: string, contextNode: AutomationNode)
      : TextWithLocale {
    if (!text || !contextNode) {
      return {text, locale: BROWSER_UI_LOCALE};
    }

    // Prefer the node's detected locale and fall back on the author-assigned
    // locale.
    const nodeLocale =
        contextNode.detectedLanguage || contextNode.language || '';
    const newLocale = this.computeNewLocale_(nodeLocale);
    let outputString = text;
    const shouldAnnounce = this.shouldAnnounceLocale_(newLocale);
    if (this.hasVoiceForLocale_(newLocale)) {
      this.setCurrentLocale_(newLocale);
      if (shouldAnnounce) {
        this.lastSpokenLocale_ = newLocale;
        // Prepend the human-readable locale to |outputString|.
        const displayLanguage =
            chrome.accessibilityPrivate.getDisplayNameForLocale(
                newLocale /* Locale to translate */,
                newLocale /* Target locale */);
        outputString =
            Msgs.getMsg('language_switch', [displayLanguage, outputString]);
      }
    } else {
      // Alert the user that no voice is available for |newLocale|.
      this.setCurrentLocale_(BROWSER_UI_LOCALE);
      const displayLanguage =
          chrome.accessibilityPrivate.getDisplayNameForLocale(
              newLocale /* Locale to translate */,
              BROWSER_UI_LOCALE /* Target locale */);
      outputString =
          Msgs.getMsg('voice_unavailable_for_language', [displayLanguage]);
    }

    return {text: outputString, locale: this.currentLocale_};
  }

  private computeNewLocale_(nodeLocale: string): string {
    nodeLocale = nodeLocale.toLowerCase();
    if (LocaleOutputHelper.isValidLocale_(nodeLocale)) {
      return nodeLocale;
    }

    return BROWSER_UI_LOCALE;
  }

  private hasVoiceForLocale_(targetLocale: string): boolean {
    const components = targetLocale.split('-');
    if (!components || components.length === 0) {
      return false;
    }

    const targetLanguage = components[0];
    for (const voice of this.availableVoices_) {
      if (!voice.lang) {
        continue;
      }
      const candidateLanguage = voice.lang.toLowerCase().split('-')[0];
      if (candidateLanguage === targetLanguage) {
        return true;
      }
    }

    return false;
  }

  private setCurrentLocale_(locale: string): void {
    if (LocaleOutputHelper.isValidLocale_(locale)) {
      this.currentLocale_ = locale;
    }
  }

  private shouldAnnounceLocale_(newLocale: string): boolean {
    const [lastSpokenLanguage, lastSpokenCountry] =
        this.lastSpokenLocale_.split('-');
    const [newLanguage, newCountry] = newLocale.split('-');
    if (lastSpokenLanguage !== newLanguage) {
      return true;
    }

    if (!newCountry) {
      // If |newCountry| is undefined, then we don't want to announce the
      // locale. For example, we don't want to announce 'en-us' -> 'en'.
      return false;
    }

    return lastSpokenCountry !== newCountry;
  }

  // =============== Static Methods ==============

  /** Creates a singleton instance of LocaleOutputHelper. */
  static init(): void {
    if (LocaleOutputHelper.instance !== undefined) {
      throw new Error(
          'LocaleOutputHelper is a singleton, can only initialize once');
    }

    LocaleOutputHelper.instance = new LocaleOutputHelper();
  }

  private static isValidLocale_(locale: string): boolean {
    return chrome.accessibilityPrivate.getDisplayNameForLocale(
               locale, locale) !== '';
  }
}

// Local to module.

const BROWSER_UI_LOCALE = chrome.i18n.getUILanguage().toLowerCase();

TestImportManager.exportForTesting(LocaleOutputHelper);