Commit 12e14c64 authored by Akihiro Ota's avatar Akihiro Ota Committed by Commit Bot

ChromeVox lang switching: Alert user if we do not have available voice

This changes ChromeVox language switching behavior to alert users
if we do not have an available voice for the current language, instead
of failing silently or butchering pronunciation with a different voice.
This change:
1. Adds logic in LanguageSwitching.js to check if there is a matching
voice for a language before making a language assignment
2. Modifies the accessibilityPrivate.getDisplayLanguage() api to
take an additional parameter (the desired output language)
3. Adds a LanguageSwitchingTest to confirm new behavior.
4. Modifies existing API tests to confirm new behavior.

Bug: 1008481
Change-Id: I5537c916a0e6b435c5c833b8e7fe7a9e530df65b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1829547
Commit-Queue: Akihiro Ota <akihiroota@chromium.org>
Reviewed-by: default avatarDavid Tseng <dtseng@chromium.org>
Reviewed-by: default avatarDevlin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/master@{#706050}
parent 4ba7eb3a
......@@ -42,6 +42,13 @@ LanguageSwitching.PROBABILITY_THRESHOLD_ = 0.9;
*/
LanguageSwitching.sub_node_switching_enabled_ = false;
/**
* An array of all available TTS voices.
* @type {!Array<!TtsVoice>}
* @private
*/
LanguageSwitching.availableVoices_ = [];
/**
* Initialization function for language switching.
*/
......@@ -53,18 +60,29 @@ LanguageSwitching.init = function() {
function(enabled) {
LanguageSwitching.sub_node_switching_enabled_ = enabled;
});
// Ensure that availableVoices_ is set and stays updated.
function setAvailableVoices() {
chrome.tts.getVoices(function(voices) {
LanguageSwitching.availableVoices_ = voices || [];
});
}
setAvailableVoices();
if (speechSynthesis) {
speechSynthesis.addEventListener(
'voiceschanged', setAvailableVoices, /* useCapture */ false);
}
};
/*
/**
* Main language switching function.
* Cut up string attribute value into multiple spans with different
* languages. Ranges and associated language information are returned by the
* languageAnnotationsForStringAttribute() function.
* @param {AutomationNode} node
* @param {string} stringAttribute The string attribute for which we want to
* get a language annotation
* @param {Function<outputString: string, newLanguage: string>}
* appendStringWithLanguage
* get a language annotation.
* @param {function(string, string)} appendStringWithLanguage
* A callback that appends outputString to the output buffer in newLanguage.
*/
LanguageSwitching.assignLanguagesForStringAttribute = function(
......@@ -117,16 +135,32 @@ LanguageSwitching.assignLanguagesForStringAttribute = function(
stringAttributeValue, startIndex, endIndex);
var newLanguage =
LanguageSwitching.decideNewLanguage(node, language, probability);
var displayLanguage = '';
if (LanguageSwitching.didLanguageSwitch(newLanguage)) {
LanguageSwitching.currentLanguage_ = newLanguage;
var displayLanguage =
chrome.accessibilityPrivate.getDisplayLanguage(newLanguage);
// Prepend the human-readable language to outputString if language
// switched.
// Get human-readable language in |newLanguage|.
displayLanguage = chrome.accessibilityPrivate.getDisplayLanguage(
newLanguage /* Language code to translate */,
newLanguage /* Target language code */);
// Prepend the human-readable language to outputString.
outputString =
Msgs.getMsg('language_switch', [displayLanguage, outputString]);
}
appendStringWithLanguage(newLanguage, outputString);
if (LanguageSwitching.hasVoiceForLanguage(newLanguage)) {
appendStringWithLanguage(newLanguage, outputString);
} else {
// Translate |newLanguage| into human-readable string in the UI language.
displayLanguage = chrome.accessibilityPrivate.getDisplayLanguage(
newLanguage /* Language code to translate */,
LanguageSwitching.browserUILanguage_ /* Target language code */);
outputString =
Msgs.getMsg('voice_unavailable_for_language', [displayLanguage]);
// Alert the user that we have no available voice for the language.
appendStringWithLanguage(
LanguageSwitching.browserUILanguage_, outputString);
}
}
};
......@@ -227,5 +261,38 @@ LanguageSwitching.isValidLanguageCode = function(languageCode) {
if (langComponentArray[0].length !== 2 && langComponentArray[0].length !== 3)
return false;
// Use the accessibilityPrivate.getDisplayLanguage() API to validate language
// code. If the language code is invalid, then this API returns an empty
// string.
if (chrome.accessibilityPrivate.getDisplayLanguage(
languageCode, languageCode) === '') {
return false;
}
return true;
};
/**
* Returns true if there is a tts voice that supports the given languageCode.
* This function is not responsible for deciding the proper output voice, it
* simply tells us if output in |languageCode| is possible.
* @param {string} languageCode
* @return {boolean}
*/
LanguageSwitching.hasVoiceForLanguage = function(languageCode) {
// Extract language from languageCode.
var languageCodeComponents = languageCode.split('-');
if (!languageCodeComponents || (languageCodeComponents.length === 0))
return false;
var language = languageCodeComponents[0];
for (var i = 0; i < LanguageSwitching.availableVoices_.length; ++i) {
// Note: availableVoices_[i].lang is always in the form of
// 'language-region'. See link for documentation on chrome.tts api:
// https://developer.chrome.com/apps/tts#type-TtsVoice
var candidateLanguage =
LanguageSwitching.availableVoices_[i].lang.toLowerCase().split('-')[0];
if (language === candidateLanguage)
return true;
}
return false;
};
......@@ -1261,13 +1261,12 @@ Output.prototype = {
* Appends outputString to the output buffer in newLanguage.
* @param {!Array<Spannable>} buff
* @param {{isUnique: (boolean|undefined),
* annotation: !Array<*>}} opt_options
* annotation: !Array<*>}} options
* @param {string} newLanguage
* @param {string} outputString
*/
var appendStringWithLanguage = function(
buff, options, newLanguage,
outputString) {
buff, options, newLanguage, outputString) {
var speechProps = new Output.SpeechProperties();
// Set output language.
speechProps['lang'] = newLanguage;
......@@ -1276,10 +1275,11 @@ Output.prototype = {
// Attach associated SpeechProperties if the buffer is non-empty.
if (buff.length > 0)
buff[buff.length - 1].setSpan(speechProps, 0, 0);
}.bind(this, buff, options);
};
// Cut up node name into multiple spans with different languages.
LanguageSwitching.assignLanguagesForStringAttribute(
node, 'name', appendStringWithLanguage);
node, 'name',
appendStringWithLanguage.bind(this, buff, options));
} else {
// Append entire node name.
// TODO(akihiroota): Follow-up with dtseng about why we append empty
......
......@@ -55,11 +55,18 @@ chrome.virtualKeyboardPrivate.VirtualKeyboardEvent;
*/
chrome.virtualKeyboardPrivate.sendKeyEvent = function(keyEvent, opt_callback) {
};
/**
* @param {function(!{a11ymode: boolean})} opt_callback
*/
chrome.virtualKeyboardPrivate.getKeyboardConfig = function(opt_callback) {};
/**
* @type {Object}
*/
window.speechSynthesis;
/**
* @type {Event}
*/
window.speechSynthesis.onvoiceschanged;
......@@ -3742,6 +3742,9 @@ If you're done with the tutorial, use ChromeVox to navigate to the Close button
<message desc="The description of the announceRichTextDescription key. Displayed in the ChromeVox menu." name="IDS_CHROMEVOX_ANNOUNCE_RICH_TEXT_DESCRIPTION">
Announce formatting for current item
</message>
<message desc="Announced when there is no available voice for a language." name="IDS_CHROMEVOX_VOICE_UNAVAILABLE_FOR_LANGUAGE">
No voice available for language: <ph name="language">$1<ex>English</ex></ph>
</message>
</messages>
</release>
</grit>
......@@ -151,10 +151,14 @@
"name": "getDisplayLanguage",
"type": "function",
"nocompile": true,
"description": "Called to translate language code into human-readable string in the language of the provided language code.",
"description": "Called to translate languageCodeToTranslate into human-readable string in the language specified by targetLanguageCode",
"parameters": [
{
"name": "languageCode",
"name": "languageCodeToTranslate",
"type": "string"
},
{
"name": "targetLanguageCode",
"type": "string"
}
],
......
......@@ -50,20 +50,61 @@ RequestResult AccessibilityPrivateHooksDelegate::HandleRequest(
*parse_result.arguments);
}
// Called to translate a language code into human-readable string in the
// language of the provided language code. Language codes can have optional
// country code e.g. 'en' and 'en-us' both yield an output of 'English'.
// As another example, the code 'fr' would produce 'francais' as output.
// Called to translate |language_code_to_translate| into human-readable string
// in the language specified by |target_language_code|. For example, if
// language_code_to_translate = 'en' and target_language_code = 'fr', then this
// function returns 'anglais'.
// If language_code_to_translate = 'fr' and target_language_code = 'en', then
// this function returns 'french'.
RequestResult AccessibilityPrivateHooksDelegate::HandleGetDisplayLanguage(
ScriptContext* script_context,
const std::vector<v8::Local<v8::Value>>& parsed_arguments) {
DCHECK(script_context->extension());
DCHECK_EQ(1u, parsed_arguments.size());
DCHECK_EQ(2u, parsed_arguments.size());
DCHECK(parsed_arguments[0]->IsString());
icu::Locale locale = icu::Locale(
gin::V8ToString(script_context->isolate(), parsed_arguments[0]).c_str());
DCHECK(parsed_arguments[1]->IsString());
std::string language_code_to_translate =
gin::V8ToString(script_context->isolate(), parsed_arguments[0]);
std::string target_language_code =
gin::V8ToString(script_context->isolate(), parsed_arguments[1]);
// The locale whose language code we want to translate.
icu::Locale locale_to_translate =
icu::Locale(language_code_to_translate.c_str());
// The locale that |display_language| should be in.
icu::Locale target_locale = icu::Locale(target_language_code.c_str());
// Validate locales.
// Get list of available locales. Please see the ICU User Guide for more
// details: http://userguide.icu-project.org/locale.
bool valid_arg1 = false;
bool valid_arg2 = false;
int32_t num_locales = 0;
const icu::Locale* available_locales =
icu::Locale::getAvailableLocales(num_locales);
for (int32_t i = 0; i < num_locales; ++i) {
// Check both the language and country for each locale.
const char* current_language = available_locales[i].getLanguage();
const char* current_country = available_locales[i].getCountry();
if (strcmp(locale_to_translate.getLanguage(), current_language) == 0 &&
strcmp(locale_to_translate.getCountry(), current_country) == 0) {
valid_arg1 = true;
}
if (strcmp(target_locale.getLanguage(), current_language) == 0 &&
strcmp(target_locale.getCountry(), current_country) == 0) {
valid_arg2 = true;
}
}
// If either of the language codes is invalid, we should return empty string.
if (!(valid_arg1 && valid_arg2)) {
RequestResult empty_result(RequestResult::HANDLED);
empty_result.return_value = gin::StringToV8(script_context->isolate(), "");
return empty_result;
}
icu::UnicodeString display_language;
locale.getDisplayLanguage(locale, display_language);
locale_to_translate.getDisplayLanguage(target_locale, display_language);
std::string language_result =
base::UTF16ToUTF8(base::i18n::UnicodeStringToString16(display_language));
// Instead of returning "und", which is what the ICU Locale class returns for
......
......@@ -63,13 +63,17 @@ TEST_F(AccessibilityPrivateHooksDelegateTest, TestGetDisplayLanguage) {
};
// Test behavior.
EXPECT_EQ(R"("")", run_get_display_language("''"));
EXPECT_EQ(R"("")", run_get_display_language("'not a language code'"));
EXPECT_EQ(R"("English")", run_get_display_language("'en'"));
EXPECT_EQ(R"("English")", run_get_display_language("'en-US'"));
EXPECT_EQ(R"("français")", run_get_display_language("'fr'"));
EXPECT_EQ(R"("español")", run_get_display_language("'es'"));
EXPECT_EQ(R"("日本語")", run_get_display_language("'ja'"));
EXPECT_EQ(R"("")", run_get_display_language("'',''"));
EXPECT_EQ(R"("")", run_get_display_language("'not a language code','ja-JP'"));
EXPECT_EQ(R"("")",
run_get_display_language("'zh-TW', 'not a language code'"));
EXPECT_EQ(R"("English")", run_get_display_language("'en','en'"));
EXPECT_EQ(R"("English")", run_get_display_language("'en-US','en'"));
EXPECT_EQ(R"("français")", run_get_display_language("'fr','fr'"));
EXPECT_EQ(R"("español")", run_get_display_language("'es','es'"));
EXPECT_EQ(R"("日本語")", run_get_display_language("'ja','ja'"));
EXPECT_EQ(R"("anglais")", run_get_display_language("'en','fr'"));
EXPECT_EQ(R"("Japanese")", run_get_display_language("'ja','en'"));
}
} // namespace extensions
......@@ -2,27 +2,30 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
var allTests = [
function getDisplayLanguage() {
chrome.test.assertEq(
'',
chrome.accessibilityPrivate.getDisplayLanguage(''));
chrome.test.assertEq(
'',
chrome.accessibilityPrivate.getDisplayLanguage('not a language code'));
chrome.test.assertEq(
'English',
chrome.accessibilityPrivate.getDisplayLanguage('en'));
chrome.test.assertEq(
'English',
chrome.accessibilityPrivate.getDisplayLanguage('en-US'));
chrome.test.assertEq(
'français',
chrome.accessibilityPrivate.getDisplayLanguage('fr'));
chrome.test.assertEq(
'日本語',
chrome.accessibilityPrivate.getDisplayLanguage('ja'));
chrome.test.succeed();
}
];
var allTests = [function getDisplayLanguage() {
chrome.test.assertEq(
'', chrome.accessibilityPrivate.getDisplayLanguage('', ''));
chrome.test.assertEq(
'',
chrome.accessibilityPrivate.getDisplayLanguage(
'not a language code', 'ja-JP'));
chrome.test.assertEq(
'',
chrome.accessibilityPrivate.getDisplayLanguage(
'zh-TW', 'not a language code'));
chrome.test.assertEq(
'English', chrome.accessibilityPrivate.getDisplayLanguage('en', 'en'));
chrome.test.assertEq(
'English', chrome.accessibilityPrivate.getDisplayLanguage('en-US', 'en'));
chrome.test.assertEq(
'français',
chrome.accessibilityPrivate.getDisplayLanguage('fr', 'fr-FR'));
chrome.test.assertEq(
'日本語', chrome.accessibilityPrivate.getDisplayLanguage('ja', 'ja'));
chrome.test.assertEq(
'anglais', chrome.accessibilityPrivate.getDisplayLanguage('en', 'fr-CA'));
chrome.test.assertEq(
'Japanese', chrome.accessibilityPrivate.getDisplayLanguage('ja', 'en'));
chrome.test.succeed();
}];
chrome.test.runTests(allTests);
......@@ -1119,6 +1119,17 @@
static AutomationNode getNextTextMatch(
DOMString searchStr, boolean backward);
// Returns the detected languages for the provided string attribute as an
// array of LanguageSpan objects. There are several guarantees about the
// format of the LanguageSpan array:
// 1. Is either empty or contains LanguageSpans that cover all indices in
// the associated string attribute value.
// 2. Is sorted by increasing startIndex (those with smaller startIndex
// appear first).
// 3. LanguageSpans are non-overlapping and contain exactly one language.
static LanguageSpan[] languageAnnotationForStringAttribute(
DOMString attribute);
};
// Called when the <code>AutomationNode</code> for the page is available.
......@@ -1165,16 +1176,5 @@
// the value where the selection starts or ends, respectively.
[nocompile] static void setDocumentSelection(
SetDocumentSelectionParams params);
// Returns the detected languages for the provided string attribute as an
// array of LanguageSpan objects. There are several guarantees about the
// format of the LanguageSpan array:
// 1. Is either empty or contains LanguageSpans that cover all indices in
// the associated string attribute value.
// 2. Is sorted by increasing startIndex (those with smaller startIndex
// appear first).
// 3. LanguageSpans are non-overlapping and contain exactly one language.
[nocompile] LanguageSpan[] languageAnnotationForStringAttribute(
DOMString attribute);
};
};
......@@ -148,12 +148,13 @@ chrome.accessibilityPrivate.FocusType = {
chrome.accessibilityPrivate.FocusRingInfo;
/**
* Called to translate language code into human-readable string in the language
* of the provided language code.
* @param {string} languageCode
* Called to translate languageCodeToTranslate into human-readable string in the
* language specified by targetLanguageCode
* @param {string} languageCodeToTranslate
* @param {string} targetLanguageCode
* @return {string} The human-readable language string in the provided language.
*/
chrome.accessibilityPrivate.getDisplayLanguage = function(languageCode) {};
chrome.accessibilityPrivate.getDisplayLanguage = function(languageCodeToTranslate, targetLanguageCode) {};
/**
* Called to request battery status from Chrome OS system.
......@@ -205,13 +206,6 @@ chrome.accessibilityPrivate.setKeyboardListener = function(enabled, capture) {};
*/
chrome.accessibilityPrivate.darkenScreen = function(enabled) {};
/**
* Change the keyboard keys captured by Switch Access.
* @param {!Array<number>} key_codes The key codes for the keys that will be
* captured.
*/
chrome.accessibilityPrivate.setSwitchAccessKeys = function(key_codes) {};
/**
* Shows or hides the Switch Access menu. If shown, it is at the indicated
* location.
......@@ -283,11 +277,12 @@ chrome.accessibilityPrivate.toggleDictation = function() {};
chrome.accessibilityPrivate.setVirtualKeyboardVisible = function(isVisible) {};
/**
* Opens a settings subpage, specified by the portion of the page's URL after
* "chrome://settings/"
* Opens a specified settings subpage. To open a page with url
* chrome://settings/manageAccessibility/tts, pass in the substring
* 'manageAccessibility/tts'.
* @param {string} subpage
*/
chrome.accessibilityPrivate.openSettingsSubpage = function (subpage) {}
chrome.accessibilityPrivate.openSettingsSubpage = function(subpage) {};
/**
* Fired whenever ChromeVox should output introduction.
......
......@@ -1719,6 +1719,19 @@ chrome.automation.AutomationNode.prototype.matches = function(params) {};
*/
chrome.automation.AutomationNode.prototype.getNextTextMatch = function(searchStr, backward) {};
/**
* Returns the detected languages for the provided string attribute as an array
* of LanguageSpan objects. There are several guarantees about the format of the
* LanguageSpan array: 1. Is either empty or contains LanguageSpans that cover
* all indices in the associated string attribute value. 2. Is sorted by
* increasing startIndex (those with smaller startIndex appear first). 3.
* LanguageSpans are non-overlapping and contain exactly one language.
* @param {string} attribute
* @return {!Array<!chrome.automation.LanguageSpan>}
* @see https://developer.chrome.com/extensions/automation#method-languageAnnotationForStringAttribute
*/
chrome.automation.AutomationNode.prototype.languageAnnotationForStringAttribute = function(attribute) {};
/**
* Get the automation tree for the tab with the given tabId, or the current tab
......@@ -1784,16 +1797,3 @@ chrome.automation.removeTreeChangeObserver = function(observer) {};
* @see https://developer.chrome.com/extensions/automation#method-setDocumentSelection
*/
chrome.automation.setDocumentSelection = function(params) {};
/**
* Returns the detected languages for the provided string attribute as an array
* of LanguageSpan objects. There are several guarantees about the format of the
* LanguageSpan array: 1. Is either empty or contains LanguageSpans that cover
* all indices in the associated string attribute value. 2. Is sorted by
* increasing startIndex (those with smaller startIndex appear first). 3.
* LanguageSpans are non-overlapping and contain exactly one language.
* @param {string} attribute
* @return {!Array<!chrome.automation.LanguageSpan>}
* @see https://developer.chrome.com/extensions/automation#method-languageAnnotationForStringAttribute
*/
chrome.automation.languageAnnotationForStringAttribute = function(attribute) {};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment