0

AccessibilityCommon extension is an IME when Dictation is activated.

Dictation adds AccessibilityCommon extension as an IME when Dictation
is activated, and removes itself when Dictation is deactivated.

Design: go/cros-dictation-extension

Test: dictation_test.js, manual testing
Bug: 1216111
Change-Id: Ic6bd9d53bda5bb3e863153cb7ec3c4c4274460e7
AX-Relnotes: N/A
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2945333
Reviewed-by: dpapad <dpapad@chromium.org>
Reviewed-by: Tetsui Ohkubo <tetsui@chromium.org>
Reviewed-by: Xiyuan Xia <xiyuan@chromium.org>
Reviewed-by: My Nguyen <myy@chromium.org>
Reviewed-by: Matthew Wolenetz <wolenetz@chromium.org>
Reviewed-by: Akihiro Ota <akihiroota@chromium.org>
Commit-Queue: Katie Dektar <katie@chromium.org>
Cr-Commit-Position: refs/heads/master@{#893100}
This commit is contained in:
Katie Dektar
2021-06-16 19:20:10 +00:00
committed by Chromium LUCI CQ
parent 1518adb0ef
commit 9a44cc619c
24 changed files with 679 additions and 38 deletions

@ -143,13 +143,27 @@ void AddTestImes() {
ime1.id = "id1";
ImeInfo ime2;
ime2.id = "id2";
std::vector<ImeInfo> available_imes;
available_imes.push_back(std::move(ime1));
available_imes.push_back(std::move(ime2));
Shell::Get()->ime_controller()->RefreshIme("id1", std::move(available_imes),
std::vector<ImeInfo> visible_imes;
visible_imes.push_back(std::move(ime1));
visible_imes.push_back(std::move(ime2));
Shell::Get()->ime_controller()->RefreshIme("id1", std::move(visible_imes),
std::vector<ImeMenuItem>());
}
void AddNotVisibleTestIme() {
ImeInfo dictation;
dictation.id = "_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation";
const std::vector<ImeInfo> visible_imes =
Shell::Get()->ime_controller()->GetVisibleImes();
std::vector<ImeInfo> available_imes;
for (auto ime : visible_imes) {
available_imes.push_back(ime);
}
available_imes.push_back(dictation);
Shell::Get()->ime_controller()->RefreshIme(
dictation.id, std::move(available_imes), std::vector<ImeMenuItem>());
}
ui::Accelerator CreateReleaseAccelerator(ui::KeyboardCode key_code,
int modifiers) {
ui::Accelerator accelerator(key_code, modifiers);
@ -1426,7 +1440,7 @@ TEST_F(AcceleratorControllerTest, GlobalAcceleratorsToggleAppListFullscreen) {
}
TEST_F(AcceleratorControllerTest, ImeGlobalAccelerators) {
ASSERT_EQ(0u, Shell::Get()->ime_controller()->available_imes().size());
ASSERT_EQ(0u, Shell::Get()->ime_controller()->GetVisibleImes().size());
// Cycling IME is blocked because there is nothing to switch to.
ui::Accelerator control_space_down(ui::VKEY_SPACE, ui::EF_CONTROL_DOWN);
@ -1438,11 +1452,24 @@ TEST_F(AcceleratorControllerTest, ImeGlobalAccelerators) {
EXPECT_FALSE(ProcessInController(control_space_up));
EXPECT_FALSE(ProcessInController(control_shift_space));
// Adding only a not visible IME doesn't make IME accelerators available.
AddNotVisibleTestIme();
ASSERT_EQ(0u, Shell::Get()->ime_controller()->GetVisibleImes().size());
EXPECT_FALSE(ProcessInController(control_space_down));
EXPECT_FALSE(ProcessInController(control_space_up));
EXPECT_FALSE(ProcessInController(control_shift_space));
// Cycling IME works when there are IMEs available.
AddTestImes();
EXPECT_TRUE(ProcessInController(control_space_down));
EXPECT_TRUE(ProcessInController(control_space_up));
EXPECT_TRUE(ProcessInController(control_shift_space));
// Adding the not visible IME back doesn't block cycling.
AddNotVisibleTestIme();
EXPECT_TRUE(ProcessInController(control_space_down));
EXPECT_TRUE(ProcessInController(control_space_up));
EXPECT_TRUE(ProcessInController(control_shift_space));
}
// TODO(nona|mazda): Remove this when crbug.com/139556 in a better way.

@ -31,6 +31,10 @@ enum class ModeChangeKeyAction {
kMaxValue = kSwitchIme
};
// The ID for the Accessibility Common IME (used for Dictation).
const char* kAccessibilityCommonIMEId =
"_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation";
} // namespace
ImeControllerImpl::ImeControllerImpl()
@ -48,6 +52,14 @@ void ImeControllerImpl::RemoveObserver(Observer* observer) {
observers_.RemoveObserver(observer);
}
const std::vector<ImeInfo>& ImeControllerImpl::GetVisibleImes() const {
return visible_imes_;
}
bool ImeControllerImpl::IsCurrentImeVisible() const {
return current_ime_.id != kAccessibilityCommonIMEId;
}
void ImeControllerImpl::SetClient(ImeControllerClient* client) {
if (client_) {
if (CastConfigController::Get())
@ -71,7 +83,7 @@ bool ImeControllerImpl::CanSwitchIme() const {
// Do not consume key event if there is only one input method is enabled.
// Ctrl+Space or Alt+Shift may be used by other application.
return available_imes_.size() > 1;
return GetVisibleImes().size() > 1;
}
void ImeControllerImpl::SwitchToNextIme() {
@ -131,12 +143,17 @@ void ImeControllerImpl::RefreshIme(const std::string& current_ime_id,
available_imes_.clear();
available_imes_.reserve(available_imes.size());
visible_imes_.clear();
visible_imes_.reserve(visible_imes_.size());
for (const auto& ime : available_imes) {
if (ime.id.empty()) {
DLOG(ERROR) << "Received IME with invalid ID.";
continue;
}
available_imes_.push_back(ime);
if (ime.id != kAccessibilityCommonIMEId) {
visible_imes_.push_back(ime);
}
if (ime.id == current_ime_id)
current_ime_ = ime;
}

@ -52,9 +52,10 @@ class ASH_EXPORT ImeControllerImpl : public ImeController,
void AddObserver(Observer* observer);
void RemoveObserver(Observer* observer);
const ImeInfo& current_ime() const { return current_ime_; }
const std::vector<ImeInfo>& GetVisibleImes() const;
bool IsCurrentImeVisible() const;
const std::vector<ImeInfo>& available_imes() const { return available_imes_; }
const ImeInfo& current_ime() const { return current_ime_; }
bool is_extra_input_options_enabled() const {
return is_extra_input_options_enabled_;
@ -147,6 +148,10 @@ class ASH_EXPORT ImeControllerImpl : public ImeController,
// "Available" IMEs are both installed and enabled by the user in settings.
std::vector<ImeInfo> available_imes_;
// "Visible" IMEs are installed, enabled, and don't include built-in IMEs that
// shouldn't be shown to the user, like Dictation.
std::vector<ImeInfo> visible_imes_;
// True if the available IMEs are currently managed by enterprise policy.
// For example, can occur at the login screen with device-level policy.
bool managed_by_policy_ = false;

@ -102,9 +102,9 @@ TEST_F(ImeControllerImplTest, RefreshIme) {
// Cached data was updated.
EXPECT_EQ("ime1", controller->current_ime().id);
ASSERT_EQ(2u, controller->available_imes().size());
EXPECT_EQ("ime1", controller->available_imes()[0].id);
EXPECT_EQ("ime2", controller->available_imes()[1].id);
ASSERT_EQ(2u, controller->GetVisibleImes().size());
EXPECT_EQ("ime1", controller->GetVisibleImes()[0].id);
EXPECT_EQ("ime2", controller->GetVisibleImes()[1].id);
ASSERT_EQ(1u, controller->current_ime_menu_items().size());
EXPECT_EQ("menu1", controller->current_ime_menu_items()[0].key);
@ -118,6 +118,7 @@ TEST_F(ImeControllerImplTest, NoCurrentIme) {
// Set up a single IME.
RefreshImes("ime1", {"ime1"});
EXPECT_EQ("ime1", controller->current_ime().id);
EXPECT_TRUE(controller->IsCurrentImeVisible());
// When there is no current IME the cached current IME is empty.
const std::string empty_ime_id;
@ -125,6 +126,30 @@ TEST_F(ImeControllerImplTest, NoCurrentIme) {
EXPECT_TRUE(controller->current_ime().id.empty());
}
TEST_F(ImeControllerImplTest, CurrentImeNotVisible) {
ImeControllerImpl* controller = Shell::Get()->ime_controller();
// Add only Dictation.
std::string dictation_id =
"_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation";
RefreshImes(dictation_id, {dictation_id});
EXPECT_EQ(dictation_id, controller->current_ime().id);
EXPECT_FALSE(controller->IsCurrentImeVisible());
EXPECT_EQ(0u, controller->GetVisibleImes().size());
// Add something else too, but Dictation is active.
RefreshImes(dictation_id, {dictation_id, "ime1"});
EXPECT_EQ(dictation_id, controller->current_ime().id);
EXPECT_FALSE(controller->IsCurrentImeVisible());
EXPECT_EQ(1u, controller->GetVisibleImes().size());
// Inactivate the other IME, leave Dictation in the list.
RefreshImes("ime1", {dictation_id, "ime1"});
EXPECT_EQ("ime1", controller->current_ime().id);
EXPECT_TRUE(controller->IsCurrentImeVisible());
EXPECT_EQ(1u, controller->GetVisibleImes().size());
}
TEST_F(ImeControllerImplTest, SetImesManagedByPolicy) {
ImeControllerImpl* controller = Shell::Get()->ime_controller();
TestImeObserver observer;
@ -152,7 +177,7 @@ TEST_F(ImeControllerImplTest, CanSwitchIme) {
ImeControllerImpl* controller = Shell::Get()->ime_controller();
// Can't switch IMEs when none are available.
ASSERT_EQ(0u, controller->available_imes().size());
ASSERT_EQ(0u, controller->GetVisibleImes().size());
EXPECT_FALSE(controller->CanSwitchIme());
// Can't switch with only 1 IME.
@ -208,7 +233,7 @@ TEST_F(ImeControllerImplTest, SwitchImeWithAccelerator) {
const ui::Accelerator wide_half_2(ui::VKEY_DBE_DBCSCHAR, ui::EF_NONE);
// When there are no IMEs available switching by accelerator does not work.
ASSERT_EQ(0u, controller->available_imes().size());
ASSERT_EQ(0u, controller->GetVisibleImes().size());
EXPECT_FALSE(controller->CanSwitchImeWithAccelerator(convert));
EXPECT_FALSE(controller->CanSwitchImeWithAccelerator(non_convert));
EXPECT_FALSE(controller->CanSwitchImeWithAccelerator(wide_half_1));

@ -2131,7 +2131,7 @@ void LockContentsView::ShowAuthErrorMessage() {
int bold_length = 0;
// Display a hint to switch keyboards if there are other active input
// methods in clamshell mode.
if (ime_controller->available_imes().size() > 1 && !IsTabletMode()) {
if (ime_controller->GetVisibleImes().size() > 1 && !IsTabletMode()) {
error_text += u" ";
bold_start = error_text.length();
std::u16string shortcut =

@ -21,7 +21,7 @@ namespace {
bool IsButtonVisible() {
DCHECK(Shell::Get());
ImeControllerImpl* ime_controller = Shell::Get()->ime_controller();
size_t ime_count = ime_controller->available_imes().size();
size_t ime_count = ime_controller->GetVisibleImes().size();
return !ime_controller->is_menu_active() &&
(ime_count > 1 || ime_controller->managed_by_policy());
}
@ -29,7 +29,7 @@ bool IsButtonVisible() {
std::u16string GetLabelString() {
DCHECK(Shell::Get());
ImeControllerImpl* ime_controller = Shell::Get()->ime_controller();
size_t ime_count = ime_controller->available_imes().size();
size_t ime_count = ime_controller->GetVisibleImes().size();
if (ime_count > 1) {
return ime_controller->current_ime().short_name;
} else {
@ -42,7 +42,7 @@ std::u16string GetLabelString() {
std::u16string GetTooltipString() {
DCHECK(Shell::Get());
ImeControllerImpl* ime_controller = Shell::Get()->ime_controller();
size_t ime_count = ime_controller->available_imes().size();
size_t ime_count = ime_controller->GetVisibleImes().size();
if (ime_count > 1) {
return l10n_util::GetStringFUTF16(IDS_ASH_STATUS_TRAY_IME_TOOLTIP_WITH_NAME,
ime_controller->current_ime().name);

@ -75,7 +75,7 @@ void UnifiedIMEDetailedViewController::OnIMEMenuActivationChanged(
void UnifiedIMEDetailedViewController::Update() {
ImeControllerImpl* ime_controller = Shell::Get()->ime_controller();
view_->Update(ime_controller->current_ime().id,
ime_controller->available_imes(),
ime_controller->GetVisibleImes(),
ime_controller->current_ime_menu_items(),
ShouldShowKeyboardToggle(), GetSingleImeBehavior());
}

@ -209,7 +209,7 @@ ImeListView::~ImeListView() = default;
void ImeListView::Init(bool show_keyboard_toggle,
SingleImeBehavior single_ime_behavior) {
ImeControllerImpl* ime_controller = Shell::Get()->ime_controller();
Update(ime_controller->current_ime().id, ime_controller->available_imes(),
Update(ime_controller->current_ime().id, ime_controller->GetVisibleImes(),
ime_controller->current_ime_menu_items(), show_keyboard_toggle,
single_ime_behavior);
}

@ -486,7 +486,7 @@ void ImeMenuTray::OnIMERefresh() {
UpdateTrayLabel();
if (bubble_ && ime_list_view_) {
ime_list_view_->Update(
ime_controller_->current_ime().id, ime_controller_->available_imes(),
ime_controller_->current_ime().id, ime_controller_->GetVisibleImes(),
ime_controller_->current_ime_menu_items(), ShouldShowKeyboardToggle(),
ImeListView::SHOW_SINGLE_IME);
}

@ -164,6 +164,39 @@ TEST_F(ImeMenuTrayTest, TrayLabelTest) {
EXPECT_EQ(u"UK*", GetTrayText());
}
TEST_F(ImeMenuTrayTest, TrayLabelExludesDictation) {
Shell::Get()->ime_controller()->ShowImeMenuOnShelf(true);
ASSERT_TRUE(IsVisible());
ImeInfo info1;
info1.id = "ime1";
info1.name = u"English";
info1.short_name = u"US";
info1.third_party = false;
ImeInfo info2;
info2.id = "ime2";
info2.name = u"English UK";
info2.short_name = u"UK";
info2.third_party = true;
ImeInfo dictation;
dictation.id = "_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation";
dictation.name = u"Dictation";
// Changes the input method to "ime1".
SetCurrentIme("ime1", {info1, dictation, info2});
EXPECT_EQ(u"US", GetTrayText());
// Changes the input method to a third-party IME extension.
SetCurrentIme("ime2", {info1, dictation, info2});
EXPECT_EQ(u"UK*", GetTrayText());
// Sets to "dictation", which shouldn't be shown.
SetCurrentIme(dictation.id, {info1, dictation, info2});
EXPECT_EQ(u"", GetTrayText());
}
// Tests that IME menu tray changes background color when tapped/clicked. And
// tests that the background color becomes 'inactive' when disabling the IME
// menu feature. Also makes sure that the shelf won't autohide as long as the

@ -92,7 +92,12 @@ void ImeModeView::Update() {
ImeControllerImpl* ime_controller = Shell::Get()->ime_controller();
size_t ime_count = ime_controller->available_imes().size();
if (!ime_controller->IsCurrentImeVisible()) {
SetVisible(false);
return;
}
size_t ime_count = ime_controller->GetVisibleImes().size();
SetVisible(!ime_menu_on_shelf_activated_ &&
(ime_count > 1 || ime_controller->managed_by_policy()));

@ -26,7 +26,8 @@ namespace {
// 5. Hotwording component extension.
// 6. XKB input method component extension.
// 7. M17n/T13n/CJK input method component extension.
// Once http://crbug.com/292856 is fixed, remove this whitelist.
// 8. Accessibility Common extension (used for Dictation)
// Once http://crbug.com/292856 is fixed, remove this allowlist.
bool IsMediaRequestAllowedForExtension(const extensions::Extension* extension) {
return extension->id() == "mppnpdlheglhdfmldimlhpnegondlapf" ||
extension->id() == "jokbpnebhdcladagohdnfgjcpejggllo" ||
@ -34,7 +35,8 @@ bool IsMediaRequestAllowedForExtension(const extensions::Extension* extension) {
extension->id() == "nnckehldicaciogcbchegobnafnjkcne" ||
extension->id() == "nbpagnldghgfoolbancepceaanlmhfmd" ||
extension->id() == "jkghodnilhceideoidjikpgommlajknk" ||
extension->id() == "gjaehgfemfahhmlgpdfknkhdnemmolop";
extension->id() == "gjaehgfemfahhmlgpdfknkhdnemmolop" ||
extension->id() == "egfdjlfmgnehecnclamagfafdccgfndp";
}
} // namespace

@ -71,6 +71,9 @@ js2gtest("accessibility_common_extjs_tests") {
"../common/testing/callback_helper.js",
"../common/testing/e2e_test_base.js",
"../common/testing/mock_accessibility_private.js",
"../common/testing/mock_input_ime.js",
"../common/testing/mock_input_method_private.js",
"../common/testing/mock_language_settings_private.js",
]
# The test base classes generate C++ code with these deps.
@ -103,6 +106,8 @@ js_library("accessibility_common") {
"$externs_path/accessibility_private.js",
"$externs_path/automation.js",
"$externs_path/command_line_private.js",
"$externs_path/input_method_private.js",
"$externs_path/language_settings_private.js",
]
}
@ -122,4 +127,9 @@ js_library("magnifier") {
js_library("dictation") {
sources = [ "dictation/dictation.js" ]
externs_list = [
"$externs_path/accessibility_private.js",
"$externs_path/input_method_private.js",
"$externs_path/language_settings_private.js",
]
}

@ -60,6 +60,11 @@ export class AccessibilityCommon {
{}, this.onDictationUpdated_.bind(this));
chrome.accessibilityFeatures.dictation.onChange.addListener(
this.onDictationUpdated_.bind(this));
// AccessibilityCommon is an IME so it shows in the input methods list
// when it starts up. Remove from this list, Dictation will add it back
// whenever needed.
Dictation.removeAsInputMethod();
}
/**

@ -2,6 +2,24 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Dictation states.
* @enum {!number}
*/
const DictationState = {
OFF: 1,
STARTING: 2,
LISTENING: 3,
STOPPING: 4,
};
/**
* The IME engine ID for AccessibilityCommon.
* @private {string}
* @const
*/
const IME_ENGINE_ID = '_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';
/**
* Main class for the Chrome OS dictation feature.
* Please note: this is being developed behind the flag
@ -11,6 +29,24 @@ export class Dictation {
constructor() {
chrome.accessibilityPrivate.onToggleDictation.addListener(
this.onToggleDictation_.bind(this));
chrome.input.ime.onFocus.addListener(this.onImeFocus_.bind(this));
chrome.input.ime.onBlur.addListener(this.onImeBlur_.bind(this));
/** @private {number} */
this.activeImeContextId_ = -1;
/**
* The engine ID of the previously active IME input method. Used to
* restore the previous IME after Dictation is deactivated.
* @private {string}
*/
this.previousImeEngineId_ = '';
/**
* The state of Dictation.
* @private {!DictationState}
*/
this.state_ = DictationState.OFF;
}
/**
@ -19,10 +55,86 @@ export class Dictation {
* @private
*/
onToggleDictation_(activated) {
if (activated) {
// Dictation as a JS extension isn't actually implemented yet, so just
// turn off again.
chrome.accessibilityPrivate.toggleDictation();
if (activated && this.state_ === DictationState.OFF) {
this.state_ = DictationState.STARTING;
chrome.inputMethodPrivate.getCurrentInputMethod((method) => {
if (this.state_ !== DictationState.STARTING) {
return;
}
this.previousImeEngineId_ = method;
// Add AccessibilityCommon as an input method and active it.
chrome.languageSettingsPrivate.addInputMethod(IME_ENGINE_ID);
chrome.inputMethodPrivate.setCurrentInputMethod(IME_ENGINE_ID, () => {
if (this.state_ === DictationState.STARTING) {
// TODO(crbug.com/1216111): Start speech recognition and
// change state to LISTENING after SR starts.
} else {
// We are no longer starting up - perhaps a stop came
// through during the async callbacks. Ensure cleanup
// by calling onDictationStopped_.
this.onDictationStopped_();
}
});
});
} else {
this.onDictationStopped_();
}
}
/**
* Stops Dictation in the browser / ash if it wasn't already stopped.
* @private
*/
stopDictation_() {
// Stop Dictation if the state isn't already off.
if (this.state_ !== DictationState.OFF) {
chrome.accessibilityPrivate.toggleDictation();
this.state_ = DictationState.STOPPING;
}
}
/**
* Called when Dictation has been toggled off. Cleans up IME and local state.
* @private
*/
onDictationStopped_() {
if (this.state_ === DictationState.OFF) {
return;
}
this.state_ = DictationState.OFF;
// Clean up IME state and reset to the previous IME method.
this.activeImeContextId_ = -1;
chrome.inputMethodPrivate.setCurrentInputMethod(this.previousImeEngineId_);
this.previousImeEngineId_ = '';
Dictation.removeAsInputMethod();
}
/**
* chrome.input.ime.onFocus callback. Save the active context ID.
* @param {chrome.input.ime.InputContext} context Input field context.
* @private
*/
onImeFocus_(context) {
this.activeImeContextId_ = context.contextID;
}
/**
* chrome.input.ime.onFocus callback. Stops Dictation if the active
* context ID lost focus.
* @param {number} contextId
* @private
*/
onImeBlur_(contextId) {
if (contextId === this.activeImeContextId_) {
this.stopDictation_();
}
}
/**
* Removes AccessibilityCommon as an input method so it doesn't show up in
* the shelf input method picker UI.
*/
static removeAsInputMethod() {
chrome.languageSettingsPrivate.removeInputMethod(IME_ENGINE_ID);
}
}

@ -4,11 +4,38 @@
GEN_INCLUDE(['../../common/testing/e2e_test_base.js']);
GEN_INCLUDE(['../../common/testing/mock_accessibility_private.js']);
GEN_INCLUDE(['../../common/testing/mock_input_ime.js']);
GEN_INCLUDE(['../../common/testing/mock_input_method_private.js']);
GEN_INCLUDE(['../../common/testing/mock_language_settings_private.js']);
/**
* Dictation feature using accessibility common extension browser tests.
*/
DictationE2ETest = class extends E2ETestBase {
constructor() {
super();
this.mockAccessibilityPrivate = MockAccessibilityPrivate;
chrome.accessibilityPrivate = this.mockAccessibilityPrivate;
this.mockInputIme = MockInputIme;
chrome.input.ime = this.mockInputIme;
this.mockInputMethodPrivate = MockInputMethodPrivate;
chrome.inputMethodPrivate = this.mockInputMethodPrivate;
this.mockLanguageSettingsPrivate = MockLanguageSettingsPrivate;
chrome.languageSettingsPrivate = this.mockLanguageSettingsPrivate;
this.dictationEngineId =
'_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';
// Re-initialize AccessibilityCommon with mock APIs.
const reinit = module => {
accessibilityCommon = new module.AccessibilityCommon();
};
import('/accessibility_common/accessibility_common_loader.js').then(reinit);
}
/** @override */
testGenCppIncludes() {
super.testGenCppIncludes();
@ -39,12 +66,125 @@ DictationE2ETest = class extends E2ETestBase {
`);
super.testGenPreambleCommon('kAccessibilityCommonExtensionId');
}
};
TEST_F('DictationE2ETest', 'SanityCheck', function() {
this.newCallback(async () => {
/**
* Waits for Dictation module to be loaded.
*/
async waitForDictationModule() {
await importModule(
'Dictation', '/accessibility_common/dictation/dictation.js');
assertNotNullNorUndefined(Dictation);
// Enable Dictation.
await new Promise(resolve => {
chrome.accessibilityFeatures.dictation.set({value: true}, resolve);
});
return new Promise(resolve => {
resolve();
});
}
/**
* Generates a function that runs a callback after the Dictation module has
* loaded.
* @param {function<>} callback
* @returns {function<>}
*/
runAfterDictationLoad(callback) {
return this.newCallback(async () => {
await this.waitForDictationModule();
callback();
});
}
/**
* Checks that Dictation is the active IME.
*/
checkDictationImeActive() {
assertEquals(
this.dictationEngineId,
this.mockInputMethodPrivate.getCurrentInputMethodForTest());
assertTrue(this.mockLanguageSettingsPrivate.hasInputMethod(
this.dictationEngineId));
}
/*
* Checks that Dictation is not the active IME.
* @param {*} opt_activeImeId If we do not expect Dictation IME to be
* activated, an optional IME ID that we do expect to be activated.
*/
checkDictationImeInactive(opt_activeImeId) {
assertNotEquals(
this.dictationEngineId,
this.mockInputMethodPrivate.getCurrentInputMethodForTest());
assertFalse(this.mockLanguageSettingsPrivate.hasInputMethod(
this.dictationEngineId));
if (opt_activeImeId) {
assertEquals(
opt_activeImeId,
this.mockInputMethodPrivate.getCurrentInputMethodForTest());
}
}
};
TEST_F('DictationE2ETest', 'SanityCheck', function() {
this.runAfterDictationLoad(() => {
assertFalse(this.mockAccessibilityPrivate.getDictationActive());
})();
});
TEST_F('DictationE2ETest', 'LoadsIMEWhenEnabled', function() {
this.runAfterDictationLoad(() => {
this.checkDictationImeInactive();
this.mockAccessibilityPrivate.callOnToggleDictation(true);
assertTrue(this.mockAccessibilityPrivate.getDictationActive());
this.checkDictationImeActive();
// Turn off Dictation and make sure it removes as IME
this.mockAccessibilityPrivate.callOnToggleDictation(false);
assertFalse(this.mockAccessibilityPrivate.getDictationActive());
this.checkDictationImeInactive();
})();
});
TEST_F('DictationE2ETest', 'TogglesDictationOffWhenIMEBlur', function() {
this.runAfterDictationLoad(() => {
this.checkDictationImeInactive();
this.mockAccessibilityPrivate.callOnToggleDictation(true);
assertTrue(this.mockAccessibilityPrivate.getDictationActive());
this.checkDictationImeActive();
// Focus an input context.
this.mockInputIme.callOnFocus(1);
// Blur the input context. Dictation should get toggled off.
this.mockInputIme.callOnBlur(1);
assertFalse(this.mockAccessibilityPrivate.getDictationActive());
// Now that we've confirmed that Dictation JS tried to toggle Dictation,
// via AccessibilityPrivate, we can call the onToggleDictation
// callback as AccessibilityManager would do, to allow Dictation JS to clean
// up state.
this.mockAccessibilityPrivate.callOnToggleDictation(false);
this.checkDictationImeInactive();
})();
});
TEST_F('DictationE2ETest', 'ResetsPreviousIMEAfterDeactivate', function() {
this.runAfterDictationLoad(() => {
// Set something as the active IME.
this.mockInputMethodPrivate.setCurrentInputMethod('keyboard_cat');
this.mockLanguageSettingsPrivate.addInputMethod('keyboard_cat');
// Activate Dictation.
this.mockAccessibilityPrivate.callOnToggleDictation(true);
assertTrue(this.mockAccessibilityPrivate.getDictationActive());
this.checkDictationImeActive();
// Deactivate Dictation.
this.mockAccessibilityPrivate.callOnToggleDictation(false);
this.checkDictationImeInactive('keyboard_cat');
})();
});

@ -17,10 +17,22 @@
"accessibilityFeatures.read",
"accessibilityFeatures.modify",
"commandLinePrivate",
"settingsPrivate"
"input",
"inputMethodPrivate",
"settingsPrivate",
"languageSettingsPrivate"
],
"automation": {
"desktop": true
},
"default_locale": "en"
"default_locale": "en",
"input_components": [
{
"name": "Dictation",
"type": "ime",
"id": "dictation",
"description": "Dictation",
"language": ["none"]
}
]
}

@ -49,6 +49,12 @@ var MockAccessibilityPrivate = {
/** @private {?string} */
highlightColor_: null,
/** @private {function<boolean>} */
dictationToggleListener_: null,
/** @private {boolean} */
dictationActivated_: false,
// Methods from AccessibilityPrivate API. //
onScrollableBoundsForPointRequested: {
@ -62,6 +68,7 @@ var MockAccessibilityPrivate = {
/**
* Removes the listener.
* @param {function<number, number>} listener
*/
removeListener: (listener) => {
if (MockAccessibilityPrivate.boundsListener_ === listener) {
@ -70,10 +77,8 @@ var MockAccessibilityPrivate = {
}
},
onMagnifierBoundsChanged: {
addListener: (listener) => {},
removeListener: (listener) => {}
},
onMagnifierBoundsChanged:
{addListener: (listener) => {}, removeListener: (listener) => {}},
onSelectToSpeakPanelAction: {
/**
@ -86,6 +91,26 @@ var MockAccessibilityPrivate = {
},
},
onToggleDictation: {
/**
* Adds a listener to onToggleDictation.
* @param {function<boolean>} listener
*/
addListener: (listener) => {
MockAccessibilityPrivate.dictationToggleListener_ = listener;
},
/**
* Removes the listener.
* @param {function<boolean>} listener
*/
removeListener: (listener) => {
if (MockAccessibilityPrivate.dictationToggleListener_ === listener) {
MockAccessibilityPrivate.dictationToggleListener_ = null;
}
}
},
onSelectToSpeakStateChangeRequested: {
/**
* Adds a listener to onSelectToSpeakStateChangeRequested.
@ -148,6 +173,14 @@ var MockAccessibilityPrivate = {
.selectToSpeakPanelState_ = {show, anchor, isPaused, speed};
},
/**
* Called in order to toggle Dictation listening.
*/
toggleDictation: () => {
MockAccessibilityPrivate.dictationActivated_ =
!MockAccessibilityPrivate.dictationActivated_;
},
// Methods for testing. //
/**
@ -239,4 +272,26 @@ var MockAccessibilityPrivate = {
MockAccessibilityPrivate.selectToSpeakStateChangeListener_();
}
},
/**
* Simulates Dictation activation change from AccessibilityManager, which may
* occur when the user or a chrome extension toggles Dictation active state.
* @param {boolean} activated
*/
callOnToggleDictation: (activated) => {
MockAccessibilityPrivate.dictationActivated_ = activated;
if (MockAccessibilityPrivate.dictationToggleListener_) {
MockAccessibilityPrivate.dictationToggleListener_(activated);
}
},
/**
* Gets the current Dictation active state. This can be flipped when
* MockAccessibilityPrivate.toggleDictation is called, and set when
* MocakAccessibilityPrivate.callOnToggleDictation is called.
* @returns {boolean} The current Dictation active state.
*/
getDictationActive() {
return MockAccessibilityPrivate.dictationActivated_;
},
};

@ -0,0 +1,86 @@
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @typedef {{
* contextID: number,
* }}
*/
let InputContext;
/*
* A mock chrome.input.ime API for tests.
*/
var MockInputIme = {
/** @private {function<InputContext>} */
onFocusListener_: null,
/** @private {function<number>} */
onBlurListener_: null,
// Methods from chrome.input.ime API. //
onFocus: {
/**
* Adds a listener to onFocus.
* @param {function<InputContext>} listener
*/
addListener: (listener) => {
MockInputIme.onFocusListener_ = listener;
},
/**
* Removes the listener.
* @param {function<InputContext>} listener
*/
removeListener: (listener) => {
if (MockInputIme.onFocusListener_ === listener) {
MockInputIme.onFocusListener_ = null;
}
}
},
onBlur: {
/**
* Adds a listener to onBlur.
* @param {function<number>} listener
*/
addListener: (listener) => {
MockInputIme.onBlurListener_ = listener;
},
/**
* Removes the listener.
* @param {function<number>} listener
*/
removeListener: (listener) => {
if (MockInputIme.onBlurListener_ === listener) {
MockInputIme.onBlurListener_ = null;
}
}
},
// Methods for testing. //
/**
* Calls listeners for chrome.input.ime.onFocus with a InputContext with the
* given contextID.
* @param {number} contextID
*/
callOnFocus(contextID) {
if (MockInputIme.onFocusListener_) {
MockInputIme.onFocusListener_({contextID});
}
},
/**
* Calls listeners for chrome.input.ime.onBlur with the given contextID.
* @param {number} contextID
*/
callOnBlur(contextID) {
if (MockInputIme.onBlurListener_) {
MockInputIme.onBlurListener_(contextID);
}
},
};

@ -0,0 +1,42 @@
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* A mock chrome.inputMethodPrivate API for tests.
*/
var MockInputMethodPrivate = {
/** @private {string} */
currentInputMethod_: '',
// Methods from chrome.inputMethodPrivate API. //
/**
* Gets the current input method.
* @param {function<string>} callback
*/
getCurrentInputMethod(callback) {
callback(this.currentInputMethod_);
},
/**
* Sets the current input method.
* @param {string} inputMethodId The input method to set.
* @param {function<>} callback Callback called on success.
*/
setCurrentInputMethod(inputMethodId, callback) {
MockInputMethodPrivate.currentInputMethod_ = inputMethodId;
callback && callback();
},
// Methods for testing. //
/**
* Gets the current input method.
* @return {string}
*/
getCurrentInputMethodForTest() {
return MockInputMethodPrivate.currentInputMethod_;
},
};

@ -0,0 +1,39 @@
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/*
* A mock chrome.languageSettingsPrivate API for tests.
*/
var MockLanguageSettingsPrivate = {
/** @private {array<string>} */
inputMethods: [],
// Methods from chrome.languageSettingsPrivate API. //
/**
* Adds an input method ID.
* @param {string} methodId
*/
addInputMethod(methodId) {
MockLanguageSettingsPrivate.inputMethods.push(methodId);
},
removeInputMethod(methodId) {
const index = MockLanguageSettingsPrivate.inputMethods.indexOf(methodId);
if (index >= 0) {
MockLanguageSettingsPrivate.inputMethods.splice(index, 1);
}
},
// Methods for testing. //
/**
* Checks if an input method exists.
* @param {stromg} methodId
* @return {boolean} True if the method is present.
*/
hasInputMethod(methodId) {
return MockLanguageSettingsPrivate.inputMethods.includes(methodId);
},
};

@ -2,6 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// The IME ID for the Accessibility Common extension used by Dictation.
/** @type {string} */
const ACCESSIBILITY_COMMON_IME_ID =
'_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';
/**
* @fileoverview 'os-settings-add-input-methods-dialog' is a dialog for
* adding input methods.
@ -92,6 +97,10 @@ Polymer({
if (this.languageHelper.isInputMethodEnabled(inputMethod.id)) {
return false;
}
// Don't show the Dictation (Accessibility Common) extension in this list.
if (inputMethod.id === ACCESSIBILITY_COMMON_IME_ID) {
return false;
}
// Show input methods whose tags match the query.
return inputMethod.tags.some(
tag => tag.toLocaleLowerCase().includes(this.lowercaseQueryString_));

@ -2,6 +2,11 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// The IME ID for the Accessibility Common extension used by Dictation.
/** @type {string} */
const ACCESSIBILITY_COMMON_IME_ID =
'_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';
/**
* @fileoverview
* 'os-settings-languages-section' is the top-level settings section for
@ -106,6 +111,9 @@ Polymer({
* @private
*/
getInputMethodDisplayName_(id, languageHelper) {
if (id === ACCESSIBILITY_COMMON_IME_ID) {
return '';
}
// LanguageHelper.getInputMethodDisplayName will throw an error if the ID
// isn't found, such as when using CrOS on Linux.
try {

@ -47,6 +47,13 @@ const kTranslateLanguageSynonyms = {
// one in ui/base/ime/chromeos/extension_ime_util.h.
const kArcImeLanguage = '_arc_ime_language_';
// <if expr="chromeos">
// The IME ID for the Accessibility Common extension used by Dictation.
/** @type {string} */
const ACCESSIBILITY_COMMON_IME_ID =
'_ext_ime_egfdjlfmgnehecnclamagfafdccgfndpdictation';
// </if>
let preferredLanguagesPrefName = 'intl.accept_languages';
// <if expr="chromeos">
preferredLanguagesPrefName = 'settings.language.preferred_languages';
@ -1214,11 +1221,13 @@ Polymer({
.value.split(','));
this.enabledInputMethodSet_ = new Set(enabledInputMethodIds);
// Return only supported input methods.
// Return only supported input methods. Don't include the Dictation
// (Accessibility Common) input method.
return enabledInputMethodIds
.map(id => this.supportedInputMethodMap_.get(id))
.filter(function(inputMethod) {
return !!inputMethod;
return !!inputMethod &&
inputMethod.id !== ACCESSIBILITY_COMMON_IME_ID;
});
},